Files
2026-09-15 00:53:04 +08:00

197 lines
6.9 KiB
Go
Raw Permalink 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.
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// UserDao 用户数据访问
type UserDao struct {
db *gorm.DB
}
// NewUserDao 创建用户 DAO
func NewUserDao() *UserDao {
return &UserDao{db: global.MysqlEngine}
}
// UserCreate 创建用户
func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(user).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// UserUpdateByID 按主键 ID 更新用户字段
func (d *UserDao) UserUpdateByID(ctx context.Context, userID int64, updates map[string]any) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserUpdateByID")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// VIPGrantIdempotent 幂等续费会员:事务内先原子累加 vip_expire(CASE 表达式避免并发丢失天数),
// 回读真实到期时间后推导会员类型并写 VIP 流水;流水唯一索引(order_no+change_type)冲突时事务回滚,
// 调用方据此识别为「已发放」从而幂等。
//
// 注意:续费 SQL 使用原生语句显式控制 SET 顺序(vip_start 先于 vip_expire 求值),
// 使 vip_start 读到更新前的旧 vip_expire,保证「已过期续费时重置 vip_start」语义正确。
//
// 会员类型按「续费后最终到期时长」推导:>=365 天为年度会员,否则月度会员,
// 避免「年度会员续月包被错误降级为月度」;体验会员等其它类型保持传入值不参与推导。
func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8, flow *mysqlmodel.VIPFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPGrantIdempotent")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var before mysqlmodel.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", userID).First(&before).Error; err != nil {
return err
}
// 购买开通/续费使用稳定的订单级幂等判断,不让重试后的 change_type 变化绕过唯一键。
if flow.ChangeType == 1 || flow.ChangeType == 2 {
var count int64
if err := tx.Model(&mysqlmodel.VIPFlowLog{}).Where("order_no = ? AND change_type IN ?", flow.OrderNo, []int8{1, 2}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
flow.ChangeType = 1
if before.VIPType != 1 && before.VIPExpire > now {
flow.ChangeType = 2
}
flow.OldVIPType, flow.OldVIPExpire = before.VIPType, before.VIPExpire
}
sql := `UPDATE users SET
vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END,
vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?
WHERE id = ? AND deleted_at = 0`
if res := tx.Exec(sql, now, now, now, now, memberDays*86400, userID); res.Error != nil {
return res.Error
}
// 回读续费后的真实到期时间,用于推导会员类型。
var renewed mysqlmodel.User
if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
return err
}
// 字面量 2=月度会员 3=年度会员(与 service 层 VIPTypeMonthly/VIPTypeYearly 一致)。
// 月度/年度续费按最终到期时长推导;体验会员(4)等其它类型保持传入值。
finalType := vipType
if vipType == 2 || vipType == 3 {
if renewed.VIPExpire-now >= 365*86400 {
finalType = 3
} else {
finalType = 2
}
}
if err := tx.Model(&mysqlmodel.User{}).Where("id = ?", userID).
UpdateColumn("vip_type", finalType).Error; err != nil {
return err
}
flow.NewVIPExpire = renewed.VIPExpire
flow.NewVIPType = finalType
return tx.Create(flow).Error
})
}
// VIPRevokeIdempotent 幂等回退会员(退款回收):事务内先扣减 vip_expire(GREATEST 保证不早于 now),
// 回读真实值后写回退流水;流水唯一索引(order_no+change_type)冲突时事务回滚,调用方据此识别为「已回退」。
func (d *UserDao) VIPRevokeIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, flow *mysqlmodel.VIPFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPRevokeIdempotent")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 扣减 vip_expire(GREATEST(now, vip_expire - days) 保证不倒退到过去)。
res := tx.Model(&mysqlmodel.User{}).
Where("id = ? AND vip_expire > ?", userID, now).
UpdateColumn("vip_expire", gorm.Expr("GREATEST(?, vip_expire - ?)", now, memberDays*86400))
if res.Error != nil {
return res.Error
}
// 2. 回读真实值填充流水。
var renewed mysqlmodel.User
if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
return err
}
flow.NewVIPExpire = renewed.VIPExpire
flow.NewVIPType = renewed.VIPType
// 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
return tx.Create(flow).Error
})
}
// UserGetByID 按主键 ID 查询用户
func (d *UserDao) UserGetByID(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByID")
defer span.End()
var user mysqlmodel.User
if err := d.db.WithContext(ctx).First(&user, userID).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &user, nil
}
// UserGetByIDs 按主键 ID 批量查询用户(空 ids 直接返回空切片)
func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByIDs")
defer span.End()
var users []mysqlmodel.User
if len(ids) == 0 {
return users, nil
}
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
span.RecordError(err)
return nil, err
}
return users, nil
}
// UserGetByUUID 按 UUID 查询用户
func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByUUID")
defer span.End()
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &user, nil
}
// UserGetByOpenID 按微信 openid 查询用户
func (d *UserDao) UserGetByOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByOpenID")
defer span.End()
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("wx_open_id = ?", openID).First(&user).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &user, nil
}