package app import ( "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/hex" "errors" "strings" "time" "git.echol.cn/loser/Go-Web-Template/server/global" appModel "git.echol.cn/loser/Go-Web-Template/server/model/app" "gorm.io/gorm" "gorm.io/gorm/clause" ) type AppInviteCodeService struct{} var AppInviteCodeServiceApp = new(AppInviteCodeService) type InviteCodeGenerated struct { Code string `json:"code"` CodeLast4 string `json:"codeLast4"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` } func sha256Hex(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } func generateCode() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } enc := base32.StdEncoding.WithPadding(base32.NoPadding) return strings.ToLower(enc.EncodeToString(buf)), nil } func (s *AppInviteCodeService) GetCurrentUnused(userID uint) (*appModel.AppInviteCode, error) { var record appModel.AppInviteCode now := time.Now() err := global.GVA_DB. Where("created_by_user_id = ? AND status = ?", userID, appModel.InviteCodeStatusUnused). Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now)). Order("id desc"). First(&record).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &record, err } func (s *AppInviteCodeService) Generate(userID uint) (*InviteCodeGenerated, error) { expireHours := GetInviteExpireHours() if expireHours <= 0 { expireHours = 24 } // 单活跃未使用码:存在则拒绝生成 existing, err := s.GetCurrentUnused(userID) if err != nil { return nil, err } if existing != nil { return nil, errors.New("已有未使用邀请码") } code, err := generateCode() if err != nil { return nil, err } last4 := "" if len(code) >= 4 { last4 = code[len(code)-4:] } t := time.Now().Add(time.Duration(expireHours) * time.Hour) expiresAt := &t record := appModel.AppInviteCode{ CreatedByUserID: userID, CodeHash: sha256Hex(code), CodeLast4: last4, Status: appModel.InviteCodeStatusUnused, ExpiresAt: expiresAt, } if err := global.GVA_DB.Create(&record).Error; err != nil { return nil, err } return &InviteCodeGenerated{Code: code, CodeLast4: last4, ExpiresAt: expiresAt}, nil } func (s *AppInviteCodeService) ConsumeByPlainCode(tx *gorm.DB, code string, inviteeUserID uint) (*appModel.AppInviteCode, error) { code = strings.TrimSpace(code) if code == "" { return nil, errors.New("邀请码不能为空") } now := time.Now() var record appModel.AppInviteCode err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("code_hash = ? AND status = ?", sha256Hex(code), appModel.InviteCodeStatusUnused). Where(tx.Where("expires_at IS NULL").Or("expires_at > ?", now)). First(&record).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("邀请码无效") } return nil, err } usedAt := time.Now() record.Status = appModel.InviteCodeStatusUsed record.UsedByUserID = &inviteeUserID record.UsedAt = &usedAt if err := tx.Model(&record).Select("status", "used_by_user_id", "used_at").Updates(&record).Error; err != nil { return nil, err } rel := appModel.AppUserInviteRelation{ InviterUserID: record.CreatedByUserID, InviteeUserID: inviteeUserID, InviteCodeID: record.ID, CreatedAt: time.Now(), } if err := tx.Create(&rel).Error; err != nil { return nil, err } return &record, nil }