gpt
This commit is contained in:
@@ -119,6 +119,7 @@ AdReward:
|
|||||||
# 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度;
|
# 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度;
|
||||||
# 各类型金额可删除或置 0(表示不发放该类型激励)。
|
# 各类型金额可删除或置 0(表示不发放该类型激励)。
|
||||||
Enable: false
|
Enable: false
|
||||||
|
MaxClaimsPerDay: 1 # 单用户、单激励类型每日最多领取次数;接入广告平台服务端验真后可按业务调整
|
||||||
Cos: 1 # 广告激励赠送的 Cos 上传额度(永久)
|
Cos: 1 # 广告激励赠送的 Cos 上传额度(永久)
|
||||||
AI: 1 # 广告激励赠送的 AI 推荐额度(每日)
|
AI: 1 # 广告激励赠送的 AI 推荐额度(每日)
|
||||||
OutfitShare: 1 # 广告激励赠送的社区分享额度(每日)
|
OutfitShare: 1 # 广告激励赠送的社区分享额度(每日)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 代码审查与修复记录(2026-09-15)
|
||||||
|
|
||||||
|
本轮检查覆盖登录鉴权、普通支付、虚拟支付、退款与补偿、会员和额度、上传与异步处理、AI 推荐、穿搭、衣柜分享、收藏点赞及通知。已运行全仓库 `go test ./...` 与 `go vet ./...`。未连接生产数据库,也未发起真实支付、退款或外部模型调用。
|
||||||
|
|
||||||
|
## 已修复
|
||||||
|
|
||||||
|
1. **同一用户续费同一会员包重复赠送额度**
|
||||||
|
- 会员额度发放在用户行锁内按 `user_id + goods_id + quota_type` 检查历史凭证。
|
||||||
|
- 首购发放,续费只延长会员时间,不追加或重置额度;不同会员商品与加量包仍按各自规则处理。
|
||||||
|
- 历史重复数据不自动回收,使用 `scripts/audit_member_quota_duplicates.sql` 先盘点。
|
||||||
|
|
||||||
|
2. **退款完成后权益回收失败无法恢复**
|
||||||
|
- 新增 `benefit_revoke_tasks`。订单退款终态、待发放任务取消和回收任务创建在同一事务完成。
|
||||||
|
- 回收立即尝试,并由定时任务持续幂等重试;超过常规告警阈值后仍继续,避免永久遗漏。
|
||||||
|
- 退款回调即使退款流水已成功,也会继续核对订单和回收任务。
|
||||||
|
|
||||||
|
3. **退款与权益发放竞态**
|
||||||
|
- 发放任务执行前锁定订单并确认仍为已支付状态。
|
||||||
|
- 退款事务取消待处理任务;已在执行的任务与退款按订单锁串行,退款随后回收已发权益。
|
||||||
|
|
||||||
|
4. **VIP 开通/续费幂等键不稳定**
|
||||||
|
- 在用户锁内按订单号检查全部购买类 VIP 流水,再根据锁内旧状态确定开通或续费类型。
|
||||||
|
- 同一订单重试时不会因 `change_type` 从开通变成续费而重复增加会员天数。
|
||||||
|
|
||||||
|
5. **广告奖励可无限更换 `ref_id` 领取**
|
||||||
|
- 新增单用户、单奖励类型每日领取上限 `AdReward.MaxClaimsPerDay`,默认 1。
|
||||||
|
- 用户锁内完成当日计数校验、事件幂等、额度凭证和流水写入。
|
||||||
|
- 当前仓库没有广告平台服务端核验接口,因此本次用服务端每日硬上限消除无限领取;以后接入可信回调时,应再校验广告事件凭证。
|
||||||
|
|
||||||
|
6. **已支付订单缺少权益任务时永久遗漏**
|
||||||
|
- 补偿调度会扫描已支付但权益任务不完整的订单,补建任务并走统一幂等发放路径。
|
||||||
|
|
||||||
|
7. **每日额度重复计数、失败回退不对称、Redis 丢键重置**
|
||||||
|
- 删除业务成功后的第二次计数。
|
||||||
|
- MySQL `user_daily_usages` 改为每日额度权威账本,通过条件更新原子校验上限。
|
||||||
|
- Redis 仅作缓存;消费与回退后删除缓存,Redis 丢键不会重新放出额度。
|
||||||
|
|
||||||
|
8. **图片任务超时或重启后卡住**
|
||||||
|
- 建衣物记录前持久化原图路径;成功、失败或删除后清理文件。
|
||||||
|
- 失败状态与额度回退使用独立短超时上下文,不受主处理 context 超时影响。
|
||||||
|
- 新增超时任务原子认领与每分钟恢复调度,多实例不会重复认领同一任务。
|
||||||
|
|
||||||
|
9. **收到的穿搭分享在取消或转私后仍泄露内容**
|
||||||
|
- 接收列表联查分享和穿搭,仅返回分享正常、穿搭公开、审核通过且状态正常的记录。
|
||||||
|
|
||||||
|
10. **穿搭、AI 穿搭和衣柜分享的主记录与明细非事务写入**
|
||||||
|
- 创建、更新重建明细和删除统一使用数据库事务,明细失败时主记录一并回滚。
|
||||||
|
|
||||||
|
11. **权益依赖实时商品配置**
|
||||||
|
- 订单新增 `goods_type` 与 `quota_valid_days` 快照,普通支付与虚拟支付下单时写入,发放时读取订单快照。
|
||||||
|
- 迁移会为历史订单从商品表回填;未回填的历史记录保留兼容读取路径,读取失败会进入补偿重试。
|
||||||
|
|
||||||
|
## 按要求暂缓
|
||||||
|
|
||||||
|
1. **虚拟支付公开回调无限制读取请求体**:`internal/routers/api/v1/order.go:177`。
|
||||||
|
2. **令牌刷新并发与同秒重复问题**:`internal/service/user.go:280`、`pkg/lzcwyjwt/lzcwyjwt.go:30`。
|
||||||
|
|
||||||
|
## 上线事项
|
||||||
|
|
||||||
|
- 先执行数据库迁移 `000020_harden_benefit_workflows.up.sql`,再发布新代码。
|
||||||
|
- 配置 `AdReward.MaxClaimsPerDay`;缺省或非正数时服务按 1 处理。
|
||||||
|
- 测试库应验证普通支付、虚拟支付、退款回调重复投递、退款与补偿交错、服务重启后的图片恢复。
|
||||||
|
- 本轮没有修改线上数据、部署服务或提交 Git。
|
||||||
@@ -33,6 +33,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
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/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.3 // indirect
|
github.com/bytedance/sonic v1.15.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BenefitRevokeTaskDao struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewBenefitRevokeTaskDao() *BenefitRevokeTaskDao {
|
||||||
|
return &BenefitRevokeTaskDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
func (d *BenefitRevokeTaskDao) GetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.BenefitRevokeTask, error) {
|
||||||
|
var task mysqlmodel.BenefitRevokeTask
|
||||||
|
err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&task).Error
|
||||||
|
return &task, err
|
||||||
|
}
|
||||||
|
func (d *BenefitRevokeTaskDao) ListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitRevokeTask, error) {
|
||||||
|
var list []mysqlmodel.BenefitRevokeTask
|
||||||
|
err := d.db.WithContext(ctx).Where("status = ?", 0).Order("id ASC").Limit(limit).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
func (d *BenefitRevokeTaskDao) Claim(ctx context.Context, id int64) (bool, error) {
|
||||||
|
r := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ? AND status IN ?", id, []int8{0, 2}).Update("status", 3)
|
||||||
|
return r.RowsAffected > 0, r.Error
|
||||||
|
}
|
||||||
|
func (d *BenefitRevokeTaskDao) Update(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
|
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
func (d *BenefitRevokeTaskDao) RecoverStale(ctx context.Context, before int64) error {
|
||||||
|
return d.db.WithContext(ctx).Model(&mysqlmodel.BenefitRevokeTask{}).Where("status = ? AND updated_at < ?", 3, before).Update("status", 0).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundSettleWithRevokeTask 原子写入退款订单终态、取消未执行发放并创建回收任务。
|
||||||
|
func (d *BenefitRevokeTaskDao) RefundSettleWithRevokeTask(ctx context.Context, orderNo string, refundAmount, refundTime int64) (bool, error) {
|
||||||
|
settled := false
|
||||||
|
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order mysqlmodel.Order
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.OrderStatus == 5 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if order.OrderStatus != 2 && order.OrderStatus != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&mysqlmodel.Order{}).Where("id = ?", order.ID).Updates(map[string]any{"order_status": 5, "refund_time": refundTime, "refund_amount": refundAmount}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("order_no = ? AND status IN ?", orderNo, []int8{0, 2}).Update("status", 4).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.OrderStatus == 2 {
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mysqlmodel.BenefitRevokeTask{OrderNo: orderNo, UserID: order.UserID, MaxRetry: 10}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return settled, err
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package mysqldao
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
"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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserDailyUsageConsumeIsAtomic(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
rows int64
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "within_limit", rows: 1, want: true},
|
||||||
|
{name: "over_limit", rows: 0, want: false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
base, mock := newQuotaTestDAO(t)
|
||||||
|
dao := &UserDailyUsageDao{db: base.db}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO `user_daily_usages`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("UPDATE `user_daily_usages` SET `ai_recommend_count`=ai_recommend_count \\+ \\? WHERE").
|
||||||
|
WithArgs(int64(1), int64(7), int64(20260915), int64(1), int64(3), int64(0)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, tc.rows))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
got, err := dao.UserDailyUsageConsume(context.Background(), 7, 20260915, 2, 1, 3)
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("consumed=%v err=%v, want %v", got, err, tc.want)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVIPGrantUsesOrderWideIdempotency(t *testing.T) {
|
||||||
|
base, mock := newQuotaTestDAO(t)
|
||||||
|
dao := &UserDao{db: base.db}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "vip_type", "vip_expire"}).AddRow(7, 2, 2000))
|
||||||
|
mock.ExpectQuery("SELECT count\\(\\*\\) FROM `vip_flow_logs`").
|
||||||
|
WithArgs("ORDER-1", int8(1), int8(2), int64(0)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
flow := &mysqlmodel.VIPFlowLog{OrderNo: "ORDER-1", ChangeType: 2}
|
||||||
|
if err := dao.VIPGrantIdempotent(context.Background(), 7, 1000, 30, 2, flow); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefundSettlementCreatesRevokeTaskAtomically(t *testing.T) {
|
||||||
|
base, mock := newQuotaTestDAO(t)
|
||||||
|
dao := &BenefitRevokeTaskDao{db: base.db}
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `orders` .*FOR UPDATE").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "user_id", "order_status"}).AddRow(8, "ORDER-1", 7, 2))
|
||||||
|
mock.ExpectExec("UPDATE `orders` SET").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("UPDATE `benefit_grant_tasks` SET").WillReturnResult(sqlmock.NewResult(0, 2))
|
||||||
|
mock.ExpectExec("INSERT INTO `benefit_revoke_tasks`").WillReturnResult(sqlmock.NewResult(3, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
settled, err := dao.RefundSettleWithRevokeTask(context.Background(), "ORDER-1", 990, 1234)
|
||||||
|
if err != nil || !settled {
|
||||||
|
t.Fatalf("settled=%v err=%v", settled, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemberQuotaGrantCreateWithFlow 按用户、会员商品、额度类型仅发放一次。
|
||||||
|
// 锁定用户行将同一用户的并发续费串行化,检查与写入使用同一事务。
|
||||||
|
// 保留原始订单号用于退款追溯;历史凭证(包括已退款软删除的凭证)也参与去重,
|
||||||
|
// 因而额度用完、会员过期或退款后再次购买都不会重新领取首次赠送。
|
||||||
|
func (d *QuotaGrantDao) MemberQuotaGrantCreateWithFlow(ctx context.Context, goodsID int64, grant *mysqlmodel.QuotaGrant, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.MemberQuotaGrantCreateWithFlow")
|
||||||
|
defer span.End()
|
||||||
|
if goodsID <= 0 || grant == nil || flow == nil || grant.UserID <= 0 || grant.RefID == "" || grant.Source != 1 {
|
||||||
|
return false, fmt.Errorf("invalid member quota grant")
|
||||||
|
}
|
||||||
|
created := false
|
||||||
|
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var user mysqlmodel.User
|
||||||
|
if err := tx.Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ?", grant.UserID).First(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 当前读确保 MySQL REPEATABLE READ 下等待用户锁后可见上一事务的发放。
|
||||||
|
// 关联订单快照识别商品,不依赖当前商品名称或用户是否仍为新用户。
|
||||||
|
var previous []mysqlmodel.QuotaGrant
|
||||||
|
if err := tx.Unscoped().Model(&mysqlmodel.QuotaGrant{}).
|
||||||
|
Select("quota_grants.*").
|
||||||
|
Joins("JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id").
|
||||||
|
Where("quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ?", grant.UserID, goodsID, grant.QuotaType, grant.Source).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).Limit(1).Find(&previous).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(previous) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Create(grant).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ensureFlowOrderNo(flow)
|
||||||
|
if err := tx.Create(flow).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"go.opentelemetry.io/otel/trace/noop"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newQuotaTestDAO(t *testing.T) (*QuotaGrantDao, sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
conn, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldTracer := global.Tracer
|
||||||
|
global.Tracer = noop.NewTracerProvider().Tracer("quota-test")
|
||||||
|
t.Cleanup(func() { global.Tracer = oldTracer })
|
||||||
|
return &QuotaGrantDao{db: db}, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberQuotaFirstPurchaseAndRenewal(t *testing.T) {
|
||||||
|
// 每次请求都是新订单;已用完、过期和退款软删除的历史凭证仍代表已领取。
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
prior bool
|
||||||
|
used, deleted int64
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "first_purchase", want: true},
|
||||||
|
{name: "renewal_new_order", prior: true},
|
||||||
|
{name: "exhausted_quota", prior: true, used: 100},
|
||||||
|
{name: "refunded_purchase", prior: true, deleted: 123},
|
||||||
|
{name: "compensation_retry", prior: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dao, mock := newQuotaTestDAO(t)
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WithArgs(7, 0, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||||
|
rows := sqlmock.NewRows([]string{"id", "user_id", "quota_type", "source", "amount", "used", "deleted_at", "ref_id"})
|
||||||
|
if tc.prior {
|
||||||
|
rows.AddRow(1, 7, 2, 1, 100, tc.used, tc.deleted, "OLD-ORDER")
|
||||||
|
}
|
||||||
|
// 精确匹配确认按用户/商品/额度类型过滤,且没有 deleted_at/有效期/剩余量过滤。
|
||||||
|
query := "SELECT quota_grants.* FROM `quota_grants` JOIN orders ON orders.order_no = quota_grants.ref_id AND orders.user_id = quota_grants.user_id WHERE quota_grants.user_id = ? AND orders.goods_id = ? AND quota_grants.quota_type = ? AND quota_grants.source = ? LIMIT ? FOR UPDATE"
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(7, 9, 2, 1, 1).WillReturnRows(rows)
|
||||||
|
if !tc.prior {
|
||||||
|
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(2, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(3, 1))
|
||||||
|
}
|
||||||
|
mock.ExpectCommit()
|
||||||
|
grant := &mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW-ORDER"}
|
||||||
|
flow := &mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow", UserID: 7, OrderNo: "NEW-ORDER", QuotaType: 2}
|
||||||
|
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9, grant, flow)
|
||||||
|
if err != nil || created != tc.want {
|
||||||
|
t.Fatalf("created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberQuotaFailureRollsBack(t *testing.T) {
|
||||||
|
for _, stage := range []string{"user_lock", "history_read", "grant_insert", "flow_insert"} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
dao, mock := newQuotaTestDAO(t)
|
||||||
|
failure := errors.New("database unavailable")
|
||||||
|
mock.ExpectBegin()
|
||||||
|
lock := mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE")
|
||||||
|
if stage == "user_lock" {
|
||||||
|
lock.WillReturnError(failure)
|
||||||
|
} else {
|
||||||
|
lock.WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||||
|
history := mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE")
|
||||||
|
if stage == "history_read" {
|
||||||
|
history.WillReturnError(failure)
|
||||||
|
} else {
|
||||||
|
history.WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
insert := mock.ExpectExec("INSERT INTO `quota_grants`")
|
||||||
|
if stage == "grant_insert" {
|
||||||
|
insert.WillReturnError(failure)
|
||||||
|
} else {
|
||||||
|
insert.WillReturnResult(sqlmock.NewResult(2, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnError(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock.ExpectRollback()
|
||||||
|
created, err := dao.MemberQuotaGrantCreateWithFlow(context.Background(), 9,
|
||||||
|
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Amount: 100, Source: 1, RefID: "NEW"},
|
||||||
|
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: "flow"})
|
||||||
|
if created || !errors.Is(err, failure) {
|
||||||
|
t.Fatalf("created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddonQuotaStillGrantedPerOrder(t *testing.T) {
|
||||||
|
dao, mock := newQuotaTestDAO(t)
|
||||||
|
for _, order := range []string{"ADDON-1", "ADDON-2"} {
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO `quota_grants`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `quota_flow_logs`").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
created, err := dao.QuotaGrantCreateWithFlow(context.Background(),
|
||||||
|
&mysqlmodel.QuotaGrant{UserID: 7, QuotaType: 2, Source: 2, Amount: 100, RefID: order},
|
||||||
|
&mysqlmodel.QuotaFlowLog{QuotaFlowUUID: order, OrderNo: order})
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("order=%s created=%v err=%v", order, created, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,19 @@ func (d *OrderDao) OrderListVirtualUnconfirmed(ctx context.Context, since, until
|
|||||||
return list, nil
|
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 查询订单
|
// OrderGetByID 按主键 ID 查询订单
|
||||||
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
||||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, re
|
|||||||
q := d.db.WithContext(ctx).
|
q := d.db.WithContext(ctx).
|
||||||
Model(&mysqlmodel.OutfitShare{}).
|
Model(&mysqlmodel.OutfitShare{}).
|
||||||
Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid").
|
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 {
|
if status != 0 {
|
||||||
q = q.Where("outfit_shares.status = ?", status)
|
q = q.Where("outfit_shares.status = ?", status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,80 @@ func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Conte
|
|||||||
return nil
|
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 查询用户某日使用计数
|
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数
|
||||||
func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
|
func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
|
||||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
|
||||||
@@ -459,6 +533,35 @@ func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mys
|
|||||||
return created, nil
|
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 查询用户某类型全部凭证(未删除)。
|
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
|
||||||
// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
|
// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
|
||||||
func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
|
func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserDao 用户数据访问
|
// UserDao 用户数据访问
|
||||||
@@ -56,6 +57,25 @@ func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int6
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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
|
sql := `UPDATE users SET
|
||||||
vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END,
|
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) + ?
|
vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?
|
||||||
|
|||||||
@@ -20,3 +20,16 @@ type BenefitGrantTask struct {
|
|||||||
func (BenefitGrantTask) TableName() string {
|
func (BenefitGrantTask) TableName() string {
|
||||||
return "benefit_grant_tasks"
|
return "benefit_grant_tasks"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenefitRevokeTask 退款后的权益回收任务。退款终态与任务同事务落库,任务可幂等重试。
|
||||||
|
type BenefitRevokeTask struct {
|
||||||
|
Model
|
||||||
|
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_revoke_order"`
|
||||||
|
UserID int64 `gorm:"column:user_id"`
|
||||||
|
Status int8 `gorm:"column:status"`
|
||||||
|
RetryCount int `gorm:"column:retry_count"`
|
||||||
|
MaxRetry int `gorm:"column:max_retry"`
|
||||||
|
LastError string `gorm:"column:last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BenefitRevokeTask) TableName() string { return "benefit_revoke_tasks" }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Clothes struct {
|
|||||||
Name string `gorm:"column:name"` // 名称
|
Name string `gorm:"column:name"` // 名称
|
||||||
Remark string `gorm:"column:remark"` // 备注
|
Remark string `gorm:"column:remark"` // 备注
|
||||||
ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
|
ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
|
||||||
|
OriginalImagePath string `gorm:"column:original_image_path"` // 待处理原图本地路径(任务恢复用)
|
||||||
ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
|
ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
|
||||||
ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||||
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type Order struct {
|
|||||||
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||||
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
||||||
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
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"` // 赠送上传额度(快照)
|
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
|
||||||
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
||||||
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
||||||
|
|||||||
@@ -117,10 +117,14 @@ func QuotaDailyClosetShareValue(ctx context.Context, userID int64, date int64) (
|
|||||||
// ---- 通用每日额度原子限流(带限额) ----
|
// ---- 通用每日额度原子限流(带限额) ----
|
||||||
|
|
||||||
// dailyQuotaConsumeScript 带限额的原子自增:
|
// 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,返回消费后的当日计数。
|
// 当前计数 + n 超过上限返回 -1;否则 INCRBY 并在首次写入时设置 TTL,返回消费后的当日计数。
|
||||||
var dailyQuotaConsumeScript = redis.NewScript(`
|
var dailyQuotaConsumeScript = redis.NewScript(`
|
||||||
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
|
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 limit = tonumber(ARGV[1])
|
||||||
local n = tonumber(ARGV[2])
|
local n = tonumber(ARGV[2])
|
||||||
if v + n > limit then
|
if v + n > limit then
|
||||||
@@ -149,14 +153,14 @@ return redis.call('DECRBY', KEYS[1], n)
|
|||||||
|
|
||||||
// DailyQuotaConsume 原子消费当日额度。
|
// DailyQuotaConsume 原子消费当日额度。
|
||||||
// 返回 (消费后计数, nil);额度不足返回 (-1, nil);Redis 异常返回 (0, err)。
|
// 返回 (消费后计数, 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")
|
ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaConsume")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if limit <= 0 || n <= 0 {
|
if limit <= 0 || n <= 0 {
|
||||||
return -1, nil
|
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 {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -184,3 +188,10 @@ func DailyQuotaValue(ctx context.Context, key string) (int64, error) {
|
|||||||
return quotaDailyValue(ctx, key)
|
return quotaDailyValue(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DailyQuotaDelete 删除每日计数缓存;MySQL 是权威账本,后续读取会从数据库恢复。
|
||||||
|
func DailyQuotaDelete(ctx context.Context, key string) error {
|
||||||
|
if key == "" || global.RedisEngine == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return global.RedisEngine.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.
|
|||||||
QuotaType: quotaType,
|
QuotaType: quotaType,
|
||||||
Reason: "广告激励赠送",
|
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 {
|
if err != nil {
|
||||||
return nil, errcode.DatabaseError
|
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 的秒级时间戳(广告激励当天有效)。
|
// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。
|
||||||
func todayEndUnix() int64 {
|
func todayEndUnix() int64 {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
"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/errcode"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AIRecommendService AI 穿搭推荐业务服务。
|
// AIRecommendService AI 穿搭推荐业务服务。
|
||||||
@@ -112,19 +113,18 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
|
|||||||
AuditStatus: OutfitAuditPending,
|
AuditStatus: OutfitAuditPending,
|
||||||
Status: OutfitStatusUnsaved,
|
Status: OutfitStatusUnsaved,
|
||||||
}
|
}
|
||||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
|
if err := tx.Create(outfit).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clothesIDs := result.GetClothesIDs()
|
clothesIDs := result.GetClothesIDs()
|
||||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||||
for _, cid := range clothesIDs {
|
for _, cid := range clothesIDs {
|
||||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||||
}
|
}
|
||||||
if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
|
return tx.Create(&items).Error
|
||||||
// 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
|
}); err != nil {
|
||||||
_ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
|
return nil, fail(errcode.OutfitCreateFailed, "穿搭及明细落库失败")
|
||||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 写成功流水 + 当日计数。
|
// 7. 写成功流水 + 当日计数。
|
||||||
@@ -140,7 +140,6 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
|
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
|
||||||
}
|
}
|
||||||
recordDailyUsage(ctx, userID, QuotaTypeAI)
|
|
||||||
|
|
||||||
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
|
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
|
||||||
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
|
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
|
// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
|
||||||
@@ -46,6 +47,15 @@ func runBenefitCompensation() {
|
|||||||
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
|
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
|
||||||
|
|
||||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
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()))
|
_, _ = 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)
|
slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(tasks) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s := NewOrderService()
|
|
||||||
for i := range tasks {
|
for i := range tasks {
|
||||||
s.retryBenefitTask(ctx, &tasks[i])
|
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 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
|
// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
|
||||||
@@ -110,3 +125,34 @@ func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmod
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OrderService) executeRevokeTaskByOrder(ctx context.Context, orderNo string) {
|
||||||
|
task, err := mysqldao.NewBenefitRevokeTaskDao().GetByOrderNo(ctx, orderNo)
|
||||||
|
if err == nil {
|
||||||
|
s.executeRevokeTask(ctx, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderService) executeRevokeTask(ctx context.Context, task *mysqlmodel.BenefitRevokeTask) {
|
||||||
|
dao := mysqldao.NewBenefitRevokeTaskDao()
|
||||||
|
if task.Status == BenefitTaskSuccess {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.RetryCount >= task.MaxRetry {
|
||||||
|
slog.ErrorContext(ctx, "benefit revoke task exceeded normal retry threshold; continuing for eventual consistency", "order_no", task.OrderNo, "retry_count", task.RetryCount)
|
||||||
|
}
|
||||||
|
claimed, err := dao.Claim(ctx, task.ID)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
|
||||||
|
if err == nil {
|
||||||
|
err = s.revokeOrderBenefits(ctx, order)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": err.Error()})
|
||||||
|
slog.ErrorContext(ctx, "revoke refunded order benefits failed", "order_no", task.OrderNo, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess, "last_error": ""})
|
||||||
|
}
|
||||||
|
|||||||
+12
-17
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClosetShareService 衣柜分享业务服务。
|
// ClosetShareService 衣柜分享业务服务。
|
||||||
@@ -72,26 +73,22 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
|
|||||||
return nil, qerr
|
return nil, qerr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 创建分享。
|
// 3. 原子创建分享及快照明细,避免留下空分享。
|
||||||
shareUUID := genUUID()
|
shareUUID := genUUID()
|
||||||
share := &mysqlmodel.ClosetShare{
|
share := &mysqlmodel.ClosetShare{ShareUUID: shareUUID, UserID: userID, ShareType: request.ShareType, ExpireTime: request.ExpireTime, Status: ShareStatusNormal}
|
||||||
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. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。
|
|
||||||
items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
|
items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
|
||||||
for _, cid := range clothesIDs {
|
for _, cid := range clothesIDs {
|
||||||
items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid})
|
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)
|
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
||||||
return nil, errcode.DatabaseError
|
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)
|
slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
recordDailyUsage(ctx, userID, QuotaTypeClosetShare)
|
|
||||||
|
|
||||||
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
|
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+81
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -87,20 +88,28 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
|||||||
|
|
||||||
// 5. 创建衣物记录,初始状态为排队中。
|
// 5. 创建衣物记录,初始状态为排队中。
|
||||||
clothesUUID := genUUID()
|
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{
|
clothes := &mysqlmodel.Clothes{
|
||||||
ClothesUUID: clothesUUID,
|
ClothesUUID: clothesUUID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ImageSize: fileHeader.Size / 1024,
|
ImageSize: fileHeader.Size / 1024,
|
||||||
ImageProcessStatus: ImageProcessQueuing,
|
ImageProcessStatus: ImageProcessQueuing,
|
||||||
|
OriginalImagePath: originalPath,
|
||||||
}
|
}
|
||||||
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
||||||
|
_ = os.Remove(originalPath)
|
||||||
// 建记录失败,回退已扣额度。
|
// 建记录失败,回退已扣额度。
|
||||||
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
||||||
return nil, errcode.ClothesCreateFailed
|
return nil, errcode.ClothesCreateFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 异步抠图 + 分类。
|
// 6. 异步抠图 + 分类。
|
||||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
|
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
|
||||||
|
|
||||||
return &dto.ClothesUploadResponse{
|
return &dto.ClothesUploadResponse{
|
||||||
ClothesUUID: clothesUUID,
|
ClothesUUID: clothesUUID,
|
||||||
@@ -109,7 +118,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
|
// 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{}{}
|
imageProcessSem <- struct{}{}
|
||||||
defer func() { <-imageProcessSem }()
|
defer func() { <-imageProcessSem }()
|
||||||
@@ -152,11 +161,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
|
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)
|
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
|
||||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
|
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer os.Remove(processedPath)
|
||||||
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
|
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
|
||||||
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
|
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
|
||||||
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
|
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,
|
"processed_image": cosKey,
|
||||||
"image_process_status": ImageProcessSuccess,
|
"image_process_status": ImageProcessSuccess,
|
||||||
"fail_reason": "",
|
"fail_reason": "",
|
||||||
|
"original_image_path": "",
|
||||||
}
|
}
|
||||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
|
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
|
||||||
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
|
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
_ = os.Remove(originalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
|
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
|
||||||
@@ -258,35 +272,42 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
|
|||||||
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
|
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
|
||||||
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
||||||
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
||||||
vipExpire := getUserVIPExpire(ctx, userID)
|
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
|
||||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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
|
var c mysqlmodel.Clothes
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("clothes_uuid = ?", clothesUUID).
|
Where("clothes_uuid = ?", clothesUUID).
|
||||||
First(&c).Error; err != nil {
|
First(&c).Error; err != nil {
|
||||||
if isNotFound(err) {
|
if isNotFound(err) {
|
||||||
// 衣物已删除:删除事务已回退额度,跳过。
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 已是失败态:说明失败回退已处理过,跳过。
|
|
||||||
if c.ImageProcessStatus == ImageProcessFailed {
|
if c.ImageProcessStatus == ImageProcessFailed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
originalPath = c.OriginalImagePath
|
||||||
// 置失败。
|
|
||||||
if err := tx.Model(&mysqlmodel.Clothes{}).
|
if err := tx.Model(&mysqlmodel.Clothes{}).
|
||||||
Where("clothes_uuid = ?", clothesUUID).
|
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 err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
|
||||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
|
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)
|
vipExpire := getUserVIPExpire(ctx, userID)
|
||||||
var processedImage string
|
var processedImage string
|
||||||
|
var originalImagePath string
|
||||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var c mysqlmodel.Clothes
|
var c mysqlmodel.Clothes
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
@@ -424,6 +446,7 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
processedImage = c.ProcessedImage
|
processedImage = c.ProcessedImage
|
||||||
|
originalImagePath = c.OriginalImagePath
|
||||||
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -442,6 +465,9 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
||||||
|
if originalImagePath != "" {
|
||||||
|
_ = os.Remove(originalImagePath)
|
||||||
|
}
|
||||||
if processedImage != "" {
|
if processedImage != "" {
|
||||||
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
|
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)
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,10 +204,12 @@ const (
|
|||||||
BenefitTaskSuccess int8 = 1 // 已发放
|
BenefitTaskSuccess int8 = 1 // 已发放
|
||||||
BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
|
BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
|
||||||
BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
|
BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
|
||||||
|
BenefitTaskCancelled int8 = 4 // 订单已退款,禁止继续发放
|
||||||
|
|
||||||
benefitMaxRetry int = 3 // 默认最大重试次数
|
benefitMaxRetry int = 3 // 默认最大重试次数
|
||||||
|
|
||||||
// benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
|
// benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
|
||||||
// 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
|
// 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
|
||||||
benefitRetryCooldownSeconds int64 = 300
|
benefitRetryCooldownSeconds int64 = 300
|
||||||
|
benefitRevokeMaxRetry = 10
|
||||||
)
|
)
|
||||||
|
|||||||
+91
-86
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrderService 订单与退款业务服务。
|
// OrderService 订单与退款业务服务。
|
||||||
@@ -81,6 +82,8 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
|
|||||||
PayChannel: PayChannelWechat,
|
PayChannel: PayChannelWechat,
|
||||||
OrderStatus: OrderStatusPending,
|
OrderStatus: OrderStatusPending,
|
||||||
MemberDays: goods.MemberDays,
|
MemberDays: goods.MemberDays,
|
||||||
|
GoodsType: goods.GoodsType,
|
||||||
|
QuotaValidDays: goods.ValidDays,
|
||||||
CosQuota: goods.CosQuota,
|
CosQuota: goods.CosQuota,
|
||||||
AIRecommendQuota: goods.AIRecommendQuota,
|
AIRecommendQuota: goods.AIRecommendQuota,
|
||||||
ClosetShareQuota: goods.ClosetShareQuota,
|
ClosetShareQuota: goods.ClosetShareQuota,
|
||||||
@@ -358,43 +361,35 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
|
|||||||
}
|
}
|
||||||
return errcode.DatabaseError
|
return errcode.DatabaseError
|
||||||
}
|
}
|
||||||
// 已终态则幂等返回。
|
if flow.Status == RefundStatusFailed {
|
||||||
if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验回调退款金额与流水一致,防止金额被篡改。
|
|
||||||
if data.Amount.Refund != flow.RefundAmount {
|
if data.Amount.Refund != flow.RefundAmount {
|
||||||
slog.ErrorContext(ctx, "refund notify: amount mismatch",
|
slog.ErrorContext(ctx, "refund notify: amount mismatch", "out_refund_no", data.OutRefundNo, "notify_amount", data.Amount.Refund, "flow_amount", flow.RefundAmount)
|
||||||
"out_refund_no", data.OutRefundNo,
|
|
||||||
"notify_amount", data.Amount.Refund,
|
|
||||||
"flow_amount", flow.RefundAmount,
|
|
||||||
)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.RefundStatus == "SUCCESS" {
|
if data.RefundStatus == "SUCCESS" {
|
||||||
// 条件流转,防止并发/乱序回调覆盖状态。
|
if flow.Status != RefundStatusSuccess {
|
||||||
ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw})
|
||||||
"status": RefundStatusSuccess,
|
if uerr != nil {
|
||||||
"success_time": nowUnix(),
|
return errcode.DatabaseError
|
||||||
"callback_raw": data.Raw,
|
}
|
||||||
})
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil // 已被并发回调处理。
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||||||
if gerr == nil {
|
if gerr != nil {
|
||||||
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,23 +533,30 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
|
|||||||
|
|
||||||
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
|
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
|
||||||
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
|
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
|
||||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
var grantErr error
|
||||||
claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
|
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err != nil || !claimed {
|
var order mysqlmodel.Order
|
||||||
return // 已被其他实例处理。
|
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 {
|
if e := s.doGrantBenefit(ctx, task); e != nil {
|
||||||
// 发放失败:回退为待发放并记录错误,交由补偿任务重试。
|
grantErr = e
|
||||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
|
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
|
||||||
"status": BenefitTaskPending,
|
}
|
||||||
"retry_count": gorm.Expr("retry_count + 1"),
|
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error
|
||||||
"last_error": e.Error(),
|
})
|
||||||
})
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "grant benefit failed",
|
slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
|
||||||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
|
} else if grantErr != nil {
|
||||||
return
|
slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr)
|
||||||
}
|
}
|
||||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
|
// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
|
||||||
@@ -587,10 +589,22 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
|||||||
expireTime := int64(0) // 0 表示跟随 VIP
|
expireTime := int64(0) // 0 表示跟随 VIP
|
||||||
reason := "购买会员赠送"
|
reason := "购买会员赠送"
|
||||||
|
|
||||||
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
|
||||||
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
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
|
source = QuotaGrantSourceAddon
|
||||||
days := goods.ValidDays
|
days := validDays
|
||||||
if days <= 0 {
|
if days <= 0 {
|
||||||
days = 30
|
days = 30
|
||||||
}
|
}
|
||||||
@@ -625,7 +639,14 @@ func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.Ben
|
|||||||
QuotaType: quotaType,
|
QuotaType: quotaType,
|
||||||
Reason: reason,
|
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 errcode.DatabaseError
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -674,46 +695,42 @@ func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, o
|
|||||||
// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
|
// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
|
||||||
// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
|
// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
|
||||||
// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
|
// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
|
||||||
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) {
|
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) error {
|
||||||
from := order.OrderStatus
|
settled, err := mysqldao.NewBenefitRevokeTaskDao().RefundSettleWithRevokeTask(ctx, order.OrderNo, refundAmount, nowUnix())
|
||||||
if from != OrderStatusPaid && from != OrderStatusCancelled {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
|
||||||
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
|
||||||
"refund_time": nowUnix(),
|
|
||||||
"refund_amount": refundAmount,
|
|
||||||
})
|
|
||||||
// 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
|
|
||||||
if from == OrderStatusPaid {
|
|
||||||
s.revokeOrderBenefits(ctx, order)
|
|
||||||
}
|
}
|
||||||
|
if settled {
|
||||||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
||||||
}
|
}
|
||||||
|
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
|
||||||
|
s.executeRevokeTaskByOrder(ctx, order.OrderNo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
||||||
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度),
|
// 会员天数走 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 {
|
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 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
|
// buildQuotaRevokeFlows 构造退款回收流水:按该订单的额度凭证逐类记录回收量。
|
||||||
// 已用部分不追回,故变更量只记未用量 (amount - used);已全部用掉的凭证不写流水。
|
// 已用部分不追回,故变更量只记未用量 (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)
|
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
|
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))
|
flows := make([]*mysqlmodel.QuotaFlowLog, 0, len(grants))
|
||||||
@@ -747,33 +764,21 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
|
|||||||
Reason: "退款回收未使用的赠送额度",
|
Reason: "退款回收未使用的赠送额度",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return flows
|
return flows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// revokeVIP 幂等回退会员天数(退款回收)。
|
// 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)
|
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
flow := &mysqlmodel.VIPFlowLog{
|
flow := &mysqlmodel.VIPFlowLog{UserID: userID, ChangeType: VIPChangeTypeRefundRevoke, OldVIPType: user.VIPType, NewVIPType: user.VIPType, OldVIPExpire: user.VIPExpire, OrderNo: orderNo, Source: VIPSourceSystem, Reason: "退款回收会员"}
|
||||||
UserID: userID,
|
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) {
|
||||||
ChangeType: VIPChangeTypeRefundRevoke,
|
return err
|
||||||
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 {
|
|
||||||
if !isDuplicateKey(err) {
|
|
||||||
slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 会员信息已变更,失效缓存。
|
|
||||||
_ = redisdao.UserInfoDel(ctx, userID)
|
_ = redisdao.UserInfoDel(ctx, userID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildOrderResponse 将订单模型映射为响应。
|
// buildOrderResponse 将订单模型映射为响应。
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"go.opentelemetry.io/otel/trace/noop"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupOrderQuotaTest(t *testing.T) sqlmock.Sqlmock {
|
||||||
|
t.Helper()
|
||||||
|
conn, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldDB, oldTracer := global.MysqlEngine, global.Tracer
|
||||||
|
global.MysqlEngine, global.Tracer = db, noop.NewTracerProvider().Tracer("order-test")
|
||||||
|
t.Cleanup(func() { global.MysqlEngine, global.Tracer = oldDB, oldTracer; conn.Close() })
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderQuotaGoodsReadFailureDoesNotGrant(t *testing.T) {
|
||||||
|
mock := setupOrderQuotaTest(t)
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", 0, 0))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `goods`").WillReturnError(errors.New("database unavailable"))
|
||||||
|
err := NewOrderService().grantQuotaGrant(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100}, QuotaTypeCos)
|
||||||
|
if err != errcode.DatabaseError {
|
||||||
|
t.Fatalf("got %v, want DatabaseError", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderRenewalAndCompensationSkipQuota(t *testing.T) {
|
||||||
|
mock := setupOrderQuotaTest(t)
|
||||||
|
svc := NewOrderService()
|
||||||
|
// 首购凭证已存在:新续费订单和该订单补偿执行均不得插入额度或流水。
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `orders`").WillReturnRows(sqlmock.NewRows([]string{"order_no", "goods_type", "quota_valid_days"}).AddRow("RENEW", GoodsTypeMember, 30))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `users`").WillReturnRows(sqlmock.NewRows([]string{"id", "vip_expire"}).AddRow(7, nowUnix()+86400))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `quota_grants`").WillReturnRows(sqlmock.NewRows([]string{"id", "source", "amount", "used", "expire_time"}).AddRow(1, QuotaGrantSourceMember, 100, 40, 0))
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `user_quotas`").WillReturnRows(sqlmock.NewRows([]string{"used"}).AddRow(0))
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("SELECT .* FROM `users` .*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
|
||||||
|
mock.ExpectQuery("SELECT quota_grants.* .*FOR UPDATE").WithArgs(7, 9, QuotaTypeCos, QuotaGrantSourceMember, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "amount", "used", "ref_id"}).AddRow(1, 100, 40, "FIRST"))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
err := svc.doGrantBenefit(context.Background(), &mysqlmodel.BenefitGrantTask{UserID: 7, GoodsID: 9, OrderNo: "RENEW", Amount: 100, BenefitType: BenefitTypeCos})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-14
@@ -60,11 +60,19 @@ func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.O
|
|||||||
AuditStatus: OutfitAuditPending,
|
AuditStatus: OutfitAuditPending,
|
||||||
Status: OutfitStatusNormal,
|
Status: OutfitStatusNormal,
|
||||||
}
|
}
|
||||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return nil, errcode.OutfitCreateFailed
|
if err := tx.Create(outfit).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
dbItems := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||||
if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
|
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
|
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_name", request.OutfitName)
|
||||||
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
|
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
|
||||||
setIfNotEmpty(updates, "scene", request.Scene)
|
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 {
|
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 {
|
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -178,10 +184,14 @@ func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.O
|
|||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return nil, cerr
|
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
|
return nil, errcode.OutfitDeleteFailed
|
||||||
}
|
}
|
||||||
_ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
|
|
||||||
return &dto.OutfitDeleteResponse{}, nil
|
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
|
return &dto.OutfitPublicResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-43
@@ -82,37 +82,9 @@ func genUUID() string {
|
|||||||
|
|
||||||
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
|
// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
|
||||||
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
|
// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
|
||||||
func recordDailyUsage(ctx context.Context, userID int64, kind int8) {
|
func recordDailyUsage(ctx context.Context, userID int64, kind int8, delta int64) {
|
||||||
usageDao := mysqldao.NewUserDailyUsageDao()
|
if err := mysqldao.NewUserDailyUsageDao().UserDailyUsageAdjust(ctx, userID, todayStatDate(), kind, delta); err != nil {
|
||||||
statDate := todayStatDate()
|
slog.WarnContext(ctx, "adjust daily usage failed", "user_id", userID, "kind", kind, "delta", delta, "error", err)
|
||||||
|
|
||||||
// 确保当日计数行存在。
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
|
||||||
key := dailyQuotaKey(quotaType, userID, todayStatDate())
|
usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
|
||||||
if key == "" {
|
if err == nil {
|
||||||
return 0
|
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 val
|
||||||
}
|
}
|
||||||
return getDailyUsedFromMySQL(ctx, userID, quotaType)
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
|
// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
|
||||||
@@ -257,15 +237,17 @@ func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int6
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return errcode.QuotaInsufficient
|
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 {
|
if err != nil {
|
||||||
return errcode.CacheError
|
return errcode.DatabaseError
|
||||||
}
|
}
|
||||||
if val < 0 {
|
if !consumed {
|
||||||
return errcode.QuotaInsufficient
|
return errcode.QuotaInsufficient
|
||||||
}
|
}
|
||||||
// 同步 MySQL 当日计数(统计/兜底)。
|
// Redis 只作缓存;删除后下次从 MySQL 权威计数恢复,不受丢键影响。
|
||||||
recordDailyUsage(ctx, userID, quotaType)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +257,12 @@ func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return
|
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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
|
|||||||
PayChannel: PayChannelVirtual,
|
PayChannel: PayChannelVirtual,
|
||||||
OrderStatus: OrderStatusPending,
|
OrderStatus: OrderStatusPending,
|
||||||
MemberDays: goods.MemberDays,
|
MemberDays: goods.MemberDays,
|
||||||
|
GoodsType: goods.GoodsType,
|
||||||
|
QuotaValidDays: goods.ValidDays,
|
||||||
CosQuota: goods.CosQuota,
|
CosQuota: goods.CosQuota,
|
||||||
AIRecommendQuota: goods.AIRecommendQuota,
|
AIRecommendQuota: goods.AIRecommendQuota,
|
||||||
ClosetShareQuota: goods.ClosetShareQuota,
|
ClosetShareQuota: goods.ClosetShareQuota,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func main() {
|
|||||||
|
|
||||||
// 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。
|
// 启动权益发放补偿、订单超时过期与虚拟支付查单补偿后台任务。
|
||||||
service.StartBenefitCompensationScheduler()
|
service.StartBenefitCompensationScheduler()
|
||||||
|
service.StartClothesProcessingRecoveryScheduler()
|
||||||
service.StartOrderExpireScheduler()
|
service.StartOrderExpireScheduler()
|
||||||
service.StartVirtualPayConfirmScheduler()
|
service.StartVirtualPayConfirmScheduler()
|
||||||
|
|
||||||
@@ -217,7 +218,6 @@ func setupSetting() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。
|
// 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。
|
||||||
if global.ServerSetting == nil {
|
if global.ServerSetting == nil {
|
||||||
return fmt.Errorf("Server config section is missing")
|
return fmt.Errorf("Server config section is missing")
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS `benefit_revoke_tasks`;
|
||||||
|
ALTER TABLE `clothes` DROP COLUMN `original_image_path`;
|
||||||
|
ALTER TABLE `orders` DROP COLUMN `quota_valid_days`, DROP COLUMN `goods_type`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE `orders`
|
||||||
|
ADD COLUMN `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照' AFTER `member_days`,
|
||||||
|
ADD COLUMN `quota_valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '额度有效期天数快照' AFTER `goods_type`;
|
||||||
|
UPDATE `orders` o JOIN `goods` g ON g.id = o.goods_id
|
||||||
|
SET o.goods_type = g.goods_type, o.quota_valid_days = g.valid_days WHERE o.goods_type = 0;
|
||||||
|
ALTER TABLE `clothes` ADD COLUMN `original_image_path` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '待处理原图本地路径' AFTER `processed_image`;
|
||||||
|
CREATE TABLE `benefit_revoke_tasks` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT, `created_at` BIGINT NOT NULL DEFAULT 0, `updated_at` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`deleted_at` BIGINT NOT NULL DEFAULT 0, `created_by` BIGINT NOT NULL DEFAULT 0, `updated_by` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`deleted_by` BIGINT NOT NULL DEFAULT 0, `order_no` VARCHAR(64) NOT NULL DEFAULT '', `user_id` BIGINT NOT NULL DEFAULT 0,
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 0, `retry_count` INT NOT NULL DEFAULT 0, `max_retry` INT NOT NULL DEFAULT 10,
|
||||||
|
`last_error` VARCHAR(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_benefit_revoke_order` (`order_no`),
|
||||||
|
KEY `idx_benefit_revoke_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款权益回收任务';
|
||||||
@@ -147,6 +147,7 @@ type AdRewardSettingS struct {
|
|||||||
AI *int64 // 广告激励赠送的 AI 推荐额度(固定值)
|
AI *int64 // 广告激励赠送的 AI 推荐额度(固定值)
|
||||||
OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值)
|
OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值)
|
||||||
ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值)
|
ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值)
|
||||||
|
MaxClaimsPerDay int // 单用户、单类型每天最多领取次数;未配置默认 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Setting) ReadSection(k string, v any) error {
|
func (s *Setting) ReadSection(k string, v any) error {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Read-only audit: repeated active member grants per user/product/quota type.
|
||||||
|
-- This query does not change quota balances or historical accounting records.
|
||||||
|
SELECT g.user_id, o.goods_id, g.quota_type,
|
||||||
|
COUNT(*) AS grant_count,
|
||||||
|
SUM(g.amount) AS granted_amount,
|
||||||
|
SUM(g.used) AS used_amount,
|
||||||
|
SUM(g.amount - g.used) AS recorded_remaining,
|
||||||
|
MIN(g.id) AS first_grant_id,
|
||||||
|
GROUP_CONCAT(g.ref_id ORDER BY g.id) AS order_numbers
|
||||||
|
FROM quota_grants AS g
|
||||||
|
JOIN orders AS o ON o.order_no = g.ref_id AND o.user_id = g.user_id
|
||||||
|
WHERE g.source = 1 AND g.deleted_at = 0
|
||||||
|
GROUP BY g.user_id, o.goods_id, g.quota_type
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY grant_count DESC, g.user_id, o.goods_id, g.quota_type;
|
||||||
|
-- recorded_remaining is not necessarily effective remaining: VIP may be expired,
|
||||||
|
-- and daily quota types use separate daily usage counters. Do not deduct it blindly.
|
||||||
Reference in New Issue
Block a user