From 112c5f8e46cea5e490d22de06481805008c35459 Mon Sep 17 00:00:00 2001 From: zhengchiliu Date: Tue, 15 Sep 2026 00:53:04 +0800 Subject: [PATCH] gpt --- configs/config.example.yaml | 1 + docs/code-review-2026-09-15.md | 63 ++++++ go.mod | 1 + go.sum | 3 + internal/mysqldao/benefit_revoke.go | 67 +++++++ internal/mysqldao/clothes.go | 18 ++ internal/mysqldao/collect.go | 2 +- internal/mysqldao/hardening_test.go | 76 ++++++++ internal/mysqldao/member_quota.go | 58 ++++++ internal/mysqldao/member_quota_test.go | 135 +++++++++++++ internal/mysqldao/order.go | 13 ++ internal/mysqldao/outfit_share.go | 5 +- internal/mysqldao/quota.go | 105 +++++++++- internal/mysqldao/user.go | 20 ++ internal/mysqlmodel/benefit_grant_task.go | 27 ++- internal/mysqlmodel/clothes.go | 1 + internal/mysqlmodel/collect.go | 2 +- internal/mysqlmodel/horoscope.go | 12 +- internal/mysqlmodel/like.go | 2 +- internal/mysqlmodel/order.go | 36 ++-- internal/mysqlmodel/quota.go | 12 +- internal/mysqlmodel/quotaflowlog.go | 2 +- internal/mysqlmodel/refundflowlog.go | 6 +- internal/mysqlmodel/user.go | 30 +-- internal/mysqlmodel/vipflowlog.go | 16 +- internal/redisdao/quota.go | 17 +- internal/service/adreward.go | 14 +- internal/service/airecommend.go | 27 ++- internal/service/benefit_compensation.go | 56 +++++- internal/service/closet.go | 29 ++- internal/service/clothes.go | 93 +++++++-- internal/service/constants.go | 34 ++-- internal/service/order.go | 183 +++++++++--------- internal/service/order_quota_test.go | 69 +++++++ internal/service/outfit.go | 39 ++-- internal/service/service.go | 72 +++---- internal/service/virtualpay.go | 32 +-- main.go | 2 +- .../000020_harden_benefit_workflows.down.sql | 3 + .../000020_harden_benefit_workflows.up.sql | 14 ++ pkg/setting/section.go | 11 +- scripts/audit_member_quota_duplicates.sql | 17 ++ 42 files changed, 1121 insertions(+), 304 deletions(-) create mode 100644 docs/code-review-2026-09-15.md create mode 100644 internal/mysqldao/benefit_revoke.go create mode 100644 internal/mysqldao/hardening_test.go create mode 100644 internal/mysqldao/member_quota.go create mode 100644 internal/mysqldao/member_quota_test.go create mode 100644 internal/service/order_quota_test.go create mode 100644 pkg/migration/000020_harden_benefit_workflows.down.sql create mode 100644 pkg/migration/000020_harden_benefit_workflows.up.sql create mode 100644 scripts/audit_member_quota_duplicates.sql diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 2f201b3..f475ef2 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -119,6 +119,7 @@ AdReward: # 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度; # 各类型金额可删除或置 0(表示不发放该类型激励)。 Enable: false + MaxClaimsPerDay: 1 # 单用户、单激励类型每日最多领取次数;接入广告平台服务端验真后可按业务调整 Cos: 1 # 广告激励赠送的 Cos 上传额度(永久) AI: 1 # 广告激励赠送的 AI 推荐额度(每日) OutfitShare: 1 # 广告激励赠送的社区分享额度(每日) diff --git a/docs/code-review-2026-09-15.md b/docs/code-review-2026-09-15.md new file mode 100644 index 0000000..935670a --- /dev/null +++ b/docs/code-review-2026-09-15.md @@ -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。 \ No newline at end of file diff --git a/go.mod b/go.mod index cff22f4..877dfe4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0c4b6c5..ef0164f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/mysqldao/benefit_revoke.go b/internal/mysqldao/benefit_revoke.go new file mode 100644 index 0000000..f6944a2 --- /dev/null +++ b/internal/mysqldao/benefit_revoke.go @@ -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 +} diff --git a/internal/mysqldao/clothes.go b/internal/mysqldao/clothes.go index fa3e7a6..9bb2193 100644 --- a/internal/mysqldao/clothes.go +++ b/internal/mysqldao/clothes.go @@ -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 +} diff --git a/internal/mysqldao/collect.go b/internal/mysqldao/collect.go index 24083f3..7509e89 100644 --- a/internal/mysqldao/collect.go +++ b/internal/mysqldao/collect.go @@ -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 } diff --git a/internal/mysqldao/hardening_test.go b/internal/mysqldao/hardening_test.go new file mode 100644 index 0000000..540fc23 --- /dev/null +++ b/internal/mysqldao/hardening_test.go @@ -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) + } +} diff --git a/internal/mysqldao/member_quota.go b/internal/mysqldao/member_quota.go new file mode 100644 index 0000000..3e34906 --- /dev/null +++ b/internal/mysqldao/member_quota.go @@ -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 +} diff --git a/internal/mysqldao/member_quota_test.go b/internal/mysqldao/member_quota_test.go new file mode 100644 index 0000000..b81b2ff --- /dev/null +++ b/internal/mysqldao/member_quota_test.go @@ -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) + } +} diff --git a/internal/mysqldao/order.go b/internal/mysqldao/order.go index c50e1f7..9039913 100644 --- a/internal/mysqldao/order.go +++ b/internal/mysqldao/order.go @@ -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") diff --git a/internal/mysqldao/outfit_share.go b/internal/mysqldao/outfit_share.go index 5af36c1..846c053 100644 --- a/internal/mysqldao/outfit_share.go +++ b/internal/mysqldao/outfit_share.go @@ -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) } diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go index 88e9391..6c9a837 100644 --- a/internal/mysqldao/quota.go +++ b/internal/mysqldao/quota.go @@ -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) { diff --git a/internal/mysqldao/user.go b/internal/mysqldao/user.go index dbfa1d1..216c85b 100644 --- a/internal/mysqldao/user.go +++ b/internal/mysqldao/user.go @@ -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) + ? diff --git a/internal/mysqlmodel/benefit_grant_task.go b/internal/mysqlmodel/benefit_grant_task.go index f3ada4e..5834c7c 100644 --- a/internal/mysqlmodel/benefit_grant_task.go +++ b/internal/mysqlmodel/benefit_grant_task.go @@ -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" } diff --git a/internal/mysqlmodel/clothes.go b/internal/mysqlmodel/clothes.go index f313df3..206a9b2 100644 --- a/internal/mysqlmodel/clothes.go +++ b/internal/mysqlmodel/clothes.go @@ -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"` // 失败原因 diff --git a/internal/mysqlmodel/collect.go b/internal/mysqlmodel/collect.go index a140243..73691cf 100644 --- a/internal/mysqlmodel/collect.go +++ b/internal/mysqlmodel/collect.go @@ -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=已取消 diff --git a/internal/mysqlmodel/horoscope.go b/internal/mysqlmodel/horoscope.go index 1ee2d87..ea4fa0c 100644 --- a/internal/mysqlmodel/horoscope.go +++ b/internal/mysqlmodel/horoscope.go @@ -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 { diff --git a/internal/mysqlmodel/like.go b/internal/mysqlmodel/like.go index 8625657..a805e90 100644 --- a/internal/mysqlmodel/like.go +++ b/internal/mysqlmodel/like.go @@ -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=已取消 diff --git a/internal/mysqlmodel/order.go b/internal/mysqlmodel/order.go index 0122b27..9551c41 100644 --- a/internal/mysqlmodel/order.go +++ b/internal/mysqlmodel/order.go @@ -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 { diff --git a/internal/mysqlmodel/quota.go b/internal/mysqlmodel/quota.go index 5200b3e..23d8a6a 100644 --- a/internal/mysqlmodel/quota.go +++ b/internal/mysqlmodel/quota.go @@ -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 { diff --git a/internal/mysqlmodel/quotaflowlog.go b/internal/mysqlmodel/quotaflowlog.go index d32dc6f..b9480b0 100644 --- a/internal/mysqlmodel/quotaflowlog.go +++ b/internal/mysqlmodel/quotaflowlog.go @@ -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"` // 变更后数值 diff --git a/internal/mysqlmodel/refundflowlog.go b/internal/mysqlmodel/refundflowlog.go index 7ded1ee..fd191a2 100644 --- a/internal/mysqlmodel/refundflowlog.go +++ b/internal/mysqlmodel/refundflowlog.go @@ -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"` // 微信退款单号 diff --git a/internal/mysqlmodel/user.go b/internal/mysqlmodel/user.go index 8711ac2..aea376b 100644 --- a/internal/mysqlmodel/user.go +++ b/internal/mysqlmodel/user.go @@ -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 { diff --git a/internal/mysqlmodel/vipflowlog.go b/internal/mysqlmodel/vipflowlog.go index 2cb7a7b..aaae993 100644 --- a/internal/mysqlmodel/vipflowlog.go +++ b/internal/mysqlmodel/vipflowlog.go @@ -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 { diff --git a/internal/redisdao/quota.go b/internal/redisdao/quota.go index 49cfe7a..00f1296 100644 --- a/internal/redisdao/quota.go +++ b/internal/redisdao/quota.go @@ -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() +} diff --git a/internal/service/adreward.go b/internal/service/adreward.go index 21fb009..66c442c 100644 --- a/internal/service/adreward.go +++ b/internal/service/adreward.go @@ -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() diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go index 8e2a068..9cc55f3 100644 --- a/internal/service/airecommend.go +++ b/internal/service/airecommend.go @@ -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)) diff --git a/internal/service/benefit_compensation.go b/internal/service/benefit_compensation.go index cb1d9dd..d5b36ad 100644 --- a/internal/service/benefit_compensation.go +++ b/internal/service/benefit_compensation.go @@ -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": ""}) +} diff --git a/internal/service/closet.go b/internal/service/closet.go index 1ad2320..d244639 100644 --- a/internal/service/closet.go +++ b/internal/service/closet.go @@ -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 } diff --git a/internal/service/clothes.go b/internal/service/clothes.go index f7c13b9..75ded7d 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -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) + } +} diff --git a/internal/service/constants.go b/internal/service/constants.go index 4bc9f87..33d9d72 100644 --- a/internal/service/constants.go +++ b/internal/service/constants.go @@ -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 ) diff --git a/internal/service/order.go b/internal/service/order.go index 13432ae..aabd51a 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -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 将订单模型映射为响应。 diff --git a/internal/service/order_quota_test.go b/internal/service/order_quota_test.go new file mode 100644 index 0000000..0db0049 --- /dev/null +++ b/internal/service/order_quota_test.go @@ -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) + } +} diff --git a/internal/service/outfit.go b/internal/service/outfit.go index 2c42ba7..efff0aa 100644 --- a/internal/service/outfit.go +++ b/internal/service/outfit.go @@ -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 } diff --git a/internal/service/service.go b/internal/service/service.go index 3dd4ae3..77d9569 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) } } diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go index ab3f9c0..a7ede20 100644 --- a/internal/service/virtualpay.go +++ b/internal/service/virtualpay.go @@ -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 diff --git a/main.go b/main.go index 9986bcb..91f746e 100644 --- a/main.go +++ b/main.go @@ -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") diff --git a/pkg/migration/000020_harden_benefit_workflows.down.sql b/pkg/migration/000020_harden_benefit_workflows.down.sql new file mode 100644 index 0000000..100f59f --- /dev/null +++ b/pkg/migration/000020_harden_benefit_workflows.down.sql @@ -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`; \ No newline at end of file diff --git a/pkg/migration/000020_harden_benefit_workflows.up.sql b/pkg/migration/000020_harden_benefit_workflows.up.sql new file mode 100644 index 0000000..4471ada --- /dev/null +++ b/pkg/migration/000020_harden_benefit_workflows.up.sql @@ -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='退款权益回收任务'; \ No newline at end of file diff --git a/pkg/setting/section.go b/pkg/setting/section.go index 3449272..9a1999a 100644 --- a/pkg/setting/section.go +++ b/pkg/setting/section.go @@ -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 { diff --git a/scripts/audit_member_quota_duplicates.sql b/scripts/audit_member_quota_duplicates.sql new file mode 100644 index 0000000..263e7a8 --- /dev/null +++ b/scripts/audit_member_quota_duplicates.sql @@ -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. \ No newline at end of file