// WEB 用户服务,负责用户认证、账户资料、积分查询和兑换码业务。 package service import ( "crypto/sha256" "encoding/hex" "errors" "strings" "time" "unicode/utf8" "juhe-factory/api/internal/billing" "juhe-factory/api/internal/model" "juhe-factory/api/internal/security" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Web struct { DB *gorm.DB Passwords security.PasswordHasher Tokens security.WebTokenService RefreshTTL time.Duration } var ( ErrInvalidUsername = errors.New("用户名长度必须为 5~12 个字符") ErrUsernameExists = errors.New("用户名已被使用") ErrOriginalPassword = errors.New("原密码错误") ErrAccountDisabled = errors.New("账户已被禁用") ) type WebTokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt time.Time `json:"expires_at"` User map[string]any `json:"user"` } func (s *Web) Login(username, password string) (*WebTokenPair, error) { var user model.WebUser if err := s.DB.Where("account = ? AND deleted_at IS NULL", strings.TrimSpace(username)).First(&user).Error; err != nil { return nil, ErrUnauthorized } if !user.Enabled { return nil, ErrAccountDisabled } if !s.Passwords.Verify(user.PasswordHash, password) { return nil, ErrUnauthorized } if user.LastOnlineAt == nil || user.LastOnlineAt.Before(time.Now().Add(-5*time.Minute)) { _ = s.DB.Model(&user).Update("last_online_at", time.Now()).Error } return s.issue(&user) } func (s *Web) Refresh(refreshToken string) (*WebTokenPair, error) { var record model.WebRefreshToken if err := s.DB.Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", security.HashToken(refreshToken), time.Now()).First(&record).Error; err != nil { return nil, ErrUnauthorized } var user model.WebUser if err := s.DB.Where("id = ? AND enabled = true AND deleted_at IS NULL", record.UserID).First(&user).Error; err != nil || user.SessionVersion != record.SessionVersion { return nil, ErrUnauthorized } now := time.Now() if err := s.DB.Model(&record).Update("revoked_at", now).Error; err != nil { return nil, err } return s.issue(&user) } func (s *Web) Logout(refreshToken string) error { if refreshToken == "" { return nil } return s.DB.Model(&model.WebRefreshToken{}).Where("token_hash = ? AND revoked_at IS NULL", security.HashToken(refreshToken)).Update("revoked_at", time.Now()).Error } func (s *Web) Authenticate(raw string) (*model.WebUser, error) { claims, err := s.Tokens.ParseAccessToken(raw) if err != nil { return nil, ErrUnauthorized } id, err := uuid.Parse(claims.UserID) if err != nil { return nil, ErrUnauthorized } var user model.WebUser if err := s.DB.Where("id = ? AND enabled = true AND deleted_at IS NULL", id).First(&user).Error; err != nil || user.SessionVersion != claims.SessionVersion { return nil, ErrUnauthorized } return &user, nil } func (s *Web) issue(user *model.WebUser) (*WebTokenPair, error) { access, expires, err := s.Tokens.NewAccessToken(user.ID.String(), user.UID, user.Account, user.SessionVersion) if err != nil { return nil, err } opaque := security.TokenService{RefreshTTL: s.RefreshTTL} refresh, hash, refreshExpires, err := opaque.NewRefreshToken() if err != nil { return nil, err } record := model.WebRefreshToken{UserID: user.ID, TokenHash: hash, SessionVersion: user.SessionVersion, ExpiresAt: refreshExpires} if err := s.DB.Create(&record).Error; err != nil { return nil, err } return &WebTokenPair{AccessToken: access, RefreshToken: refresh, ExpiresAt: expires, User: map[string]any{"id": user.ID, "uid": user.UID, "username": user.Username, "account": user.Account, "avatar_url": user.AvatarURL}}, nil } // Account 返回用户资料、永久积分余额、每日限额和当日消耗数据。 func (s *Web) Account(userID uuid.UUID) (map[string]any, error) { result := map[string]any{} err := s.DB.Raw(`SELECT id,uid,username,account,avatar_url,point_balance,daily_limit,last_online_at, coalesce((SELECT sum(-hold.change_amount) FROM point_ledger hold WHERE hold.user_id=web_users.id AND hold.business_type='generation_hold' AND hold.created_at>=CURRENT_DATE AND hold.created_at 12 { return nil, ErrInvalidUsername } var duplicates int64 if err := s.DB.Table("web_users").Where("username = ? AND id <> ?", username, userID).Count(&duplicates).Error; err != nil { return nil, err } if duplicates > 0 { return nil, ErrUsernameExists } if err := s.DB.Table("web_users").Where("id = ? AND deleted_at IS NULL", userID).Update("username", username).Error; err != nil { var raced int64 if s.DB.Table("web_users").Where("username = ? AND id <> ?", username, userID).Count(&raced).Error == nil && raced > 0 { return nil, ErrUsernameExists } return nil, err } return s.Account(userID) } func (s *Web) ChangePassword(userID uuid.UUID, originalPassword, newPassword string) error { if len(newPassword) < 8 || len(newPassword) > 128 { return errors.New("新密码长度必须为 8~128 位") } var user model.WebUser if err := s.DB.Where("id = ? AND enabled = true AND deleted_at IS NULL", userID).First(&user).Error; err != nil { return ErrUnauthorized } if !s.Passwords.Verify(user.PasswordHash, originalPassword) { return ErrOriginalPassword } hash, err := s.Passwords.Hash(newPassword) if err != nil { return err } return s.DB.Model(&user).Update("password_hash", hash).Error } func (s *Web) AvatarKey(userID uuid.UUID) (string, error) { var key string err := s.DB.Raw("SELECT coalesce(avatar_key, '') FROM web_users WHERE id = ? AND deleted_at IS NULL", userID).Scan(&key).Error return key, err } func (s *Web) UpdateAvatar(userID uuid.UUID, url, key string) error { return s.DB.Table("web_users").Where("id = ? AND deleted_at IS NULL", userID).Updates(map[string]any{"avatar_url": url, "avatar_key": key}).Error } func (s *Web) Usage30Days(userID uuid.UUID) ([]map[string]any, error) { items := make([]map[string]any, 0) err := s.DB.Raw(`WITH days AS (SELECT generate_series((CURRENT_DATE-INTERVAL '29 days')::date,CURRENT_DATE::date,'1 day')::date AS day), stats AS (SELECT hold.created_at::date AS day,coalesce(sum(-hold.change_amount),0) AS consumption FROM point_ledger hold WHERE hold.user_id=? AND hold.business_type='generation_hold' AND hold.created_at>=CURRENT_DATE-INTERVAL '29 days' AND NOT EXISTS (SELECT 1 FROM point_ledger refund WHERE refund.idempotency_key='generation:refund:'||hold.business_id) GROUP BY hold.created_at::date) SELECT days.day,coalesce(stats.consumption,0) AS consumption FROM days LEFT JOIN stats USING(day) ORDER BY days.day`, userID).Scan(&items).Error return items, err } // ConsumptionRecordPage 描述用户积分流水的服务端分页结果。 type ConsumptionRecordPage struct { Items []map[string]any `json:"items"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } // normalizeConsumptionPagination 约束积分流水页码和每页数量,避免无效或过大的查询。 func normalizeConsumptionPagination(page, pageSize int) (int, int) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 10 } if pageSize > 100 { pageSize = 100 } return page, pageSize } // ConsumptionRecords 返回用户消费、退款、兑换及后台发放形成的积分变动分页流水。 func (s *Web) ConsumptionRecords(userID uuid.UUID, page, pageSize int) (ConsumptionRecordPage, error) { page, pageSize = normalizeConsumptionPagination(page, pageSize) result := ConsumptionRecordPage{Items: make([]map[string]any, 0), Page: page, PageSize: pageSize} ledger := s.DB.Table("point_ledger"). Where("user_id=? AND business_type IN ?", userID, []string{"generation_hold", "generation_refund", "redemption", "admin_grant"}) if err := ledger.Count(&result.Total).Error; err != nil { return result, err } offset := (page - 1) * pageSize err := s.DB.Raw(`SELECT id,abs(change_amount)::text AS amount,change_amount::text AS change_amount, balance_after::text AS balance_after,business_type,remark,created_at FROM point_ledger WHERE user_id=? AND business_type IN ('generation_hold','generation_refund','redemption','admin_grant') ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, userID, pageSize, offset).Scan(&result.Items).Error return result, err } // Redeem 校验兑换码并将对应积分以永久有效批次计入用户账户。 func (s *Web) Redeem(userID uuid.UUID, code string) (map[string]any, error) { normalized := strings.ReplaceAll(strings.TrimSpace(code), "-", "") if normalized == "" { return nil, errors.New("请输入兑换码") } sum := sha256.Sum256([]byte(normalized)) hash := hex.EncodeToString(sum[:]) result := map[string]any{} err := s.DB.Transaction(func(tx *gorm.DB) error { var record struct { ID uuid.UUID Points string Status string ExpiresAt *time.Time } if err := tx.Table("redemption_codes").Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,points::text AS points,status,expires_at").Where("code_hash=?", hash).Take(&record).Error; err != nil { return errors.New("兑换码不存在") } if record.Status != "unused" { return errors.New("兑换码已使用或已过期") } if record.ExpiresAt != nil && !record.ExpiresAt.After(time.Now()) { if err := tx.Table("redemption_codes").Where("id=?", record.ID).Update("status", "expired").Error; err != nil { return err } return errors.New("兑换码已过期") } redeemedAt := time.Now() balance, credited, err := billing.CreditRedemptionPoints(tx, userID, record.ID, record.Points) if err != nil { return err } if !credited { return errors.New("兑换码积分已入账") } if err := tx.Table("redemption_codes").Where("id=?", record.ID).Updates(map[string]any{"status": "used", "redeemed_by": userID, "redeemed_at": redeemedAt}).Error; err != nil { return err } result = map[string]any{"points": record.Points, "balance": balance} return nil }) return result, err }