Files
Go-Web-Template/server/service/app/invite_code.go
2026-04-23 15:29:07 +08:00

133 lines
3.4 KiB
Go

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
}