feat: add order governance flags and reconciliation
This commit is contained in:
@@ -165,6 +165,10 @@ type SuperOrderListFilter struct {
|
||||
Type *consts.OrderType `query:"type"`
|
||||
// Status 订单状态过滤。
|
||||
Status *consts.OrderStatus `query:"status"`
|
||||
// IsFlagged 是否标记为问题订单(true/false)。
|
||||
IsFlagged *bool `query:"is_flagged"`
|
||||
// IsReconciled 是否已完成对账(true/false)。
|
||||
IsReconciled *bool `query:"is_reconciled"`
|
||||
// CreatedAtFrom 创建时间起始(RFC3339)。
|
||||
CreatedAtFrom *string `query:"created_at_from"`
|
||||
// CreatedAtTo 创建时间结束(RFC3339)。
|
||||
@@ -549,6 +553,22 @@ type SuperOrderItem struct {
|
||||
PaidAt string `json:"paid_at"`
|
||||
// RefundedAt 退款时间(RFC3339)。
|
||||
RefundedAt string `json:"refunded_at"`
|
||||
// IsFlagged 是否标记为问题订单。
|
||||
IsFlagged bool `json:"is_flagged"`
|
||||
// FlagReason 问题标记原因。
|
||||
FlagReason string `json:"flag_reason"`
|
||||
// FlaggedBy 标记操作者ID。
|
||||
FlaggedBy int64 `json:"flagged_by"`
|
||||
// FlaggedAt 标记时间(RFC3339)。
|
||||
FlaggedAt string `json:"flagged_at"`
|
||||
// IsReconciled 是否完成对账。
|
||||
IsReconciled bool `json:"is_reconciled"`
|
||||
// ReconcileNote 对账说明。
|
||||
ReconcileNote string `json:"reconcile_note"`
|
||||
// ReconciledBy 对账操作者ID。
|
||||
ReconciledBy int64 `json:"reconciled_by"`
|
||||
// ReconciledAt 对账时间(RFC3339)。
|
||||
ReconciledAt string `json:"reconciled_at"`
|
||||
// CreatedAt 创建时间(RFC3339)。
|
||||
CreatedAt string `json:"created_at"`
|
||||
// UpdatedAt 更新时间(RFC3339)。
|
||||
@@ -621,6 +641,20 @@ type SuperOrderRefundForm struct {
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
type SuperOrderFlagForm struct {
|
||||
// IsFlagged 是否标记为问题订单。
|
||||
IsFlagged bool `json:"is_flagged"`
|
||||
// Reason 标记原因(标记为问题时必填)。
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type SuperOrderReconcileForm struct {
|
||||
// IsReconciled 是否完成对账。
|
||||
IsReconciled bool `json:"is_reconciled"`
|
||||
// Note 对账说明(可选)。
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// AdminContentItem for super admin view
|
||||
type AdminContentItem struct {
|
||||
// Content 内容摘要信息。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
dto "quyun/v2/app/http/super/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
@@ -42,6 +43,42 @@ func (c *orders) Get(ctx fiber.Ctx, id int64) (*dto.SuperOrderDetail, error) {
|
||||
return services.Super.GetOrder(ctx, id)
|
||||
}
|
||||
|
||||
// Flag order
|
||||
//
|
||||
// @Router /super/v1/orders/:id<int>/flag [post]
|
||||
// @Summary Flag order
|
||||
// @Description Flag or unflag an order as problematic
|
||||
// @Tags Order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int64 true "Order ID"
|
||||
// @Param form body dto.SuperOrderFlagForm true "Flag form"
|
||||
// @Success 200 {string} string "OK"
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
// @Bind form body
|
||||
func (c *orders) Flag(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperOrderFlagForm) error {
|
||||
return services.Super.FlagOrder(ctx, user.ID, id, form)
|
||||
}
|
||||
|
||||
// Reconcile order
|
||||
//
|
||||
// @Router /super/v1/orders/:id<int>/reconcile [post]
|
||||
// @Summary Reconcile order
|
||||
// @Description Mark or unmark order reconciliation status
|
||||
// @Tags Order
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int64 true "Order ID"
|
||||
// @Param form body dto.SuperOrderReconcileForm true "Reconcile form"
|
||||
// @Success 200 {string} string "OK"
|
||||
// @Bind user local key(__ctx_user)
|
||||
// @Bind id path
|
||||
// @Bind form body
|
||||
func (c *orders) Reconcile(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperOrderReconcileForm) error {
|
||||
return services.Super.ReconcileOrder(ctx, user.ID, id, form)
|
||||
}
|
||||
|
||||
// Refund order
|
||||
//
|
||||
// @Router /super/v1/orders/:id<int>/refund [post]
|
||||
|
||||
@@ -267,6 +267,20 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
router.Get("/super/v1/orders/statistics"[len(r.Path()):], DataFunc0(
|
||||
r.orders.Statistics,
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /super/v1/orders/:id<int>/flag -> orders.Flag")
|
||||
router.Post("/super/v1/orders/:id<int>/flag"[len(r.Path()):], Func3(
|
||||
r.orders.Flag,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
Body[dto.SuperOrderFlagForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /super/v1/orders/:id<int>/reconcile -> orders.Reconcile")
|
||||
router.Post("/super/v1/orders/:id<int>/reconcile"[len(r.Path()):], Func3(
|
||||
r.orders.Reconcile,
|
||||
Local[*models.User]("__ctx_user"),
|
||||
PathParam[int64]("id"),
|
||||
Body[dto.SuperOrderReconcileForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Post /super/v1/orders/:id<int>/refund -> orders.Refund")
|
||||
router.Post("/super/v1/orders/:id<int>/refund"[len(r.Path()):], Func2(
|
||||
r.orders.Refund,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user