gpt
This commit is contained in:
@@ -119,6 +119,7 @@ AdReward:
|
||||
# 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度;
|
||||
# 各类型金额可删除或置 0(表示不发放该类型激励)。
|
||||
Enable: false
|
||||
MaxClaimsPerDay: 1 # 单用户、单激励类型每日最多领取次数;接入广告平台服务端验真后可按业务调整
|
||||
Cos: 1 # 广告激励赠送的 Cos 上传额度(永久)
|
||||
AI: 1 # 广告激励赠送的 AI 推荐额度(每日)
|
||||
OutfitShare: 1 # 广告激励赠送的社区分享额度(每日)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 代码审查与修复记录(2026-09-15)
|
||||
|
||||
本轮检查覆盖登录鉴权、普通支付、虚拟支付、退款与补偿、会员和额度、上传与异步处理、AI 推荐、穿搭、衣柜分享、收藏点赞及通知。已运行全仓库 `go test ./...` 与 `go vet ./...`。未连接生产数据库,也未发起真实支付、退款或外部模型调用。
|
||||
|
||||
## 已修复
|
||||
|
||||
1. **同一用户续费同一会员包重复赠送额度**
|
||||
- 会员额度发放在用户行锁内按 `user_id + goods_id + quota_type` 检查历史凭证。
|
||||
- 首购发放,续费只延长会员时间,不追加或重置额度;不同会员商品与加量包仍按各自规则处理。
|
||||
- 历史重复数据不自动回收,使用 `scripts/audit_member_quota_duplicates.sql` 先盘点。
|
||||
|
||||
2. **退款完成后权益回收失败无法恢复**
|
||||
- 新增 `benefit_revoke_tasks`。订单退款终态、待发放任务取消和回收任务创建在同一事务完成。
|
||||
- 回收立即尝试,并由定时任务持续幂等重试;超过常规告警阈值后仍继续,避免永久遗漏。
|
||||
- 退款回调即使退款流水已成功,也会继续核对订单和回收任务。
|
||||
|
||||
3. **退款与权益发放竞态**
|
||||
- 发放任务执行前锁定订单并确认仍为已支付状态。
|
||||
- 退款事务取消待处理任务;已在执行的任务与退款按订单锁串行,退款随后回收已发权益。
|
||||
|
||||
4. **VIP 开通/续费幂等键不稳定**
|
||||
- 在用户锁内按订单号检查全部购买类 VIP 流水,再根据锁内旧状态确定开通或续费类型。
|
||||
- 同一订单重试时不会因 `change_type` 从开通变成续费而重复增加会员天数。
|
||||
|
||||
5. **广告奖励可无限更换 `ref_id` 领取**
|
||||
- 新增单用户、单奖励类型每日领取上限 `AdReward.MaxClaimsPerDay`,默认 1。
|
||||
- 用户锁内完成当日计数校验、事件幂等、额度凭证和流水写入。
|
||||
- 当前仓库没有广告平台服务端核验接口,因此本次用服务端每日硬上限消除无限领取;以后接入可信回调时,应再校验广告事件凭证。
|
||||
|
||||
6. **已支付订单缺少权益任务时永久遗漏**
|
||||
- 补偿调度会扫描已支付但权益任务不完整的订单,补建任务并走统一幂等发放路径。
|
||||
|
||||
7. **每日额度重复计数、失败回退不对称、Redis 丢键重置**
|
||||
- 删除业务成功后的第二次计数。
|
||||
- MySQL `user_daily_usages` 改为每日额度权威账本,通过条件更新原子校验上限。
|
||||
- Redis 仅作缓存;消费与回退后删除缓存,Redis 丢键不会重新放出额度。
|
||||
|
||||
8. **图片任务超时或重启后卡住**
|
||||
- 建衣物记录前持久化原图路径;成功、失败或删除后清理文件。
|
||||
- 失败状态与额度回退使用独立短超时上下文,不受主处理 context 超时影响。
|
||||
- 新增超时任务原子认领与每分钟恢复调度,多实例不会重复认领同一任务。
|
||||
|
||||
9. **收到的穿搭分享在取消或转私后仍泄露内容**
|
||||
- 接收列表联查分享和穿搭,仅返回分享正常、穿搭公开、审核通过且状态正常的记录。
|
||||
|
||||
10. **穿搭、AI 穿搭和衣柜分享的主记录与明细非事务写入**
|
||||
- 创建、更新重建明细和删除统一使用数据库事务,明细失败时主记录一并回滚。
|
||||
|
||||
11. **权益依赖实时商品配置**
|
||||
- 订单新增 `goods_type` 与 `quota_valid_days` 快照,普通支付与虚拟支付下单时写入,发放时读取订单快照。
|
||||
- 迁移会为历史订单从商品表回填;未回填的历史记录保留兼容读取路径,读取失败会进入补偿重试。
|
||||
|
||||
## 按要求暂缓
|
||||
|
||||
1. **虚拟支付公开回调无限制读取请求体**:`internal/routers/api/v1/order.go:177`。
|
||||
2. **令牌刷新并发与同秒重复问题**:`internal/service/user.go:280`、`pkg/lzcwyjwt/lzcwyjwt.go:30`。
|
||||
|
||||
## 上线事项
|
||||
|
||||
- 先执行数据库迁移 `000020_harden_benefit_workflows.up.sql`,再发布新代码。
|
||||
- 配置 `AdReward.MaxClaimsPerDay`;缺省或非正数时服务按 1 处理。
|
||||
- 测试库应验证普通支付、虚拟支付、退款回调重复投递、退款与补偿交错、服务重启后的图片恢复。
|
||||
- 本轮没有修改线上数据、部署服务或提交 Git。
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
||||
@@ -104,6 +106,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type BenefitRevokeTaskDao struct{ db *gorm.DB }
|
||||
|
||||
func NewBenefitRevokeTaskDao() *BenefitRevokeTaskDao {
|
||||
return &BenefitRevokeTaskDao{db: global.MysqlEngine}
|
||||
}
|
||||
func (d *BenefitRevokeTaskDao) GetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.BenefitRevokeTask, error) {
|
||||
var task mysqlmodel.BenefitRevokeTask
|
||||
err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&task).Error
|
||||
return &task, err
|
||||
}
|
||||
func (d *BenefitRevokeTaskDao) ListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitRevokeTask, error) {
|
||||
var list []mysqlmodel.BenefitRevokeTask
|
||||
err := d.db.WithContext(ctx).Where("status = ?", 0).Order("id ASC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
func (d *BenefitRevokeTaskDao) Claim(ctx context.Context, id int64) (bool, error) {
|
||||
r := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ? AND status IN ?", id, []int8{0, 2}).Update("status", 3)
|
||||
return r.RowsAffected > 0, r.Error
|
||||
}
|
||||
func (d *BenefitRevokeTaskDao) Update(ctx context.Context, id int64, updates map[string]any) error {
|
||||
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) error {
|
||||
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("status = ? AND updated_at < ?", 3, before).Update("status", 0).Error
|
||||
}
|
||||
|
||||
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
|
||||
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
|
||||
settled := false
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order mysqlmodel.Order
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.OrderStatus == 5 {
|
||||
return nil
|
||||
}
|
||||
if order.OrderStatus != 2 && order.OrderStatus != 3 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.OrderStatus == 2 {
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
settled = true
|
||||
return nil
|
||||
})
|
||||
return settled, err
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
@@ -136,3 +137,20 @@ func (d *ClothesDao) ClothesListByUserID(ctx context.Context, userID int64, cate
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// ClothesListStaleProcessing 查询超时未完成的图片任务。
|
||||
func (d *ClothesDao) ClothesListStaleProcessing(ctx context.Context, before int64, limit int) ([]mysqlmodel.Clothes, error) {
|
||||
var list []mysqlmodel.Clothes
|
||||
err := d.db.WithContext(ctx).
|
||||
Where("image_process_status IN ? AND updated_at < ?", []int8{1, 2, 3}, before).
|
||||
Order("id ASC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ClothesClaimStaleProcessing 原子认领超时任务,避免多实例重复恢复。
|
||||
func (d *ClothesDao) ClothesClaimStaleProcessing(ctx context.Context, clothesUUID string, before int64) (bool, error) {
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
|
||||
Where("clothes_uuid = ? AND image_process_status IN ? AND updated_at < ?", clothesUUID, []int8{1, 2, 3}, before).
|
||||
Updates(map[string]any{"image_process_status": 3, "updated_at": time.Now().Unix()})
|
||||
return res.RowsAffected > 0, res.Error
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collect
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
|
||||
if err := q.Order("id DESC").Offset((page-1)*pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestUserDailyUsageConsumeIsAtomic(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rows int64
|
||||
want bool
|
||||
}{
|
||||
{name: "within_limit", rows: 1, want: true},
|
||||
{name: "over_limit", rows: 0, want: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base, mock := newQuotaTestDAO(t)
|
||||
dao := &UserDailyUsageDao{db: base.db}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `user_daily_usages`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `user_daily_usages` SET `ai_recommend_count`=ai_recommend_count \\+ \\? WHERE").
|
||||
WithArgs(int64(1), int64(7), int64(20260915), int64(1), int64(3), int64(0)).
|
||||
WillReturnResult(sqlmock.NewResult(0, tc.rows))
|
||||
mock.ExpectCommit()
|
||||
got, err := dao.UserDailyUsageConsume(context.Background(), 7, 20260915, 2, 1, 3)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("consumed=%v err=%v, want %v", got, err, tc.want)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVIPGrantUsesOrderWideIdempotency(t *testing.T) {
|
||||
base, mock := newQuotaTestDAO(t)
|
||||
dao := &UserDao{db: base.db}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "vip_type", "vip_expire"}).AddRow(7, 2, 2000))
|
||||
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `vip_flow_logs`").
|
||||
WithArgs("ORDER-1", int8(1), int8(2), int64(0)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(1))
|
||||
mock.ExpectCommit()
|
||||
flow := &mysqlmodel.VIPFlowLog{OrderNo: "ORDER-1", ChangeType: 2}
|
||||
if err := dao.VIPGrantIdempotent(context.Background(), 7, 1000, 30, 2, flow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundSettlementCreatesRevokeTaskAtomically(t *testing.T) {
|
||||
base, mock := newQuotaTestDAO(t)
|
||||
dao := &BenefitRevokeTaskDao{db: base.db}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).AddRow(8, "ORDER-1", 7, 2))
|
||||
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `benefit_grant_tasks` SET").WillReturnResult(sqlmock.NewResult(0, 2))
|
||||
mock.ExpectExec("INSERT INTO `benefit_revoke_tasks`").WillReturnResult(sqlmock.NewResult(3, 1))
|
||||
mock.ExpectCommit()
|
||||
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
|
||||
if err != nil || !settled {
|
||||
t.Fatalf("settled=%v err=%v", settled, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// MemberQuotaGrantCreateWithFlow 按用户、会员商品、额度类型仅发放一次。
|
||||
// 锁定用户行将同一用户的并发续费串行化,检查与写入使用同一事务。
|
||||
// 保留原始订单号用于退款追溯;历史凭证(包括已退款软删除的凭证)也参与去重,
|
||||
// 因而额度用完、会员过期或退款后再次购买都不会重新领取首次赠送。
|
||||
func (d *QuotaGrantDao) MemberQuotaGrantCreateWithFlow(ctx context.Context, goodsID int64, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.MemberQuotaGrantCreateWithFlow")
|
||||
defer span.End()
|
||||
if goodsID <= 0 || grant == nil || flow == nil || grant.UserID <= 0 || grant.RefID == "" || grant.Source != 1 {
|
||||
return false, fmt.Errorf("invalid member quota grant")
|
||||
}
|
||||
created := false
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user mysqlmodel.User
|
||||
if err := tx.Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", grant.UserID).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 当前读确保 MySQL REPEATABLE READ 下等待用户锁后可见上一事务的发放。
|
||||
// 关联订单快照识别商品,不依赖当前商品名称或用户是否仍为新用户。
|
||||
var previous []mysqlmodel.QuotaGrant
|
||||
if err := tx.Unscoped().Model(&mysqlmodel.QuotaGrant{}).
|
||||
Select("quota_grants.*").
|
||||
Joins("JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id").
|
||||
Where("quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ?", grant.UserID, goodsID, grant.QuotaType, grant.Source).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).Limit(1).Find(&previous).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(previous) > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(grant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ensureFlowOrderNo(flow)
|
||||
if err := tx.Create(flow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func newQuotaTestDAO(t *testing.T) (*QuotaGrantDao, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
conn, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldTracer := global.Tracer
|
||||
global.Tracer = noop.NewTracerProvider().Tracer("quota-test")
|
||||
t.Cleanup(func() { global.Tracer = oldTracer })
|
||||
return &QuotaGrantDao{db: db}, mock
|
||||
}
|
||||
|
||||
func TestMemberQuotaFirstPurchaseAndRenewal(t *testing.T) {
|
||||
// 每次请求都是新订单;已用完、过期和退款软删除的历史凭证仍代表已领取。
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prior bool
|
||||
used, deleted int64
|
||||
want bool
|
||||
}{
|
||||
{name: "first_purchase", want: true},
|
||||
{name: "renewal_new_order", prior: true},
|
||||
{name: "exhausted_quota", prior: true, used: 100},
|
||||
{name: "refunded_purchase", prior: true, deleted: 123},
|
||||
{name: "compensation_retry", prior: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dao, mock := newQuotaTestDAO(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WithArgs(7, 0, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||
rows := sqlmock.NewRows([]string{"id", "user_id", "quota_type", "source", "amount", "used", "deleted_at", "ref_id"})
|
||||
if tc.prior {
|
||||
rows.AddRow(1, 7, 2, 1, 100, tc.used, tc.deleted, "OLD-ORDER")
|
||||
}
|
||||
// 精确匹配确认按用户/商品/额度类型过滤,且没有 deleted_at/有效期/剩余量过滤。
|
||||
query := "SELECT quota_grants.* FROM `quota_grants` JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id WHERE quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ? LIMIT ? FOR UPDATE"
|
||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(7, 9, 2, 1, 1).WillReturnRows(rows)
|
||||
if !tc.prior {
|
||||
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(2, 1))
|
||||
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(3, 1))
|
||||
}
|
||||
mock.ExpectCommit()
|
||||
grant := &mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW-ORDER"}
|
||||
flow := &mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow", UserID: 7, OrderNo: "NEW-ORDER", QuotaType: 2}
|
||||
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9, grant, flow)
|
||||
if err != nil || created != tc.want {
|
||||
t.Fatalf("created=%v err=%v", created, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberQuotaFailureRollsBack(t *testing.T) {
|
||||
for _, stage := range []string{"user_lock", "history_read", "grant_insert", "flow_insert"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
dao, mock := newQuotaTestDAO(t)
|
||||
failure := errors.New("database unavailable")
|
||||
mock.ExpectBegin()
|
||||
lock := mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE")
|
||||
if stage == "user_lock" {
|
||||
lock.WillReturnError(failure)
|
||||
} else {
|
||||
lock.WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||
history := mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE")
|
||||
if stage == "history_read" {
|
||||
history.WillReturnError(failure)
|
||||
} else {
|
||||
history.WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
insert := mock.ExpectExec("INSERT INTO `quota_grants`")
|
||||
if stage == "grant_insert" {
|
||||
insert.WillReturnError(failure)
|
||||
} else {
|
||||
insert.WillReturnResult(sqlmock.NewResult(2, 1))
|
||||
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnError(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
mock.ExpectRollback()
|
||||
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9,
|
||||
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW"},
|
||||
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow"})
|
||||
if created || !errors.Is(err, failure) {
|
||||
t.Fatalf("created=%v err=%v", created, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddonQuotaStillGrantedPerOrder(t *testing.T) {
|
||||
dao, mock := newQuotaTestDAO(t)
|
||||
for _, order := range []string{"ADDON-1", "ADDON-2"} {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
created, err := dao.QuotaGrantCreateWithFlow(context.Background(),
|
||||
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Source: 2, Amount: 100, RefID: order},
|
||||
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: order, OrderNo: order})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("order=%s created=%v err=%v", order, created, err)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,19 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// OrderListPaidMissingBenefitTasks 查询存在未建权益任务的已支付订单,修复支付回调与任务落库之间的崩溃窗口。
|
||||
func (d *OrderDao) OrderListPaidMissingBenefitTasks(ctx context.Context, limit int) ([]mysqlmodel.Order, error) {
|
||||
var list []mysqlmodel.Order
|
||||
err := d.db.WithContext(ctx).Where("order_status = ?", 2).
|
||||
Where(`(member_days > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=1)) OR
|
||||
(cos_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=2)) OR
|
||||
(ai_recommend_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=3)) OR
|
||||
(closet_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=4)) OR
|
||||
(outfit_share_quota > 0 AND NOT EXISTS (SELECT 1 FROM benefit_grant_tasks t WHERE t.order_no=orders.order_no AND t.benefit_type=5))`).
|
||||
Order("id ASC").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// OrderGetByID 按主键 ID 查询订单
|
||||
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
||||
|
||||
@@ -117,7 +117,10 @@ func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, re
|
||||
q := d.db.WithContext(ctx).
|
||||
Model(&mysqlmodel.OutfitShare{}).
|
||||
Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid").
|
||||
Where("r.receiver_user_id = ?", receiverUserID)
|
||||
Joins("JOIN outfits o ON o.id = outfit_shares.outfit_id").
|
||||
Where("r.receiver_user_id = ?", receiverUserID).
|
||||
Where("outfit_shares.status = ?", 1).
|
||||
Where("o.is_public = ? AND o.audit_status = ? AND o.status = ? AND o.deleted_at = 0", 2, 2, 1)
|
||||
if status != 0 {
|
||||
q = q.Where("outfit_shares.status = ?", status)
|
||||
}
|
||||
|
||||
+104
-1
@@ -346,6 +346,80 @@ func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDailyUsageAdjust 原子调整每日使用量;正数使用 upsert,负数保证不低于 0。
|
||||
func (d *UserDailyUsageDao) UserDailyUsageAdjust(ctx context.Context, userID, statDate int64, quotaType int8, delta int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageAdjust")
|
||||
defer span.End()
|
||||
column := ""
|
||||
switch quotaType {
|
||||
case 2:
|
||||
column = "ai_recommend_count"
|
||||
case 3:
|
||||
column = "outfit_share_count"
|
||||
case 4:
|
||||
column = "closet_share_count"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if delta > 0 {
|
||||
usage := &mysqlmodel.UserDailyUsage{UserID: userID, StatDate: statDate}
|
||||
switch quotaType {
|
||||
case 2:
|
||||
usage.AIRecommendCount = delta
|
||||
case 3:
|
||||
usage.OutfitShareCount = delta
|
||||
case 4:
|
||||
usage.ClosetShareCount = delta
|
||||
}
|
||||
err = d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "stat_date"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{column: gorm.Expr(column+" + ?", delta)}),
|
||||
}).Create(usage).Error
|
||||
} else if delta < 0 {
|
||||
err = d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
|
||||
Where("user_id = ? AND stat_date = ?", userID, statDate).
|
||||
UpdateColumn(column, gorm.Expr("GREATEST(0, "+column+" + ?)", delta)).Error
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UserDailyUsageConsume 以 MySQL 为权威账本原子预占每日额度,避免 Redis 丢键后重复消费。
|
||||
func (d *UserDailyUsageDao) UserDailyUsageConsume(ctx context.Context, userID, statDate int64, quotaType int8, n, limit int64) (bool, error) {
|
||||
column := ""
|
||||
switch quotaType {
|
||||
case 2:
|
||||
column = "ai_recommend_count"
|
||||
case 3:
|
||||
column = "outfit_share_count"
|
||||
case 4:
|
||||
column = "closet_share_count"
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
var consumed bool
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
usage := &mysqlmodel.UserDailyUsage{UserID: userID, StatDate: statDate}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "stat_date"}}, DoNothing: true,
|
||||
}).Create(usage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res := tx.Model(&mysqlmodel.UserDailyUsage{}).
|
||||
Where("user_id = ? AND stat_date = ? AND "+column+" + ? <= ?", userID, statDate, n, limit).
|
||||
UpdateColumn(column, gorm.Expr(column+" + ?", n))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
consumed = res.RowsAffected > 0
|
||||
return nil
|
||||
})
|
||||
return consumed, err
|
||||
}
|
||||
|
||||
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数
|
||||
func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
|
||||
@@ -431,7 +505,7 @@ func NewQuotaGrantDao() *QuotaGrantDao {
|
||||
// QuotaGrantCreateWithFlow 事务内创建额度凭证并写发放流水(购买赠送/活动赠送)。
|
||||
// 返回值 created 表示本次是否新建了凭证:依赖 uk_grant_ref_type(ref_id, quota_type) 唯一索引实现幂等,
|
||||
// 凭证已存在时返回 (false, nil) 且不写流水,避免重复回调/补偿重试重复记账
|
||||
//(流水侧另有 (order_no, quota_type, change_type) 唯一索引兜底)。
|
||||
// (流水侧另有 (order_no, quota_type, change_type) 唯一索引兜底)。
|
||||
func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreateWithFlow")
|
||||
defer span.End()
|
||||
@@ -459,6 +533,35 @@ func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mys
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// AdRewardGrantCreateWithFlow 在用户行锁内执行每日限额、事件幂等、凭证与流水原子写入。
|
||||
func (d *QuotaGrantDao) AdRewardGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog, dayStart, dayEnd int64, maxClaims int) (bool, error) {
|
||||
created := false
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user mysqlmodel.User
|
||||
if err := tx.Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", grant.UserID).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ? AND source = ? AND created_at >= ? AND created_at < ?", grant.UserID, grant.QuotaType, grant.Source, dayStart, dayEnd).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= int64(maxClaims) {
|
||||
return nil
|
||||
}
|
||||
res := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "ref_id"}, {Name: "quota_type"}}, DoNothing: true}).Create(grant)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
ensureFlowOrderNo(flow)
|
||||
if err := tx.Create(flow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
|
||||
// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
|
||||
func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"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 用户数据访问
|
||||
@@ -56,6 +57,25 @@ func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int6
|
||||
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) + ?
|
||||
|
||||
@@ -4,19 +4,32 @@ package mysqlmodel
|
||||
// 唯一索引 (order_no, benefit_type) 作为幂等凭证,防止重复发放。
|
||||
type BenefitGrantTask struct {
|
||||
Model
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
// OrderNo 商户订单号;与 BenefitType 组成联合唯一索引作为幂等凭证。
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_task_idem,priority:1"`
|
||||
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||
// BenefitType 权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share
|
||||
BenefitType int8 `gorm:"column:benefit_type;uniqueIndex:uk_benefit_task_idem,priority:2"`
|
||||
Amount int64 `gorm:"column:amount"` // 发放数量
|
||||
Status int8 `gorm:"column:status"` // 状态 0=待发放 1=已发放 2=发放失败
|
||||
RetryCount int `gorm:"column:retry_count"` // 已重试次数
|
||||
MaxRetry int `gorm:"column:max_retry"` // 最大重试次数
|
||||
LastError string `gorm:"column:last_error"` // 最近一次失败原因
|
||||
Amount int64 `gorm:"column:amount"` // 发放数量
|
||||
Status int8 `gorm:"column:status"` // 状态 0=待发放 1=已发放 2=发放失败
|
||||
RetryCount int `gorm:"column:retry_count"` // 已重试次数
|
||||
MaxRetry int `gorm:"column:max_retry"` // 最大重试次数
|
||||
LastError string `gorm:"column:last_error"` // 最近一次失败原因
|
||||
}
|
||||
|
||||
func (BenefitGrantTask) TableName() string {
|
||||
return "benefit_grant_tasks"
|
||||
}
|
||||
|
||||
// BenefitRevokeTask 退款后的权益回收任务。退款终态与任务同事务落库,任务可幂等重试。
|
||||
type BenefitRevokeTask struct {
|
||||
Model
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_revoke_order"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Status int8 `gorm:"column:status"`
|
||||
RetryCount int `gorm:"column:retry_count"`
|
||||
MaxRetry int `gorm:"column:max_retry"`
|
||||
LastError string `gorm:"column:last_error"`
|
||||
}
|
||||
|
||||
func (BenefitRevokeTask) TableName() string { return "benefit_revoke_tasks" }
|
||||
|
||||
@@ -22,6 +22,7 @@ type Clothes struct {
|
||||
Name string `gorm:"column:name"` // 名称
|
||||
Remark string `gorm:"column:remark"` // 备注
|
||||
ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
|
||||
OriginalImagePath string `gorm:"column:original_image_path"` // 待处理原图本地路径(任务恢复用)
|
||||
ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
|
||||
ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||
|
||||
@@ -3,7 +3,7 @@ package mysqlmodel
|
||||
type Collect struct {
|
||||
Model
|
||||
// CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消
|
||||
|
||||
@@ -6,12 +6,12 @@ type Horoscope struct {
|
||||
StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_horoscope_date_zodiac,priority:1"`
|
||||
Zodiac string `gorm:"column:zodiac;uniqueIndex:uk_horoscope_date_zodiac,priority:2"` // 星座
|
||||
OverallScore int8 `gorm:"column:overall_score"` // 综合运势 1-5分
|
||||
LoveScore int8 `gorm:"column:love_score"` // 爱情运势
|
||||
CareerScore int8 `gorm:"column:career_score"` // 工作运势
|
||||
WealthScore int8 `gorm:"column:wealth_score"` // 财富运势
|
||||
DailyText string `gorm:"column:daily_text"` // 今日运势详情
|
||||
LuckyColor string `gorm:"column:lucky_color"` // 幸运色
|
||||
LuckyNumber string `gorm:"column:lucky_number"` // 幸运数字
|
||||
LoveScore int8 `gorm:"column:love_score"` // 爱情运势
|
||||
CareerScore int8 `gorm:"column:career_score"` // 工作运势
|
||||
WealthScore int8 `gorm:"column:wealth_score"` // 财富运势
|
||||
DailyText string `gorm:"column:daily_text"` // 今日运势详情
|
||||
LuckyColor string `gorm:"column:lucky_color"` // 幸运色
|
||||
LuckyNumber string `gorm:"column:lucky_number"` // 幸运数字
|
||||
}
|
||||
|
||||
func (Horoscope) TableName() string {
|
||||
|
||||
@@ -3,7 +3,7 @@ package mysqlmodel
|
||||
type Like struct {
|
||||
Model
|
||||
// LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消
|
||||
|
||||
@@ -3,30 +3,32 @@ package mysqlmodel
|
||||
type Order struct {
|
||||
Model
|
||||
// OrderNo 商户订单号;唯一索引保证订单号全局唯一。
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"`
|
||||
UserID int64 `gorm:"column:user_id"` // 下单用户ID
|
||||
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||
GoodsName string `gorm:"column:goods_name"` // 商品名称快照
|
||||
UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分)
|
||||
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
|
||||
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
|
||||
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付
|
||||
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"`
|
||||
UserID int64 `gorm:"column:user_id"` // 下单用户ID
|
||||
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||
GoodsName string `gorm:"column:goods_name"` // 商品名称快照
|
||||
UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分)
|
||||
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
|
||||
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
|
||||
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付
|
||||
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
||||
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
||||
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
|
||||
QuotaValidDays int64 `gorm:"column:quota_valid_days"` // 额度有效期天数(快照)
|
||||
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
|
||||
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
||||
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
||||
OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度(快照)
|
||||
ExpireTime int64 `gorm:"column:expire_time"` // 支付过期时间
|
||||
PayTime int64 `gorm:"column:pay_time"` // 支付完成时间
|
||||
RefundTime int64 `gorm:"column:refund_time"` // 退款时间
|
||||
RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分)
|
||||
Remark string `gorm:"column:remark"` // 订单备注
|
||||
CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文
|
||||
XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用)
|
||||
XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
|
||||
XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
|
||||
PayTime int64 `gorm:"column:pay_time"` // 支付完成时间
|
||||
RefundTime int64 `gorm:"column:refund_time"` // 退款时间
|
||||
RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分)
|
||||
Remark string `gorm:"column:remark"` // 订单备注
|
||||
CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文
|
||||
XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用)
|
||||
XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
|
||||
XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
|
||||
}
|
||||
|
||||
func (Order) TableName() string {
|
||||
|
||||
@@ -17,13 +17,13 @@ func (Quota) TableName() string {
|
||||
// Cos 永久额度场景 Amount/Used 表示「一次性赠送库存及其已用」。
|
||||
type QuotaGrant struct {
|
||||
Model
|
||||
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID
|
||||
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID
|
||||
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `gorm:"column:amount"` // 赠送总量
|
||||
Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励
|
||||
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP)
|
||||
RefID string `gorm:"column:ref_id;uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
|
||||
Amount int64 `gorm:"column:amount"` // 赠送总量
|
||||
Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励
|
||||
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP)
|
||||
RefID string `gorm:"column:ref_id;uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
|
||||
}
|
||||
|
||||
func (QuotaGrant) TableName() string {
|
||||
|
||||
@@ -7,7 +7,7 @@ type QuotaFlowLog struct {
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用
|
||||
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
|
||||
ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
|
||||
OldValue int64 `gorm:"column:old_value"` // 变更前数值
|
||||
NewValue int64 `gorm:"column:new_value"` // 变更后数值
|
||||
|
||||
@@ -2,9 +2,9 @@ package mysqlmodel
|
||||
|
||||
type RefundFlowLog struct {
|
||||
Model
|
||||
OrderID int64 `gorm:"column:order_id"` // 关联订单ID
|
||||
OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询)
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查)
|
||||
OrderID int64 `gorm:"column:order_id"` // 关联订单ID
|
||||
OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询)
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查)
|
||||
// RefundNo 商户退款单号;唯一索引为退款申请防重/重试幂等依据。
|
||||
RefundNo string `gorm:"column:refund_no;uniqueIndex:uk_refund_flow_no"`
|
||||
WechatRefundNo string `gorm:"column:wechat_refund_no"` // 微信退款单号
|
||||
|
||||
+15
-15
@@ -2,22 +2,22 @@ package mysqlmodel
|
||||
|
||||
type User struct {
|
||||
Model
|
||||
UserUUID string `gorm:"column:user_uuid"` // 用户UUID
|
||||
UserUUID string `gorm:"column:user_uuid"` // 用户UUID
|
||||
WxOpenID string `gorm:"column:wx_open_id;uniqueIndex:uk_user_wx_open_id"` // 微信openid
|
||||
Username string `gorm:"column:username"` // 用户名
|
||||
Avatar string `gorm:"column:avatar"` // 头像存储位置
|
||||
Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女
|
||||
BirthDay int64 `gorm:"column:birth_day"` // 生日
|
||||
Phone string `gorm:"column:phone"` // 手机号
|
||||
IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户
|
||||
Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||
BanReason string `gorm:"column:ban_reason"` // 封禁原因
|
||||
BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间
|
||||
BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间
|
||||
VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间
|
||||
VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间
|
||||
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
|
||||
Username string `gorm:"column:username"` // 用户名
|
||||
Avatar string `gorm:"column:avatar"` // 头像存储位置
|
||||
Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女
|
||||
BirthDay int64 `gorm:"column:birth_day"` // 生日
|
||||
Phone string `gorm:"column:phone"` // 手机号
|
||||
IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户
|
||||
Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||
BanReason string `gorm:"column:ban_reason"` // 封禁原因
|
||||
BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间
|
||||
BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间
|
||||
VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间
|
||||
VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间
|
||||
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
|
||||
@@ -2,17 +2,17 @@ package mysqlmodel
|
||||
|
||||
type VIPFlowLog struct {
|
||||
Model
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
// ChangeType 变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"`
|
||||
OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒)
|
||||
NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒)
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"`
|
||||
OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒)
|
||||
NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒)
|
||||
// OrderNo 关联订单号(购买类变更);唯一索引 (order_no, change_type) 为 VIP 发放/回收幂等兜底。
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_vip_flow_idem,priority:1"`
|
||||
Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整
|
||||
Reason string `gorm:"column:reason"` // 变更原因/备注
|
||||
Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整
|
||||
Reason string `gorm:"column:reason"` // 变更原因/备注
|
||||
}
|
||||
|
||||
func (VIPFlowLog) TableName() string {
|
||||
|
||||
@@ -117,10 +117,14 @@ func QuotaDailyClosetShareValue(ctx context.Context, userID int64, date int64) (
|
||||
// ---- 通用每日额度原子限流(带限额) ----
|
||||
|
||||
// dailyQuotaConsumeScript 带限额的原子自增:
|
||||
// KEYS[1]=当日计数 key;ARGV[1]=每日上限;ARGV[2]=消费数量;ARGV[3]=TTL 秒。
|
||||
//
|
||||
// KEYS[1]=当日计数 key;ARGV[1]=每日上限;ARGV[2]=消费数量;ARGV[3]=TTL 秒。
|
||||
//
|
||||
// 当前计数 + n 超过上限返回 -1;否则 INCRBY 并在首次写入时设置 TTL,返回消费后的当日计数。
|
||||
var dailyQuotaConsumeScript = redis.NewScript(`
|
||||
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local baseline = tonumber(ARGV[4])
|
||||
if baseline > v then v = baseline; redis.call('SET', KEYS[1], v); redis.call('EXPIRE', KEYS[1], ARGV[3]) end
|
||||
local limit = tonumber(ARGV[1])
|
||||
local n = tonumber(ARGV[2])
|
||||
if v + n > limit then
|
||||
@@ -149,14 +153,14 @@ return redis.call('DECRBY', KEYS[1], n)
|
||||
|
||||
// DailyQuotaConsume 原子消费当日额度。
|
||||
// 返回 (消费后计数, nil);额度不足返回 (-1, nil);Redis 异常返回 (0, err)。
|
||||
func DailyQuotaConsume(ctx context.Context, key string, limit int64, n int64) (int64, error) {
|
||||
func DailyQuotaConsume(ctx context.Context, key string, limit int64, n int64, baseline int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaConsume")
|
||||
defer span.End()
|
||||
|
||||
if limit <= 0 || n <= 0 {
|
||||
return -1, nil
|
||||
}
|
||||
val, err := dailyQuotaConsumeScript.Run(ctx, global.RedisEngine, []string{key}, limit, n, int(quotaDailyTTL.Seconds())).Int64()
|
||||
val, err := dailyQuotaConsumeScript.Run(ctx, global.RedisEngine, []string{key}, limit, n, int(quotaDailyTTL.Seconds()), baseline).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
@@ -184,3 +188,10 @@ func DailyQuotaValue(ctx context.Context, key string) (int64, error) {
|
||||
return quotaDailyValue(ctx, key)
|
||||
}
|
||||
|
||||
// DailyQuotaDelete 删除每日计数缓存;MySQL 是权威账本,后续读取会从数据库恢复。
|
||||
func DailyQuotaDelete(ctx context.Context, key string) error {
|
||||
if key == "" || global.RedisEngine == nil {
|
||||
return nil
|
||||
}
|
||||
return global.RedisEngine.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
@@ -69,7 +69,12 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.
|
||||
QuotaType: quotaType,
|
||||
Reason: "广告激励赠送",
|
||||
}
|
||||
created, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow)
|
||||
maxClaims := global.AdRewardSetting.MaxClaimsPerDay
|
||||
if maxClaims <= 0 {
|
||||
maxClaims = 1
|
||||
}
|
||||
dayStart, dayEnd := todayRangeUnix()
|
||||
created, err := mysqldao.NewQuotaGrantDao().AdRewardGrantCreateWithFlow(ctx, grant, flow, dayStart, dayEnd, maxClaims)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
@@ -126,6 +131,13 @@ func parseAdRewardType(rewardType string) (int8, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// todayRangeUnix 返回本地时区当天的左闭右开时间范围。
|
||||
func todayRangeUnix() (int64, int64) {
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return start.Unix(), start.AddDate(0, 0, 1).Unix()
|
||||
}
|
||||
|
||||
// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。
|
||||
func todayEndUnix() int64 {
|
||||
now := time.Now()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIRecommendService AI 穿搭推荐业务服务。
|
||||
@@ -112,19 +113,18 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
|
||||
AuditStatus: OutfitAuditPending,
|
||||
Status: OutfitStatusUnsaved,
|
||||
}
|
||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
|
||||
}
|
||||
|
||||
clothesIDs := result.GetClothesIDs()
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
|
||||
// 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
|
||||
_ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")
|
||||
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(outfit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
clothesIDs := result.GetClothesIDs()
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
}); err != nil {
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭及明细落库失败")
|
||||
}
|
||||
|
||||
// 7. 写成功流水 + 当日计数。
|
||||
@@ -140,7 +140,6 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
|
||||
}
|
||||
recordDailyUsage(ctx, userID, QuotaTypeAI)
|
||||
|
||||
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
|
||||
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
|
||||
@@ -46,6 +47,15 @@ func runBenefitCompensation() {
|
||||
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
|
||||
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
s := NewOrderService()
|
||||
// 对账已支付但任务未完整落库的订单,覆盖支付状态提交后进程崩溃/数据库瞬时错误。
|
||||
if orders, err := mysqldao.NewOrderDao().OrderListPaidMissingBenefitTasks(ctx, 100); err == nil {
|
||||
for i := range orders {
|
||||
s.grantOrderBenefits(ctx, &orders[i])
|
||||
}
|
||||
} else {
|
||||
slog.ErrorContext(ctx, "reconcile paid order benefits failed", "error", err)
|
||||
}
|
||||
|
||||
// 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。
|
||||
_, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
|
||||
@@ -55,14 +65,19 @@ func runBenefitCompensation() {
|
||||
slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s := NewOrderService()
|
||||
for i := range tasks {
|
||||
s.retryBenefitTask(ctx, &tasks[i])
|
||||
}
|
||||
|
||||
revokeDao := mysqldao.NewBenefitRevokeTaskDao()
|
||||
_ = revokeDao.RecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
|
||||
if revokeTasks, err := revokeDao.ListPending(ctx, 100); err == nil {
|
||||
for i := range revokeTasks {
|
||||
s.executeRevokeTask(ctx, &revokeTasks[i])
|
||||
}
|
||||
} else {
|
||||
slog.ErrorContext(ctx, "list pending benefit revoke tasks failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
|
||||
@@ -110,3 +125,34 @@ func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmod
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OrderService) executeRevokeTaskByOrder(ctx context.Context, orderNo string) {
|
||||
task, err := mysqldao.NewBenefitRevokeTaskDao().GetByOrderNo(ctx, orderNo)
|
||||
if err == nil {
|
||||
s.executeRevokeTask(ctx, task)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OrderService) executeRevokeTask(ctx context.Context, task *mysqlmodel.BenefitRevokeTask) {
|
||||
dao := mysqldao.NewBenefitRevokeTaskDao()
|
||||
if task.Status == BenefitTaskSuccess {
|
||||
return
|
||||
}
|
||||
if task.RetryCount >= task.MaxRetry {
|
||||
slog.ErrorContext(ctx, "benefit revoke task exceeded normal retry threshold; continuing for eventual consistency", "order_no", task.OrderNo, "retry_count", task.RetryCount)
|
||||
}
|
||||
claimed, err := dao.Claim(ctx, task.ID)
|
||||
if err != nil || !claimed {
|
||||
return
|
||||
}
|
||||
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
|
||||
if err == nil {
|
||||
err = s.revokeOrderBenefits(ctx, order)
|
||||
}
|
||||
if err != nil {
|
||||
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": err.Error()})
|
||||
slog.ErrorContext(ctx, "revoke refunded order benefits failed", "order_no", task.OrderNo, "error", err)
|
||||
return
|
||||
}
|
||||
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess, "last_error": ""})
|
||||
}
|
||||
|
||||
+12
-17
@@ -11,6 +11,7 @@ import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ClosetShareService 衣柜分享业务服务。
|
||||
@@ -72,26 +73,22 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 3. 创建分享。
|
||||
// 3. 原子创建分享及快照明细,避免留下空分享。
|
||||
shareUUID := genUUID()
|
||||
share := &mysqlmodel.ClosetShare{
|
||||
ShareUUID: shareUUID,
|
||||
UserID: userID,
|
||||
ShareType: request.ShareType,
|
||||
ExpireTime: request.ExpireTime,
|
||||
Status: ShareStatusNormal,
|
||||
}
|
||||
if err := s.shareDao.ClosetShareCreate(ctx, share); err != nil {
|
||||
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 4. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。
|
||||
share := &mysqlmodel.ClosetShare{ShareUUID: shareUUID, UserID: userID, ShareType: request.ShareType, ExpireTime: request.ExpireTime, Status: ShareStatusNormal}
|
||||
items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid})
|
||||
}
|
||||
if err := s.shareItemDao.ClosetShareItemCreateBatch(ctx, items); err != nil {
|
||||
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(share).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return tx.Create(&items).Error
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
@@ -105,8 +102,6 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
|
||||
slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
|
||||
}
|
||||
|
||||
recordDailyUsage(ctx, userID, QuotaTypeClosetShare)
|
||||
|
||||
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
|
||||
}
|
||||
|
||||
|
||||
+81
-12
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -87,20 +88,28 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
|
||||
// 5. 创建衣物记录,初始状态为排队中。
|
||||
clothesUUID := genUUID()
|
||||
originalName := clothesUUID + ext
|
||||
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
|
||||
if serr != nil {
|
||||
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
clothes := &mysqlmodel.Clothes{
|
||||
ClothesUUID: clothesUUID,
|
||||
UserID: userID,
|
||||
ImageSize: fileHeader.Size / 1024,
|
||||
ImageProcessStatus: ImageProcessQueuing,
|
||||
OriginalImagePath: originalPath,
|
||||
}
|
||||
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
||||
_ = os.Remove(originalPath)
|
||||
// 建记录失败,回退已扣额度。
|
||||
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
||||
return nil, errcode.ClothesCreateFailed
|
||||
}
|
||||
|
||||
// 6. 异步抠图 + 分类。
|
||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
|
||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
|
||||
|
||||
return &dto.ClothesUploadResponse{
|
||||
ClothesUUID: clothesUUID,
|
||||
@@ -109,7 +118,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
||||
}
|
||||
|
||||
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
|
||||
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
|
||||
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) {
|
||||
// 限流:获取信号量,超出并发上限时阻塞等待。
|
||||
imageProcessSem <- struct{}{}
|
||||
defer func() { <-imageProcessSem }()
|
||||
@@ -152,11 +161,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
return
|
||||
}
|
||||
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
|
||||
if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
|
||||
processedPath, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir)
|
||||
if serr != nil {
|
||||
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
|
||||
return
|
||||
}
|
||||
defer os.Remove(processedPath)
|
||||
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
|
||||
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
|
||||
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
@@ -209,10 +220,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
||||
"processed_image": cosKey,
|
||||
"image_process_status": ImageProcessSuccess,
|
||||
"fail_reason": "",
|
||||
"original_image_path": "",
|
||||
}
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
|
||||
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
return
|
||||
}
|
||||
_ = os.Remove(originalPath)
|
||||
}
|
||||
|
||||
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
|
||||
@@ -258,35 +272,42 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
|
||||
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
|
||||
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
||||
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
vipExpire := getUserVIPExpire(cleanupCtx, userID)
|
||||
var originalPath string
|
||||
err := global.MysqlEngine.WithContext(cleanupCtx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
First(&c).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
// 衣物已删除:删除事务已回退额度,跳过。
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 已是失败态:说明失败回退已处理过,跳过。
|
||||
if c.ImageProcessStatus == ImageProcessFailed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 置失败。
|
||||
originalPath = c.OriginalImagePath
|
||||
if err := tx.Model(&mysqlmodel.Clothes{}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"image_process_status": ImageProcessFailed,
|
||||
"fail_reason": reason,
|
||||
"original_image_path": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
|
||||
return
|
||||
}
|
||||
if originalPath != "" {
|
||||
_ = os.Remove(originalPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +437,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
var processedImage string
|
||||
var originalImagePath string
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
@@ -424,6 +446,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
return err
|
||||
}
|
||||
processedImage = c.ProcessedImage
|
||||
originalImagePath = c.OriginalImagePath
|
||||
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -442,6 +465,9 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
||||
}
|
||||
|
||||
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
||||
if originalImagePath != "" {
|
||||
_ = os.Remove(originalImagePath)
|
||||
}
|
||||
if processedImage != "" {
|
||||
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
|
||||
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
|
||||
@@ -524,3 +550,46 @@ func setIfNotEmpty(m map[string]any, key, val string) {
|
||||
m[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// StartClothesProcessingRecoveryScheduler 周期恢复因进程退出或超时遗留的图片任务。
|
||||
func StartClothesProcessingRecoveryScheduler() {
|
||||
go func() {
|
||||
slog.Info("clothes processing recovery scheduler started")
|
||||
for {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("clothes processing recovery scheduler panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
runClothesProcessingRecovery()
|
||||
}()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runClothesProcessingRecovery() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
before := time.Now().Add(-5 * time.Minute).Unix()
|
||||
dao := mysqldao.NewClothesDao()
|
||||
list, err := dao.ClothesListStaleProcessing(ctx, before, 50)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "list stale clothes processing tasks failed", "error", err)
|
||||
return
|
||||
}
|
||||
service := NewClothesService()
|
||||
for i := range list {
|
||||
claimed, err := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
imageData, err := os.ReadFile(list[i].OriginalImagePath)
|
||||
if err != nil || !upload.FileCheckImageMagic(imageData) {
|
||||
service.markProcessFailed(ctx, list[i].UserID, list[i].ClothesUUID, "原图丢失或损坏,任务无法恢复")
|
||||
continue
|
||||
}
|
||||
go service.processImageAsync(context.Background(), list[i].UserID, list[i].ClothesUUID, imageData, list[i].OriginalImagePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ const (
|
||||
QuotaChangeTargetUse int8 = 2 // 使用
|
||||
|
||||
// ChangeType 变更类型
|
||||
QuotaChangeTypePurchase int8 = 1 // 购买获赠
|
||||
QuotaChangeTypeActivity int8 = 2 // 活动赠送
|
||||
QuotaChangeTypeConsume int8 = 3 // 上传消耗
|
||||
QuotaChangeTypeRefund int8 = 4 // 删除回退
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
QuotaChangeTypePurchase int8 = 1 // 购买获赠
|
||||
QuotaChangeTypeActivity int8 = 2 // 活动赠送
|
||||
QuotaChangeTypeConsume int8 = 3 // 上传消耗
|
||||
QuotaChangeTypeRefund int8 = 4 // 删除回退
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
)
|
||||
|
||||
// 新用户默认免费额度(内置兜底值)。
|
||||
@@ -154,10 +154,10 @@ const (
|
||||
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
|
||||
|
||||
// 虚拟支付平台(由前端按 systemInfo.platform 上报)
|
||||
XPayPlatformAndroid = "android"
|
||||
XPayPlatformIOS = "ios"
|
||||
XPayPlatformHarmony = "harmony"
|
||||
XPayPlatformWindows = "windows"
|
||||
XPayPlatformAndroid = "android"
|
||||
XPayPlatformIOS = "ios"
|
||||
XPayPlatformHarmony = "harmony"
|
||||
XPayPlatformWindows = "windows"
|
||||
)
|
||||
|
||||
// ===== 退款 Refund =====
|
||||
@@ -170,12 +170,12 @@ const (
|
||||
|
||||
// ===== VIP 流水 =====
|
||||
const (
|
||||
VIPChangeTypeOpen int8 = 1 // 开通
|
||||
VIPChangeTypeRenew int8 = 2 // 续费
|
||||
VIPChangeTypeActivity int8 = 3 // 活动赠送
|
||||
VIPChangeTypeExpire int8 = 4 // 到期失效
|
||||
VIPChangeTypeManual int8 = 5 // 人工调整
|
||||
VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
VIPChangeTypeOpen int8 = 1 // 开通
|
||||
VIPChangeTypeRenew int8 = 2 // 续费
|
||||
VIPChangeTypeActivity int8 = 3 // 活动赠送
|
||||
VIPChangeTypeExpire int8 = 4 // 到期失效
|
||||
VIPChangeTypeManual int8 = 5 // 人工调整
|
||||
VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
|
||||
VIPSourcePersonalPay int8 = 1 // 个人支付
|
||||
VIPSourceActivity int8 = 2 // 活动赠送
|
||||
@@ -204,10 +204,12 @@ const (
|
||||
BenefitTaskSuccess int8 = 1 // 已发放
|
||||
BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
|
||||
BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
|
||||
BenefitTaskCancelled int8 = 4 // 订单已退款,禁止继续发放
|
||||
|
||||
benefitMaxRetry int = 3 // 默认最大重试次数
|
||||
|
||||
// benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
|
||||
// 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
|
||||
benefitRetryCooldownSeconds int64 = 300
|
||||
benefitRevokeMaxRetry = 10
|
||||
)
|
||||
|
||||
+94
-89
@@ -16,6 +16,7 @@ import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// OrderService 订单与退款业务服务。
|
||||
@@ -81,6 +82,8 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
|
||||
PayChannel: PayChannelWechat,
|
||||
OrderStatus: OrderStatusPending,
|
||||
MemberDays: goods.MemberDays,
|
||||
GoodsType: goods.GoodsType,
|
||||
QuotaValidDays: goods.ValidDays,
|
||||
CosQuota: goods.CosQuota,
|
||||
AIRecommendQuota: goods.AIRecommendQuota,
|
||||
ClosetShareQuota: goods.ClosetShareQuota,
|
||||
@@ -358,43 +361,35 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
// 已终态则幂等返回。
|
||||
if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
|
||||
if flow.Status == RefundStatusFailed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 校验回调退款金额与流水一致,防止金额被篡改。
|
||||
if data.Amount.Refund != flow.RefundAmount {
|
||||
slog.ErrorContext(ctx, "refund notify: amount mismatch",
|
||||
"out_refund_no", data.OutRefundNo,
|
||||
"notify_amount", data.Amount.Refund,
|
||||
"flow_amount", flow.RefundAmount,
|
||||
)
|
||||
slog.ErrorContext(ctx, "refund notify: amount mismatch", "out_refund_no", data.OutRefundNo, "notify_amount", data.Amount.Refund, "flow_amount", flow.RefundAmount)
|
||||
return nil
|
||||
}
|
||||
|
||||
if data.RefundStatus == "SUCCESS" {
|
||||
// 条件流转,防止并发/乱序回调覆盖状态。
|
||||
ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||||
"status": RefundStatusSuccess,
|
||||
"success_time": nowUnix(),
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
if !ok {
|
||||
return nil // 已被并发回调处理。
|
||||
if flow.Status != RefundStatusSuccess {
|
||||
ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw})
|
||||
if uerr != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if !ok {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
}
|
||||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||||
if gerr == nil {
|
||||
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
|
||||
if gerr != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
} else if flow.Status != RefundStatusSuccess {
|
||||
if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
} else {
|
||||
_, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||||
"status": RefundStatusFailed,
|
||||
"fail_reason": "微信退款未成功:" + data.RefundStatus,
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -538,23 +533,30 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
|
||||
|
||||
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
|
||||
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
|
||||
if err != nil || !claimed {
|
||||
return // 已被其他实例处理。
|
||||
var grantErr error
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order mysqlmodel.Order
|
||||
if err := tx.Select("id", "order_status").Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", task.OrderNo).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.OrderStatus != OrderStatusPaid {
|
||||
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskCancelled).Error
|
||||
}
|
||||
res := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskProcessing)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
if e := s.doGrantBenefit(ctx, task); e != nil {
|
||||
grantErr = e
|
||||
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": e.Error()}).Error
|
||||
}
|
||||
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
|
||||
} else if grantErr != nil {
|
||||
slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr)
|
||||
}
|
||||
if e := s.doGrantBenefit(ctx, task); e != nil {
|
||||
// 发放失败:回退为待发放并记录错误,交由补偿任务重试。
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
|
||||
"status": BenefitTaskPending,
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
"last_error": e.Error(),
|
||||
})
|
||||
slog.ErrorContext(ctx, "grant benefit failed",
|
||||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
|
||||
return
|
||||
}
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
|
||||
}
|
||||
|
||||
// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
|
||||
@@ -587,10 +589,22 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
||||
expireTime := int64(0) // 0 表示跟随 VIP
|
||||
reason := "购买会员赠送"
|
||||
|
||||
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
||||
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
||||
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
|
||||
if err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
goodsType, validDays := order.GoodsType, order.QuotaValidDays
|
||||
if goodsType == 0 { // 兼容迁移前创建、尚未回填的历史订单。
|
||||
goods, gerr := s.goodsDao.GoodsGetByID(ctx, task.GoodsID)
|
||||
if gerr != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
goodsType, validDays = goods.GoodsType, goods.ValidDays
|
||||
}
|
||||
if goodsType != GoodsTypeMember {
|
||||
// 加量包:独立有效期,从发放时刻起算快照 valid_days 天;未配置则兜底 30 天。
|
||||
source = QuotaGrantSourceAddon
|
||||
days := goods.ValidDays
|
||||
days := validDays
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
@@ -625,7 +639,14 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
||||
QuotaType: quotaType,
|
||||
Reason: reason,
|
||||
}
|
||||
if _, err := mysqldao.NewQuotaGrantDao().QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil {
|
||||
grantDao := mysqldao.NewQuotaGrantDao()
|
||||
if source == QuotaGrantSourceMember {
|
||||
// 同一用户、同一会员包只赠送一次;续费和补偿重试共用事务内检查。
|
||||
_, err = grantDao.MemberQuotaGrantCreateWithFlow(ctx, task.GoodsID, grant, flow)
|
||||
} else {
|
||||
_, err = grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow)
|
||||
}
|
||||
if err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
@@ -674,46 +695,42 @@ func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, o
|
||||
// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
|
||||
// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
|
||||
// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
|
||||
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) {
|
||||
from := order.OrderStatus
|
||||
if from != OrderStatusPaid && from != OrderStatusCancelled {
|
||||
return
|
||||
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) error {
|
||||
settled, err := mysqldao.NewBenefitRevokeTaskDao().RefundSettleWithRevokeTask(ctx, order.OrderNo, refundAmount, nowUnix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
|
||||
if !ok {
|
||||
return
|
||||
if settled {
|
||||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
||||
}
|
||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||||
"refund_time": nowUnix(),
|
||||
"refund_amount": refundAmount,
|
||||
})
|
||||
// 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
|
||||
if from == OrderStatusPaid {
|
||||
s.revokeOrderBenefits(ctx, order)
|
||||
}
|
||||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
||||
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
|
||||
s.executeRevokeTaskByOrder(ctx, order.OrderNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
||||
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
|
||||
// 并在同一事务内写「退款回收」流水,保证额度明细能看到回收记录。
|
||||
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
||||
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) error {
|
||||
if order.MemberDays > 0 {
|
||||
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
|
||||
if err := s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flows := s.buildQuotaRevokeFlows(ctx, order)
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows); err != nil {
|
||||
slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err)
|
||||
flows, err := s.buildQuotaRevokeFlows(ctx, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefIDWithFlows(ctx, order.OrderNo, flows)
|
||||
}
|
||||
|
||||
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
|
||||
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
|
||||
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) []*mysqlmodel.QuotaFlowLog {
|
||||
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
|
||||
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
|
||||
@@ -747,33 +764,21 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
|
||||
Reason: "退款回收未使用的赠送额度",
|
||||
})
|
||||
}
|
||||
return flows
|
||||
return flows, nil
|
||||
}
|
||||
|
||||
// revokeVIP 幂等回退会员天数(退款回收)。
|
||||
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
|
||||
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) error {
|
||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
flow := &mysqlmodel.VIPFlowLog{
|
||||
UserID: userID,
|
||||
ChangeType: VIPChangeTypeRefundRevoke,
|
||||
OldVIPType: user.VIPType,
|
||||
NewVIPType: user.VIPType,
|
||||
OldVIPExpire: user.VIPExpire,
|
||||
OrderNo: orderNo,
|
||||
Source: VIPSourceSystem,
|
||||
Reason: "退款回收会员",
|
||||
flow := &mysqlmodel.VIPFlowLog{UserID: userID, ChangeType: VIPChangeTypeRefundRevoke, OldVIPType: user.VIPType, NewVIPType: user.VIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourceSystem, Reason: "退款回收会员"}
|
||||
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) {
|
||||
return err
|
||||
}
|
||||
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil {
|
||||
if !isDuplicateKey(err) {
|
||||
slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
|
||||
}
|
||||
}
|
||||
// 会员信息已变更,失效缓存。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOrderResponse 将订单模型映射为响应。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupOrderQuotaTest(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
conn, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDB, oldTracer := global.MysqlEngine, global.Tracer
|
||||
global.MysqlEngine, global.Tracer = db, noop.NewTracerProvider().Tracer("order-test")
|
||||
t.Cleanup(func() { global.MysqlEngine, global.Tracer = oldDB, oldTracer; conn.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
func TestOrderQuotaGoodsReadFailureDoesNotGrant(t *testing.T) {
|
||||
mock := setupOrderQuotaTest(t)
|
||||
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", 0, 0))
|
||||
mock.ExpectQuery("SELECT .* FROM `goods`").WillReturnError(errors.New("database unavailable"))
|
||||
err := NewOrderService().grantQuotaGrant(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100}, QuotaTypeCos)
|
||||
if err != errcode.DatabaseError {
|
||||
t.Fatalf("got %v, want DatabaseError", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRenewalAndCompensationSkipQuota(t *testing.T) {
|
||||
mock := setupOrderQuotaTest(t)
|
||||
svc := NewOrderService()
|
||||
// 首购凭证已存在:新续费订单和该订单补偿执行均不得插入额度或流水。
|
||||
for i := 0; i < 2; i++ {
|
||||
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", GoodsTypeMember, 30))
|
||||
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
|
||||
mock.ExpectQuery("SELECT .* FROM `quota_grants`").WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).AddRow(1, QuotaGrantSourceMember, 100, 40, 0))
|
||||
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||
mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE").WithArgs(7, 9, QuotaTypeCos, QuotaGrantSourceMember, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "amount", "used", "ref_id"}).AddRow(1, 100, 40, "FIRST"))
|
||||
mock.ExpectCommit()
|
||||
err := svc.doGrantBenefit(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100, BenefitType: BenefitTypeCos})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+24
-15
@@ -60,11 +60,19 @@ func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.O
|
||||
AuditStatus: OutfitAuditPending,
|
||||
Status: OutfitStatusNormal,
|
||||
}
|
||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
||||
return nil, errcode.OutfitCreateFailed
|
||||
}
|
||||
|
||||
if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
|
||||
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(outfit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
dbItems := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
dbItems = append(dbItems, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
if len(dbItems) > 0 {
|
||||
return tx.Create(&dbItems).Error
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, errcode.OutfitCreateFailed
|
||||
}
|
||||
|
||||
@@ -119,14 +127,12 @@ func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request
|
||||
setIfNotEmpty(updates, "outfit_name", request.OutfitName)
|
||||
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
|
||||
setIfNotEmpty(updates, "scene", request.Scene)
|
||||
if len(updates) > 0 {
|
||||
if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil {
|
||||
return nil, errcode.OutfitUpdateFailed
|
||||
}
|
||||
}
|
||||
|
||||
// 重建明细(事务内先删后建,避免中途失败留下空明细)。
|
||||
rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", request.OutfitUUID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -178,10 +184,14 @@ func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.O
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil {
|
||||
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("outfit_uuid = ?", request.OutfitUUID).Delete(&mysqlmodel.Outfit{}).Error
|
||||
}); err != nil {
|
||||
return nil, errcode.OutfitDeleteFailed
|
||||
}
|
||||
_ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
|
||||
return &dto.OutfitDeleteResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -250,7 +260,6 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
|
||||
}
|
||||
|
||||
// 记录当日社区分享计数(统计)。
|
||||
recordDailyUsage(ctx, userID, QuotaTypeOutfitShare)
|
||||
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
|
||||
+29
-43
@@ -82,37 +82,9 @@ func genUUID() string {
|
||||
|
||||
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
|
||||
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
|
||||
func recordDailyUsage(ctx context.Context, userID int64, kind int8) {
|
||||
usageDao := mysqldao.NewUserDailyUsageDao()
|
||||
statDate := todayStatDate()
|
||||
|
||||
// 确保当日计数行存在。
|
||||
if _, err := usageDao.UserDailyUsageGetByUserIDAndDate(ctx, userID, statDate); err != nil {
|
||||
if isNotFound(err) {
|
||||
if cerr := usageDao.UserDailyUsageCreate(ctx, &mysqlmodel.UserDailyUsage{
|
||||
UserID: userID,
|
||||
StatDate: statDate,
|
||||
}); cerr != nil {
|
||||
slog.WarnContext(ctx, "create daily usage failed", "user_id", userID, "error", cerr)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
slog.WarnContext(ctx, "get daily usage failed", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
switch kind {
|
||||
case QuotaTypeAI:
|
||||
err = usageDao.UserDailyUsageIncrementAI(ctx, userID, statDate)
|
||||
case QuotaTypeOutfitShare:
|
||||
err = usageDao.UserDailyUsageIncrementOutfitShare(ctx, userID, statDate)
|
||||
case QuotaTypeClosetShare:
|
||||
err = usageDao.UserDailyUsageIncrementClosetShare(ctx, userID, statDate)
|
||||
}
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "increment daily usage failed", "user_id", userID, "kind", kind, "error", err)
|
||||
func recordDailyUsage(ctx context.Context, userID int64, kind int8, delta int64) {
|
||||
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), kind, delta); err != nil {
|
||||
slog.WarnContext(ctx, "adjust daily usage failed", "user_id", userID, "kind", kind, "delta", delta, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,16 +188,24 @@ func dailyQuotaKey(quotaType int8, userID int64, date int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// getDailyUsed 读取当日已用计数(Redis 优先,异常回落 MySQL 统计)。
|
||||
// getDailyUsed 读取 MySQL 权威当日计数;数据库异常时只把 Redis 作为展示兜底。
|
||||
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
|
||||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||||
if key == "" {
|
||||
return 0
|
||||
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
|
||||
if err == nil {
|
||||
switch quotaType {
|
||||
case QuotaTypeAI:
|
||||
return usage.AIRecommendCount
|
||||
case QuotaTypeOutfitShare:
|
||||
return usage.OutfitShareCount
|
||||
case QuotaTypeClosetShare:
|
||||
return usage.ClosetShareCount
|
||||
}
|
||||
}
|
||||
if val, err := redisdao.DailyQuotaValue(ctx, key); err == nil {
|
||||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
||||
if val, rerr := redisdao.DailyQuotaValue(ctx, key); rerr == nil {
|
||||
return val
|
||||
}
|
||||
return getDailyUsedFromMySQL(ctx, userID, quotaType)
|
||||
return 0
|
||||
}
|
||||
|
||||
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
|
||||
@@ -257,15 +237,17 @@ func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int6
|
||||
if key == "" {
|
||||
return errcode.QuotaInsufficient
|
||||
}
|
||||
val, err := redisdao.DailyQuotaConsume(ctx, key, quota.Total, n)
|
||||
consumed, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageConsume(ctx, userID, todayStatDate(), quotaType, n, quota.Total)
|
||||
if err != nil {
|
||||
return errcode.CacheError
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if val < 0 {
|
||||
if !consumed {
|
||||
return errcode.QuotaInsufficient
|
||||
}
|
||||
// 同步 MySQL 当日计数(统计/兜底)。
|
||||
recordDailyUsage(ctx, userID, quotaType)
|
||||
// Redis 只作缓存;删除后下次从 MySQL 权威计数恢复,不受丢键影响。
|
||||
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
|
||||
slog.WarnContext(ctx, "delete daily quota cache failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -275,8 +257,12 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if err := redisdao.DailyQuotaRefund(ctx, key, n); err != nil {
|
||||
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), quotaType, -n); err != nil {
|
||||
slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||||
return
|
||||
}
|
||||
if err := redisdao.DailyQuotaDelete(ctx, key); err != nil {
|
||||
slog.WarnContext(ctx, "delete daily quota cache after refund failed", "user_id", userID, "quota_type", quotaType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,24 +68,26 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
|
||||
platform := request.Platform
|
||||
|
||||
order := &mysqlmodel.Order{
|
||||
OrderNo: orderNo,
|
||||
UserID: userID,
|
||||
GoodsID: goods.ID,
|
||||
GoodsName: goods.GoodsName,
|
||||
UnitPrice: goods.DiscountPrice,
|
||||
TotalAmount: payAmount,
|
||||
PayAmount: payAmount,
|
||||
PayChannel: PayChannelVirtual,
|
||||
OrderStatus: OrderStatusPending,
|
||||
MemberDays: goods.MemberDays,
|
||||
CosQuota: goods.CosQuota,
|
||||
OrderNo: orderNo,
|
||||
UserID: userID,
|
||||
GoodsID: goods.ID,
|
||||
GoodsName: goods.GoodsName,
|
||||
UnitPrice: goods.DiscountPrice,
|
||||
TotalAmount: payAmount,
|
||||
PayAmount: payAmount,
|
||||
PayChannel: PayChannelVirtual,
|
||||
OrderStatus: OrderStatusPending,
|
||||
MemberDays: goods.MemberDays,
|
||||
GoodsType: goods.GoodsType,
|
||||
QuotaValidDays: goods.ValidDays,
|
||||
CosQuota: goods.CosQuota,
|
||||
AIRecommendQuota: goods.AIRecommendQuota,
|
||||
ClosetShareQuota: goods.ClosetShareQuota,
|
||||
OutfitShareQuota: goods.OutfitShareQuota,
|
||||
ExpireTime: expireTime,
|
||||
XPayProductID: goods.ProductID,
|
||||
XPayPlatform: platform,
|
||||
XPayEnv: int8(xpayEnv),
|
||||
ExpireTime: expireTime,
|
||||
XPayProductID: goods.ProductID,
|
||||
XPayPlatform: platform,
|
||||
XPayEnv: int8(xpayEnv),
|
||||
}
|
||||
if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
|
||||
return nil, errcode.VirtualOrderCreateFailed
|
||||
|
||||
@@ -63,6 +63,7 @@ func main() {
|
||||
|
||||
// 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。
|
||||
service.StartBenefitCompensationScheduler()
|
||||
service.StartClothesProcessingRecoveryScheduler()
|
||||
service.StartOrderExpireScheduler()
|
||||
service.StartVirtualPayConfirmScheduler()
|
||||
|
||||
@@ -217,7 +218,6 @@ func setupSetting() error {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。
|
||||
if global.ServerSetting == nil {
|
||||
return fmt.Errorf("Server config section is missing")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS `benefit_revoke_tasks`;
|
||||
ALTER TABLE `clothes` DROP COLUMN `original_image_path`;
|
||||
ALTER TABLE `orders` DROP COLUMN `quota_valid_days`, DROP COLUMN `goods_type`;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE `orders`
|
||||
ADD COLUMN `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照' AFTER `member_days`,
|
||||
ADD COLUMN `quota_valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '额度有效期天数快照' AFTER `goods_type`;
|
||||
UPDATE `orders` o JOIN `goods` g ON g.id = o.goods_id
|
||||
SET o.goods_type = g.goods_type, o.quota_valid_days = g.valid_days WHERE o.goods_type = 0;
|
||||
ALTER TABLE `clothes` ADD COLUMN `original_image_path` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '待处理原图本地路径' AFTER `processed_image`;
|
||||
CREATE TABLE `benefit_revoke_tasks` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT, `created_at` BIGINT NOT NULL DEFAULT 0, `updated_at` BIGINT NOT NULL DEFAULT 0,
|
||||
`deleted_at` BIGINT NOT NULL DEFAULT 0, `created_by` BIGINT NOT NULL DEFAULT 0, `updated_by` BIGINT NOT NULL DEFAULT 0,
|
||||
`deleted_by` BIGINT NOT NULL DEFAULT 0, `order_no` VARCHAR(64) NOT NULL DEFAULT '', `user_id` BIGINT NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 0, `retry_count` INT NOT NULL DEFAULT 0, `max_retry` INT NOT NULL DEFAULT 10,
|
||||
`last_error` VARCHAR(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_benefit_revoke_order` (`order_no`),
|
||||
KEY `idx_benefit_revoke_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款权益回收任务';
|
||||
@@ -142,11 +142,12 @@ type UserDefaultQuotaSettingS struct {
|
||||
// 金额为服务端固定值,前端不传金额,从根源杜绝刷额度;各类型金额使用指针以区分
|
||||
// 「未配置/显式 0」,nil 或非正数表示不发放该类型激励。
|
||||
type AdRewardSettingS struct {
|
||||
Enable bool // 是否开启广告激励;关闭时接口返回「广告激励未开启」
|
||||
Cos *int64 // 广告激励赠送的 Cos 上传额度(固定值)
|
||||
AI *int64 // 广告激励赠送的 AI 推荐额度(固定值)
|
||||
OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值)
|
||||
ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值)
|
||||
Enable bool // 是否开启广告激励;关闭时接口返回「广告激励未开启」
|
||||
Cos *int64 // 广告激励赠送的 Cos 上传额度(固定值)
|
||||
AI *int64 // 广告激励赠送的 AI 推荐额度(固定值)
|
||||
OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值)
|
||||
ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值)
|
||||
MaxClaimsPerDay int // 单用户、单类型每天最多领取次数;未配置默认 1
|
||||
}
|
||||
|
||||
func (s *Setting) ReadSection(k string, v any) error {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Read-only audit: repeated active member grants per user/product/quota type.
|
||||
-- This query does not change quota balances or historical accounting records.
|
||||
SELECT g.user_id, o.goods_id, g.quota_type,
|
||||
COUNT(*) AS grant_count,
|
||||
SUM(g.amount) AS granted_amount,
|
||||
SUM(g.used) AS used_amount,
|
||||
SUM(g.amount - g.used) AS recorded_remaining,
|
||||
MIN(g.id) AS first_grant_id,
|
||||
GROUP_CONCAT(g.ref_id ORDER BY g.id) AS order_numbers
|
||||
FROM quota_grants AS g
|
||||
JOIN orders AS o ON o.order_no = g.ref_id AND o.user_id = g.user_id
|
||||
WHERE g.source = 1 AND g.deleted_at = 0
|
||||
GROUP BY g.user_id, o.goods_id, g.quota_type
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY grant_count DESC, g.user_id, o.goods_id, g.quota_type;
|
||||
-- recorded_remaining is not necessarily effective remaining: VIP may be expired,
|
||||
-- and daily quota types use separate daily usage counters. Do not deduct it blindly.
|
||||
Reference in New Issue
Block a user