初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
// 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("用户名长度必须为 512 个字符")
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<CURRENT_DATE+INTERVAL '1 day'
AND NOT EXISTS (SELECT 1 FROM point_ledger refund WHERE refund.idempotency_key='generation:refund:'||hold.business_id)),0) AS today_consumption
FROM web_users WHERE id=? AND deleted_at IS NULL`, userID).Scan(&result).Error
return result, err
}
func (s *Web) UpdateProfile(userID uuid.UUID, username string) (map[string]any, error) {
username = strings.TrimSpace(username)
if count := utf8.RuneCountInString(username); count < 5 || count > 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("新密码长度必须为 8128 位")
}
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
}