Files
JuYou/API/internal/service/web.go
T
2026-08-25 17:59:42 +08:00

273 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}