feat: add order governance flags and reconciliation

This commit is contained in:
2026-01-16 13:29:59 +08:00
parent 7ead7fc11c
commit e5f40287c3
17 changed files with 1247 additions and 103 deletions

View File

@@ -4529,6 +4529,12 @@ func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderList
if filter.Status != nil && *filter.Status != "" {
q = q.Where(tbl.Status.Eq(*filter.Status))
}
if filter.IsFlagged != nil {
q = q.Where(tbl.IsFlagged.Is(*filter.IsFlagged))
}
if filter.IsReconciled != nil {
q = q.Where(tbl.IsReconciled.Is(*filter.IsReconciled))
}
if filter.AmountPaidMin != nil {
q = q.Where(tbl.AmountPaid.Gte(*filter.AmountPaidMin))
}
@@ -4715,6 +4721,140 @@ func (s *super) GetOrder(ctx context.Context, id int64) (*super_dto.SuperOrderDe
}, nil
}
func (s *super) FlagOrder(ctx context.Context, operatorID, id int64, form *super_dto.SuperOrderFlagForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if id == 0 {
return errorx.ErrBadRequest.WithMsg("订单ID不能为空")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("标记参数不能为空")
}
reason := strings.TrimSpace(form.Reason)
if form.IsFlagged && reason == "" {
return errorx.ErrBadRequest.WithMsg("标记原因不能为空")
}
tenantID := int64(0)
err := models.Q.Transaction(func(tx *models.Query) error {
tbl, q := tx.Order.QueryContext(ctx)
o, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound
}
return errorx.ErrDatabaseError.WithCause(err)
}
tenantID = o.TenantID
// 运营治理:问题订单标记/取消标记需记录操作者与时间,并清空无效原因。
now := time.Now()
updates := map[string]interface{}{
"is_flagged": form.IsFlagged,
"flag_reason": reason,
"flagged_by": operatorID,
"updated_at": now,
}
if form.IsFlagged {
updates["flagged_at"] = now
} else {
updates["flag_reason"] = ""
updates["flagged_by"] = int64(0)
updates["flagged_at"] = nil
}
if _, err := q.Where(tbl.ID.Eq(id)).Updates(updates); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
})
if err != nil {
return err
}
if Audit != nil {
action := "flag_order"
detail := "标记问题订单"
if !form.IsFlagged {
action = "unflag_order"
detail = "取消问题订单标记"
}
if reason != "" {
detail += "" + reason
}
Audit.Log(ctx, tenantID, operatorID, action, cast.ToString(id), detail)
}
return nil
}
func (s *super) ReconcileOrder(ctx context.Context, operatorID, id int64, form *super_dto.SuperOrderReconcileForm) error {
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if id == 0 {
return errorx.ErrBadRequest.WithMsg("订单ID不能为空")
}
if form == nil {
return errorx.ErrBadRequest.WithMsg("对账参数不能为空")
}
note := strings.TrimSpace(form.Note)
tenantID := int64(0)
err := models.Q.Transaction(func(tx *models.Query) error {
tbl, q := tx.Order.QueryContext(ctx)
o, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound
}
return errorx.ErrDatabaseError.WithCause(err)
}
tenantID = o.TenantID
// 运营对账:对账与撤销对账需同步更新时间与操作者信息。
now := time.Now()
updates := map[string]interface{}{
"is_reconciled": form.IsReconciled,
"reconcile_note": note,
"reconciled_by": operatorID,
"updated_at": now,
}
if form.IsReconciled {
updates["reconciled_at"] = now
} else {
updates["reconcile_note"] = ""
updates["reconciled_by"] = int64(0)
updates["reconciled_at"] = nil
}
if _, err := q.Where(tbl.ID.Eq(id)).Updates(updates); err != nil {
return errorx.ErrDatabaseError.WithCause(err)
}
return nil
})
if err != nil {
return err
}
if Audit != nil {
action := "reconcile_order"
detail := "完成订单对账"
if !form.IsReconciled {
action = "unreconcile_order"
detail = "撤销订单对账"
}
if note != "" {
detail += "" + note
}
Audit.Log(ctx, tenantID, operatorID, action, cast.ToString(id), detail)
}
return nil
}
func (s *super) RefundOrder(ctx context.Context, id int64, form *super_dto.SuperOrderRefundForm) error {
o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First()
if err != nil {
@@ -4939,6 +5079,12 @@ func (s *super) toSuperOrderItem(o *models.Order, tenant *models.Tenant, buyer *
AmountDiscount: o.AmountDiscount,
AmountPaid: o.AmountPaid,
Snapshot: o.Snapshot.Data(),
IsFlagged: o.IsFlagged,
FlagReason: o.FlagReason,
FlaggedBy: o.FlaggedBy,
IsReconciled: o.IsReconciled,
ReconcileNote: o.ReconcileNote,
ReconciledBy: o.ReconciledBy,
CreatedAt: o.CreatedAt.Format(time.RFC3339),
UpdatedAt: o.UpdatedAt.Format(time.RFC3339),
}
@@ -4949,6 +5095,12 @@ func (s *super) toSuperOrderItem(o *models.Order, tenant *models.Tenant, buyer *
if !o.RefundedAt.IsZero() {
item.RefundedAt = o.RefundedAt.Format(time.RFC3339)
}
if !o.FlaggedAt.IsZero() {
item.FlaggedAt = o.FlaggedAt.Format(time.RFC3339)
}
if !o.ReconciledAt.IsZero() {
item.ReconciledAt = o.ReconciledAt.Format(time.RFC3339)
}
if tenant != nil {
item.Tenant = &super_dto.OrderTenantLite{

View File

@@ -2,10 +2,12 @@ package services
import (
"database/sql"
"errors"
"testing"
"time"
"quyun/v2/app/commands/testx"
"quyun/v2/app/errorx"
super_dto "quyun/v2/app/http/super/v1/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
@@ -597,3 +599,92 @@ func (s *SuperTestSuite) Test_ContentReview() {
So(reloaded2.Status, ShouldEqual, consts.ContentStatusBlocked)
})
}
func (s *SuperTestSuite) Test_OrderGovernance() {
Convey("OrderGovernance", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameOrder, models.TableNameAuditLog)
newOrder := func() *models.Order {
o := &models.Order{
TenantID: 1,
UserID: 2,
Type: consts.OrderTypeContentPurchase,
Status: consts.OrderStatusPaid,
AmountPaid: 100,
}
So(models.OrderQuery.WithContext(ctx).Create(o), ShouldBeNil)
return o
}
operatorID := int64(9001)
Convey("should require reason when flagging", func() {
o := newOrder()
err := Super.FlagOrder(ctx, operatorID, o.ID, &super_dto.SuperOrderFlagForm{
IsFlagged: true,
})
So(err, ShouldNotBeNil)
var appErr *errorx.AppError
So(errors.As(err, &appErr), ShouldBeTrue)
So(appErr.Code, ShouldEqual, errorx.ErrBadRequest.Code)
})
Convey("should flag and unflag order", func() {
o := newOrder()
reason := "支付回调异常"
So(Super.FlagOrder(ctx, operatorID, o.ID, &super_dto.SuperOrderFlagForm{
IsFlagged: true,
Reason: reason,
}), ShouldBeNil)
reloaded, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First()
So(err, ShouldBeNil)
So(reloaded.IsFlagged, ShouldBeTrue)
So(reloaded.FlagReason, ShouldEqual, reason)
So(reloaded.FlaggedBy, ShouldEqual, operatorID)
So(reloaded.FlaggedAt.IsZero(), ShouldBeFalse)
So(Super.FlagOrder(ctx, operatorID, o.ID, &super_dto.SuperOrderFlagForm{
IsFlagged: false,
}), ShouldBeNil)
reloaded, err = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First()
So(err, ShouldBeNil)
So(reloaded.IsFlagged, ShouldBeFalse)
So(reloaded.FlagReason, ShouldEqual, "")
So(reloaded.FlaggedBy, ShouldEqual, int64(0))
So(reloaded.FlaggedAt.IsZero(), ShouldBeTrue)
})
Convey("should reconcile and unreconcile order", func() {
o := newOrder()
note := "对账完成"
So(Super.ReconcileOrder(ctx, operatorID, o.ID, &super_dto.SuperOrderReconcileForm{
IsReconciled: true,
Note: note,
}), ShouldBeNil)
reloaded, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First()
So(err, ShouldBeNil)
So(reloaded.IsReconciled, ShouldBeTrue)
So(reloaded.ReconcileNote, ShouldEqual, note)
So(reloaded.ReconciledBy, ShouldEqual, operatorID)
So(reloaded.ReconciledAt.IsZero(), ShouldBeFalse)
So(Super.ReconcileOrder(ctx, operatorID, o.ID, &super_dto.SuperOrderReconcileForm{
IsReconciled: false,
}), ShouldBeNil)
reloaded, err = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First()
So(err, ShouldBeNil)
So(reloaded.IsReconciled, ShouldBeFalse)
So(reloaded.ReconcileNote, ShouldEqual, "")
So(reloaded.ReconciledBy, ShouldEqual, int64(0))
So(reloaded.ReconciledAt.IsZero(), ShouldBeTrue)
})
})
}