Files
SingBox-Gopanel/internal/handler/realname_api.go
CN-JS-HuiBai d077eae2f6
All checks were successful
build / build (api, amd64, linux) (push) Successful in -49s
build / build (api, arm64, linux) (push) Successful in -47s
build / build (api.exe, amd64, windows) (push) Successful in -47s
移除被错误提交的测试用exe文件
修复前后端逻辑
2026-04-17 10:40:05 +08:00

394 lines
11 KiB
Go

package handler
import (
"strconv"
"strings"
"time"
"xboard-go/internal/database"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/gin-gonic/gin"
)
const unverifiedExpiration = int64(946684800)
func PluginRealNameStatus(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
return
}
auth := realNameRecordForUser(user)
status := "unverified"
var realName, masked, rejectReason string
var submittedAt, reviewedAt int64
if auth != nil {
status = auth.Status
realName = auth.RealName
masked = auth.IdentityMasked
rejectReason = auth.RejectReason
submittedAt = auth.SubmittedAt
reviewedAt = auth.ReviewedAt
}
canSubmit := status == "unverified" || (status == "rejected" && service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true))
Success(c, gin.H{
"status": status,
"status_label": realNameStatusLabel(status),
"is_inherited": strings.Contains(user.Email, "-ipv6@"),
"can_submit": canSubmit,
"real_name": realName,
"identity_no_masked": masked,
"notice": service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please submit real-name information."),
"reject_reason": rejectReason,
"submitted_at": submittedAt,
"reviewed_at": reviewedAt,
})
}
func PluginRealNameSubmit(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
Fail(c, 401, "unauthorized")
return
}
var req struct {
RealName string `json:"real_name" binding:"required"`
IdentityNo string `json:"identity_no" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, 422, "missing identity information")
return
}
now := time.Now().Unix()
record := model.RealNameAuth{
UserID: uint64(user.ID),
RealName: req.RealName,
IdentityMasked: maskIdentity(req.IdentityNo),
IdentityEncrypted: req.IdentityNo,
Status: "pending",
SubmittedAt: now,
ReviewedAt: 0,
CreatedAt: now,
UpdatedAt: now,
}
var existing model.RealNameAuth
status := "pending"
reviewedAt := int64(0)
if service.GetPluginConfigBool(service.PluginRealNameVerification, "auto_approve", false) {
status = "approved"
reviewedAt = now
}
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
if existing.Status == "approved" {
Fail(c, 400, "verification already approved")
return
}
if existing.Status == "pending" {
Fail(c, 400, "verification is pending review")
return
}
if existing.Status == "rejected" && !service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true) {
Fail(c, 400, "rejected records cannot be resubmitted")
return
}
record.ID = existing.ID
record.CreatedAt = existing.CreatedAt
record.Status = status
record.ReviewedAt = reviewedAt
record.RejectReason = ""
database.DB.Model(&existing).Updates(record)
} else {
record.Status = status
record.ReviewedAt = reviewedAt
database.DB.Create(&record)
}
syncRealNameExpiration(user.ID, status)
SuccessMessage(c, "verification submitted", gin.H{"status": status})
}
func PluginRealNameRecords(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
keyword := strings.TrimSpace(c.Query("keyword"))
query := database.DB.Table("v2_user AS u").
Select("u.id, u.email, r.status, r.real_name, r.identity_masked, r.submitted_at, r.reviewed_at").
Joins("LEFT JOIN v2_realname_auth AS r ON u.id = r.user_id").
Where("u.email NOT LIKE ?", "%-ipv6@%")
if keyword != "" {
query = query.Where("u.email LIKE ?", "%"+keyword+"%")
}
var total int64
query.Count(&total)
type recordRow struct {
ID int
Email string
Status *string
RealName *string
IdentityMasked *string
SubmittedAt *int64
ReviewedAt *int64
}
var rows []recordRow
if err := query.Offset((page - 1) * perPage).Limit(perPage).Scan(&rows).Error; err != nil {
Fail(c, 500, "failed to fetch records")
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
status := "unverified"
if row.Status != nil && *row.Status != "" {
status = *row.Status
}
items = append(items, gin.H{
"id": row.ID,
"email": row.Email,
"status": status,
"status_label": realNameStatusLabel(status),
"real_name": stringPointerValue(row.RealName),
"identity_no_masked": stringPointerValue(row.IdentityMasked),
"submitted_at": int64PointerValue(row.SubmittedAt),
"reviewed_at": int64PointerValue(row.ReviewedAt),
})
}
Success(c, gin.H{
"status": "success",
"data": items,
"pagination": gin.H{
"total": total,
"current": page,
"last_page": calculateLastPage(total, perPage),
},
})
}
func PluginRealNameReview(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
return
}
var req struct {
Status string `json:"status"`
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&req)
if req.Status == "" {
req.Status = "approved"
}
now := time.Now().Unix()
var record model.RealNameAuth
if err := database.DB.Where("user_id = ?", userID).First(&record).Error; err == nil {
record.Status = req.Status
record.RejectReason = req.Reason
record.ReviewedAt = now
record.UpdatedAt = now
database.DB.Save(&record)
} else {
database.DB.Create(&model.RealNameAuth{
UserID: uint64(userID),
Status: req.Status,
RealName: "admin approved",
IdentityMasked: "admin approved",
SubmittedAt: now,
ReviewedAt: now,
RejectReason: req.Reason,
CreatedAt: now,
UpdatedAt: now,
})
}
syncRealNameExpiration(userID, req.Status)
SuccessMessage(c, "review updated", true)
}
func PluginRealNameReset(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
return
}
database.DB.Where("user_id = ?", userID).Delete(&model.RealNameAuth{})
syncRealNameExpiration(userID, "unverified")
SuccessMessage(c, "record reset", true)
}
func PluginRealNameSyncAll(c *gin.Context) {
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
}
func PluginRealNameApproveAll(c *gin.Context) {
var users []model.User
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)
now := time.Now().Unix()
for _, user := range users {
var record model.RealNameAuth
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
record.Status = "approved"
record.RealName = "admin approved"
record.IdentityMasked = "admin approved"
record.SubmittedAt = now
record.ReviewedAt = now
record.UpdatedAt = now
database.DB.Save(&record)
} else {
database.DB.Create(&model.RealNameAuth{
UserID: uint64(user.ID),
Status: "approved",
RealName: "admin approved",
IdentityMasked: "admin approved",
SubmittedAt: now,
ReviewedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
}
}
SuccessMessage(c, "all users approved", performGlobalRealNameSync())
}
func PluginRealNameClearCache(c *gin.Context) {
_ = database.CacheDelete("realname:sync")
SuccessMessage(c, "cache cleared", true)
}
func realNameRecordForUser(user *model.User) *model.RealNameAuth {
if user == nil {
return nil
}
var record model.RealNameAuth
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
return &record
}
if strings.Contains(user.Email, "-ipv6@") {
mainEmail := strings.Replace(user.Email, "-ipv6@", "@", 1)
var mainUser model.User
if err := database.DB.Where("email = ?", mainEmail).First(&mainUser).Error; err == nil {
if err := database.DB.Where("user_id = ?", mainUser.ID).First(&record).Error; err == nil {
return &record
}
}
}
return nil
}
func syncRealNameExpiration(userID int, status string) {
if status == "approved" {
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"expired_at": parseExpirationSetting(),
"updated_at": time.Now().Unix(),
})
return
}
if !service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false) {
database.DB.Model(&model.User{}).Where("id = ?", userID).Update("updated_at", time.Now().Unix())
return
}
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"expired_at": unverifiedExpiration,
"updated_at": time.Now().Unix(),
})
}
func parseExpirationSetting() int64 {
raw := service.GetPluginConfigString(service.PluginRealNameVerification, "verified_expiration_date", "2099-12-31")
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
return value
}
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
return parsed.Unix()
}
return time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
}
func performGlobalRealNameSync() gin.H {
expireApproved := parseExpirationSetting()
now := time.Now().Unix()
enforce := service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false)
type approvedRow struct {
UserID int
}
var approvedRows []approvedRow
database.DB.Table("v2_realname_auth").Select("user_id").Where("status = ?", "approved").Scan(&approvedRows)
approvedUsers := make([]int, 0, len(approvedRows))
for _, row := range approvedRows {
approvedUsers = append(approvedUsers, row.UserID)
}
if len(approvedUsers) > 0 {
database.DB.Model(&model.User{}).Where("id IN ?", approvedUsers).
Updates(map[string]any{"expired_at": expireApproved, "updated_at": now})
if enforce {
database.DB.Model(&model.User{}).Where("id NOT IN ?", approvedUsers).
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
}
} else {
if enforce {
database.DB.Model(&model.User{}).
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
}
}
return gin.H{
"enforce_real_name": enforce,
"total_verified": len(approvedUsers),
"actual_synced": len(approvedUsers),
}
}
func realNameStatusLabel(status string) string {
switch status {
case "approved":
return "approved"
case "pending":
return "pending"
case "rejected":
return "rejected"
default:
return "unverified"
}
}
func maskIdentity(identity string) string {
if len(identity) <= 8 {
return identity
}
return identity[:4] + "**********" + identity[len(identity)-4:]
}
func stringPointerValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func int64PointerValue(value *int64) int64 {
if value == nil {
return 0
}
return *value
}