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