初始化
This commit is contained in:
@@ -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("用户名长度必须为 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
|
||||
}
|
||||
Reference in New Issue
Block a user