Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -55,6 +55,8 @@ func orderStatusName(status uint8) string {
|
|||||||
case 5:
|
case 5:
|
||||||
return "finished"
|
return "finished"
|
||||||
case 6:
|
case 6:
|
||||||
|
return "claimed"
|
||||||
|
case 7:
|
||||||
return "refunded"
|
return "refunded"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
orderStatusRefunded = 6
|
orderStatusRefunded = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
type RefundOrderLogic struct {
|
type RefundOrderLogic struct {
|
||||||
@@ -67,15 +67,12 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
|||||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
|
|
||||||
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
|
|
||||||
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
|
|
||||||
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if refunded {
|
if refunded {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
||||||
@@ -183,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cachesToClear) > 0 {
|
if len(cachesToClear) > 0 && l.svcCtx.UserModel != nil {
|
||||||
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
|
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
|
||||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
|
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, subscribeID := range planCacheIDs {
|
for _, subscribeID := range planCacheIDs {
|
||||||
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
|
if l.svcCtx.SubscribeModel != nil {
|
||||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
|
||||||
|
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(userCacheTargets) > 0 {
|
if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil {
|
||||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
||||||
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
|
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
|
||||||
}
|
}
|
||||||
@@ -375,10 +374,15 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
|
// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
|
||||||
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333,
|
|
||||||
// 防止 content.order_no 子串误判。
|
|
||||||
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||||
|
refunded, err := log.HasOrderRefundLog(tx, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if refunded {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
return log.HasRefundCommissionLog(tx, orderNo)
|
return log.HasRefundCommissionLog(tx, orderNo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
|
|||||||
3: "closed",
|
3: "closed",
|
||||||
4: "failed",
|
4: "failed",
|
||||||
5: "finished",
|
5: "finished",
|
||||||
6: "refunded",
|
6: "claimed",
|
||||||
7: "unknown",
|
7: "refunded",
|
||||||
}
|
}
|
||||||
|
|
||||||
for input, want := range tests {
|
for input, want := range tests {
|
||||||
@@ -96,14 +96,64 @@ func TestBuildRefundAuditLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
|
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
|
||||||
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
|
const (
|
||||||
|
orderID = int64(1000)
|
||||||
|
orderNo = "ORD-REFUND-SUCCESS"
|
||||||
|
operatorUID = int64(519)
|
||||||
|
userID = int64(7000)
|
||||||
|
subscribeID = int64(8000)
|
||||||
|
userSubID = int64(9000)
|
||||||
|
)
|
||||||
|
|
||||||
|
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectQuery("FROM `order`").
|
||||||
|
WithArgs(orderID, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||||||
|
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WithArgs(orderID, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||||||
|
AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||||
|
mock.ExpectExec("UPDATE `order`").
|
||||||
|
WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("UPDATE `user_subscribe`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `system_logs`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
|
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RefundOrder error: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复:
|
||||||
|
// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须:
|
||||||
// 1. 返回 OrderAlreadyRefunded 错误码;
|
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||||||
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||||||
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||||||
//
|
//
|
||||||
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
||||||
// SELECT system_logs(命中 333)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
// SELECT system_logs(命中 24)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||||||
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
orderID = int64(53647)
|
orderID = int64(53647)
|
||||||
@@ -120,9 +170,9 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
|||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
||||||
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||||||
mock.ExpectQuery("FROM `system_logs`").
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo)))
|
AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
|
||||||
mock.ExpectRollback()
|
mock.ExpectRollback()
|
||||||
|
|
||||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
@@ -138,12 +188,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
|
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
|
||||||
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||||||
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
orderID = int64(1001)
|
orderID = int64(1001)
|
||||||
orderNo = "ORD-STATUS-6"
|
orderNo = "ORD-STATUS-7"
|
||||||
operatorUID = int64(519)
|
operatorUID = int64(519)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,6 +356,9 @@ func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) {
|
|||||||
WithArgs(orderID, 1).
|
WithArgs(orderID, 1).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||||||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||||
mock.ExpectQuery("FROM `system_logs`").
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
|
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
|
||||||
// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker
|
|
||||||
// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6
|
|
||||||
// 的语义。
|
|
||||||
//
|
//
|
||||||
// 实现细节:
|
// 实现细节:
|
||||||
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
|
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
|
||||||
@@ -36,3 +33,26 @@ func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasOrderRefundLog 判断指定订单号是否已写入 24 订单退款审计日志。
|
||||||
|
func HasOrderRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||||
|
if orderNo == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var logs []SystemLog
|
||||||
|
if err := tx.Model(&SystemLog{}).
|
||||||
|
Where("type = ? AND content LIKE ?", TypeOrderRefund.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)).
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("query order refund log failed: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range logs {
|
||||||
|
var content OrderRefund
|
||||||
|
if err := content.Unmarshal([]byte(item.Content)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if content.OrderNo == orderNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,62 @@ func TestHasRefundCommissionLog(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasOrderRefundLog(t *testing.T) {
|
||||||
|
const orderNo = "ORD-REFUND-AUDIT-1"
|
||||||
|
|
||||||
|
t.Run("returns true when order refund audit log exists", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
|
AddRow(1, fmt.Sprintf(`{"order_id":100,"order_no":"%s","order_status_after":7}`, orderNo)))
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = false, want true")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false when audit log order_no does not match", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
|
AddRow(1, `{"order_id":100,"order_no":"OTHER","order_status_after":7}`))
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = true, want false")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false for empty order_no without querying", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = true, want false")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
## 关联 Issue
|
||||||
|
|
||||||
|
Closes HIF-16
|
||||||
|
|
||||||
|
## 改动摘要
|
||||||
|
|
||||||
|
拆分订单退款终态与队列 claim 临时态:退款成功后写入 `status=7`,`status=6` 仅保留为 worker claimed 状态,避免后台恢复任务把退款订单重新入队。
|
||||||
|
|
||||||
|
## 改动细节
|
||||||
|
|
||||||
|
- `internal/logic/admin/order/refundOrderLogic.go`:退款状态改为 `7`,重复退款优先检查 `type=24` 退款审计日志,再兼容旧的 `333` 佣金退款日志。
|
||||||
|
- `queue/logic/order/activateOrderLogic.go`、`queue/logic/order/stuckOrderRecoveryLogic.go`:release/recovery 只按真实 `claimed(6)` 回退,不再依赖 `333` 日志猜测状态语义。
|
||||||
|
- `internal/logic/admin/order/getOrderListLogic.go`:管理端状态展示改为 `6=claimed`、`7=refunded`。
|
||||||
|
- `internal/model/log/refund.go`:新增 `HasOrderRefundLog`,用于基于退款审计日志做幂等判断。
|
||||||
|
- `internal/logic/admin/order/refundOrderLogic_test.go`、`internal/model/log/refund_test.go`、`queue/logic/order/order_status_recovery_test.go`:补充退款写 `7`、重复退款、退款日志判断、claim release/recovery 行为测试。
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- [ ] `go build ./...` 通过
|
||||||
|
- [x] `go vet ./...` 通过
|
||||||
|
- [ ] `go test -race ./... -count=1` 通过
|
||||||
|
- [ ] golangci-lint 通过
|
||||||
|
- [x] 新增/修改的逻辑有对应单测覆盖
|
||||||
|
- [x] (如涉及 DB 变更)无 DB migration
|
||||||
|
- [ ] (如涉及 API)curl / Postman 验证命令贴在下面
|
||||||
|
|
||||||
|
```
|
||||||
|
go test ./internal/logic/admin/order ./internal/model/log ./queue/logic/order
|
||||||
|
ok github.com/perfect-panel/server/internal/logic/admin/order
|
||||||
|
ok github.com/perfect-panel/server/internal/model/log
|
||||||
|
ok github.com/perfect-panel/server/queue/logic/order
|
||||||
|
|
||||||
|
go test ./...
|
||||||
|
PASS
|
||||||
|
|
||||||
|
go test -v ./...
|
||||||
|
PASS
|
||||||
|
|
||||||
|
go vet ./...
|
||||||
|
PASS
|
||||||
|
|
||||||
|
golangci-lint run --new-from-rev=origin/internal
|
||||||
|
PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:本地 `lefthook run pre-commit` 无法执行,因为环境缺少 `lefthook` 命令;已手动执行 hook 配置中的等价检查。全量 `golangci-lint run` 仍会失败在 internal 上已有历史 lint 问题,本分支使用 `--new-from-rev=origin/internal` 验证未新增 lint 问题。
|
||||||
|
|
||||||
|
未做 curl 验证:本地没有可用服务配置与测试数据库,且该改动主要通过事务单测覆盖退款状态与队列恢复行为。
|
||||||
|
|
||||||
|
## 风险 / 回滚
|
||||||
|
|
||||||
|
- 风险:线上若已经存在 `status=6` 表示已退款的历史订单,本改动不会自动迁移,后续查询会显示为 `claimed`。建议上线前评估历史数据,可按退款审计日志或旧 `333` 佣金退款日志筛出并迁移到 `status=7`。
|
||||||
|
- 回滚:回滚本 PR 后恢复旧的 `status=6` 退款语义和 `333` 日志守卫;不涉及 schema 变更。
|
||||||
|
|
||||||
|
## Reviewer 自检清单
|
||||||
|
|
||||||
|
- [x] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`)
|
||||||
|
- [x] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
|
||||||
|
- [x] 目标分支 = `internal`
|
||||||
|
- [x] 改动 scope 与 Issue 描述一致,无 scope creep
|
||||||
|
- [x] **无无关代码改动**(架构师红线)
|
||||||
|
- [x] 无密钥/凭证泄露
|
||||||
|
- [ ] CI 全绿
|
||||||
|
- [ ] 测试工程师已验收(如涉及业务逻辑)
|
||||||
@@ -49,6 +49,7 @@ const (
|
|||||||
OrderStatusClose = 3 // Order closed/cancelled
|
OrderStatusClose = 3 // Order closed/cancelled
|
||||||
OrderStatusFailed = 4 // Order processing failed
|
OrderStatusFailed = 4 // Order processing failed
|
||||||
OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order
|
OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order
|
||||||
|
OrderStatusRefunded = 7 // Order refunded terminal state
|
||||||
OrderStatusFinished = 5 // Order successfully completed
|
OrderStatusFinished = 5 // Order successfully completed
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -272,25 +273,6 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
|
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
|
||||||
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值。
|
|
||||||
// 若已存在 333 退款日志,说明此处的 status=6 是「已退款」,不能再降回 5,
|
|
||||||
// 否则下次 activate 会重新激活订阅、且管理员可二次触发退款导致佣金被多次扣减。
|
|
||||||
// 详见 HIF-131 / HIF-132。
|
|
||||||
refunded, err := log.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), orderNo)
|
|
||||||
if err != nil {
|
|
||||||
logger.WithContext(ctx).Error("Check refund log before release claim failed",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("order_no", orderNo),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("check refund log failed for order %s: %w", orderNo, err)
|
|
||||||
}
|
|
||||||
if refunded {
|
|
||||||
logger.WithContext(ctx).Info("Skip release claim for refunded order (status=6 + refund log)",
|
|
||||||
logger.Field("order_no", orderNo),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := l.svc.DB.WithContext(ctx).
|
if err := l.svc.DB.WithContext(ctx).
|
||||||
Model(&order.Order{}).
|
Model(&order.Order{}).
|
||||||
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package orderLogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReleaseClaimOnlyReleasesClaimedStatus(t *testing.T) {
|
||||||
|
const orderNo = "ORD-CLAIMED-1"
|
||||||
|
|
||||||
|
db, mock, cleanup := newOrderQueueTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("UPDATE `order`").
|
||||||
|
WithArgs(OrderStatusPaid, sqlmock.AnyArg(), orderNo, OrderStatusClaimed).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
logic := NewActivateOrderLogic(&svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.releaseClaim(context.Background(), orderNo); err != nil {
|
||||||
|
t.Fatalf("releaseClaim error: %v", err)
|
||||||
|
}
|
||||||
|
assertOrderQueueExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStuckOrderRecoveryScansOnlyClaimedStatus(t *testing.T) {
|
||||||
|
db, mock, cleanup := newOrderQueueTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `order`").
|
||||||
|
WithArgs(OrderStatusClaimed, sqlmock.AnyArg()).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}))
|
||||||
|
|
||||||
|
logic := NewStuckOrderRecoveryLogic(&svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.ProcessTask(context.Background(), nil); err != nil {
|
||||||
|
t.Fatalf("ProcessTask error: %v", err)
|
||||||
|
}
|
||||||
|
assertOrderQueueExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderQueueTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||||
|
if strings.Contains(actualSQL, expectedSQL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, mock, func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOrderQueueExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
|
||||||
"github.com/perfect-panel/server/internal/model/order"
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -55,25 +54,6 @@ func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task
|
|||||||
for i := range stuckOrders {
|
for i := range stuckOrders {
|
||||||
o := &stuckOrders[i]
|
o := &stuckOrders[i]
|
||||||
|
|
||||||
// 终态守卫:OrderStatusClaimed(6) 与 orderStatusRefunded(6) 共用同一枚举值,
|
|
||||||
// 若该订单已写入 333 退款佣金日志,说明状态 6 表示「已退款」而非「短暂 claim」,
|
|
||||||
// 必须跳过,否则会把已退款订单重置为 5 + 重新入队 activate,导致重复退款。
|
|
||||||
// 详见 HIF-131 / HIF-132。
|
|
||||||
refunded, err := logmodel.HasRefundCommissionLog(l.svc.DB.WithContext(ctx), o.OrderNo)
|
|
||||||
if err != nil {
|
|
||||||
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to check refund log",
|
|
||||||
logger.Field("order_no", o.OrderNo),
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if refunded {
|
|
||||||
logger.WithContext(ctx).Info("[StuckOrderRecovery] Skip refunded order (status=6 + refund log)",
|
|
||||||
logger.Field("order_no", o.OrderNo),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result := l.svc.DB.WithContext(ctx).
|
result := l.svc.DB.WithContext(ctx).
|
||||||
Model(&order.Order{}).
|
Model(&order.Order{}).
|
||||||
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).
|
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).
|
||||||
|
|||||||
Reference in New Issue
Block a user