273 lines
10 KiB
Go
273 lines
10 KiB
Go
// 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<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("新密码长度必须为 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
|
||
}
|