diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 7e24d99..0281576 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -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 内容摘要信息。 diff --git a/backend/app/http/super/v1/orders.go b/backend/app/http/super/v1/orders.go index 878b99f..95ec71e 100644 --- a/backend/app/http/super/v1/orders.go +++ b/backend/app/http/super/v1/orders.go @@ -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/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/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/refund [post] diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index a44fa66..0e940ae 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -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/flag -> orders.Flag") + router.Post("/super/v1/orders/:id/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/reconcile -> orders.Reconcile") + router.Post("/super/v1/orders/:id/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/refund -> orders.Refund") router.Post("/super/v1/orders/:id/refund"[len(r.Path()):], Func2( r.orders.Refund, diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 22d372a..d7974e8 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -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{ diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 474588a..0cf1ceb 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -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) + }) + }) +} diff --git a/backend/database/migrations/20260116044549_add_order_governance_fields.sql b/backend/database/migrations/20260116044549_add_order_governance_fields.sql new file mode 100644 index 0000000..776b47e --- /dev/null +++ b/backend/database/migrations/20260116044549_add_order_governance_fields.sql @@ -0,0 +1,39 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS is_flagged BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS flag_reason VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS flagged_by BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS flagged_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS is_reconciled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS reconcile_note VARCHAR(255) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reconciled_by BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ; + +COMMENT ON COLUMN orders.is_flagged IS '问题订单标记;用途:运营标注需复核订单;默认 false。'; +COMMENT ON COLUMN orders.flag_reason IS '问题标记原因;用途:说明问题点与风险;默认空字符串。'; +COMMENT ON COLUMN orders.flagged_by IS '问题标记操作者ID;用途:审计追踪;默认 0 表示未标记。'; +COMMENT ON COLUMN orders.flagged_at IS '问题标记时间;用途:记录标记时效;未标记为空。'; +COMMENT ON COLUMN orders.is_reconciled IS '对账状态;用途:标识是否完成人工对账;默认 false。'; +COMMENT ON COLUMN orders.reconcile_note IS '对账说明;用途:记录对账备注与结论;默认空字符串。'; +COMMENT ON COLUMN orders.reconciled_by IS '对账操作者ID;用途:审计追踪;默认 0 表示未对账。'; +COMMENT ON COLUMN orders.reconciled_at IS '对账时间;用途:记录完成对账时间;未对账为空。'; + +CREATE INDEX IF NOT EXISTS orders_is_flagged_idx ON orders(is_flagged); +CREATE INDEX IF NOT EXISTS orders_is_reconciled_idx ON orders(is_reconciled); +CREATE INDEX IF NOT EXISTS orders_flagged_at_idx ON orders(flagged_at); +CREATE INDEX IF NOT EXISTS orders_reconciled_at_idx ON orders(reconciled_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE orders + DROP COLUMN IF EXISTS reconciled_at, + DROP COLUMN IF EXISTS reconciled_by, + DROP COLUMN IF EXISTS reconcile_note, + DROP COLUMN IF EXISTS is_reconciled, + DROP COLUMN IF EXISTS flagged_at, + DROP COLUMN IF EXISTS flagged_by, + DROP COLUMN IF EXISTS flag_reason, + DROP COLUMN IF EXISTS is_flagged; +-- +goose StatementEnd diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index fc251f6..a3de8e8 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -40,9 +40,9 @@ type Content struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured + Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` - Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index d462865..ddbf1b6 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -46,6 +46,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") + _contentQuery.Comments = contentQueryHasManyComments{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Comments", "Comment"), + } + _contentQuery.Author = contentQueryBelongsToAuthor{ db: db.Session(&gorm.Session{}), @@ -58,12 +64,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("ContentAssets", "ContentAsset"), } - _contentQuery.Comments = contentQueryHasManyComments{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Comments", "Comment"), - } - _contentQuery.fillFieldMap() return _contentQuery @@ -94,12 +94,12 @@ type contentQuery struct { DeletedAt field.Field Key field.String // Musical key/tone IsPinned field.Bool // Whether content is pinned/featured - Author contentQueryBelongsToAuthor + Comments contentQueryHasManyComments + + Author contentQueryBelongsToAuthor ContentAssets contentQueryHasManyContentAssets - Comments contentQueryHasManyComments - fieldMap map[string]field.Expr } @@ -195,23 +195,104 @@ func (c *contentQuery) fillFieldMap() { func (c contentQuery) clone(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool) + c.Comments.db = db.Session(&gorm.Session{Initialized: true}) + c.Comments.db.Statement.ConnPool = db.Statement.ConnPool c.Author.db = db.Session(&gorm.Session{Initialized: true}) c.Author.db.Statement.ConnPool = db.Statement.ConnPool c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool - c.Comments.db = db.Session(&gorm.Session{Initialized: true}) - c.Comments.db.Statement.ConnPool = db.Statement.ConnPool return c } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) + c.Comments.db = db.Session(&gorm.Session{}) c.Author.db = db.Session(&gorm.Session{}) c.ContentAssets.db = db.Session(&gorm.Session{}) - c.Comments.db = db.Session(&gorm.Session{}) return c } +type contentQueryHasManyComments struct { + db *gorm.DB + + field.RelationField +} + +func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { + a.db = a.db.Session(session) + return &a +} + +func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { + return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} +} + +func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { + a.db = a.db.Unscoped() + return &a +} + +type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } + +func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { + return result, a.tx.Find(&result) +} + +func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Clear() error { + return a.tx.Clear() +} + +func (a contentQueryHasManyCommentsTx) Count() int64 { + return a.tx.Count() +} + +func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { + a.tx = a.tx.Unscoped() + return &a +} + type contentQueryBelongsToAuthor struct { db *gorm.DB @@ -374,87 +455,6 @@ func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyConte return &a } -type contentQueryHasManyComments struct { - db *gorm.DB - - field.RelationField -} - -func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { - a.db = a.db.Session(session) - return &a -} - -func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { - return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} -} - -func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { - a.db = a.db.Unscoped() - return &a -} - -type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } - -func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { - return result, a.tx.Find(&result) -} - -func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Clear() error { - return a.tx.Clear() -} - -func (a contentQueryHasManyCommentsTx) Count() int64 { - return a.tx.Count() -} - -func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { - a.tx = a.tx.Unscoped() - return &a -} - type contentQueryDo struct{ gen.DO } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/backend/database/models/orders.gen.go b/backend/database/models/orders.gen.go index 5c73b77..3da23e1 100644 --- a/backend/database/models/orders.gen.go +++ b/backend/database/models/orders.gen.go @@ -37,7 +37,15 @@ type Order struct { RefundReason string `gorm:"column:refund_reason;type:character varying(255)" json:"refund_reason"` CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;default:now()" json:"updated_at"` - CouponID int64 `gorm:"column:coupon_id;type:bigint;comment:使用的优惠券ID (0表示未使用)" json:"coupon_id"` // 使用的优惠券ID (0表示未使用) + CouponID int64 `gorm:"column:coupon_id;type:bigint;comment:使用的优惠券ID (0表示未使用)" json:"coupon_id"` // 使用的优惠券ID (0表示未使用) + IsFlagged bool `gorm:"column:is_flagged;type:boolean;not null;comment:问题订单标记;用途:运营标注需复核订单;默认 false。" json:"is_flagged"` // 问题订单标记;用途:运营标注需复核订单;默认 false。 + FlagReason string `gorm:"column:flag_reason;type:character varying(255);not null;comment:问题标记原因;用途:说明问题点与风险;默认空字符串。" json:"flag_reason"` // 问题标记原因;用途:说明问题点与风险;默认空字符串。 + FlaggedBy int64 `gorm:"column:flagged_by;type:bigint;not null;comment:问题标记操作者ID;用途:审计追踪;默认 0 表示未标记。" json:"flagged_by"` // 问题标记操作者ID;用途:审计追踪;默认 0 表示未标记。 + FlaggedAt time.Time `gorm:"column:flagged_at;type:timestamp with time zone;comment:问题标记时间;用途:记录标记时效;未标记为空。" json:"flagged_at"` // 问题标记时间;用途:记录标记时效;未标记为空。 + IsReconciled bool `gorm:"column:is_reconciled;type:boolean;not null;comment:对账状态;用途:标识是否完成人工对账;默认 false。" json:"is_reconciled"` // 对账状态;用途:标识是否完成人工对账;默认 false。 + ReconcileNote string `gorm:"column:reconcile_note;type:character varying(255);not null;comment:对账说明;用途:记录对账备注与结论;默认空字符串。" json:"reconcile_note"` // 对账说明;用途:记录对账备注与结论;默认空字符串。 + ReconciledBy int64 `gorm:"column:reconciled_by;type:bigint;not null;comment:对账操作者ID;用途:审计追踪;默认 0 表示未对账。" json:"reconciled_by"` // 对账操作者ID;用途:审计追踪;默认 0 表示未对账。 + ReconciledAt time.Time `gorm:"column:reconciled_at;type:timestamp with time zone;comment:对账时间;用途:记录完成对账时间;未对账为空。" json:"reconciled_at"` // 对账时间;用途:记录完成对账时间;未对账为空。 } // Quick operations without importing query package diff --git a/backend/database/models/orders.query.gen.go b/backend/database/models/orders.query.gen.go index a0da79c..6297da5 100644 --- a/backend/database/models/orders.query.gen.go +++ b/backend/database/models/orders.query.gen.go @@ -44,6 +44,14 @@ func newOrder(db *gorm.DB, opts ...gen.DOOption) orderQuery { _orderQuery.CreatedAt = field.NewTime(tableName, "created_at") _orderQuery.UpdatedAt = field.NewTime(tableName, "updated_at") _orderQuery.CouponID = field.NewInt64(tableName, "coupon_id") + _orderQuery.IsFlagged = field.NewBool(tableName, "is_flagged") + _orderQuery.FlagReason = field.NewString(tableName, "flag_reason") + _orderQuery.FlaggedBy = field.NewInt64(tableName, "flagged_by") + _orderQuery.FlaggedAt = field.NewTime(tableName, "flagged_at") + _orderQuery.IsReconciled = field.NewBool(tableName, "is_reconciled") + _orderQuery.ReconcileNote = field.NewString(tableName, "reconcile_note") + _orderQuery.ReconciledBy = field.NewInt64(tableName, "reconciled_by") + _orderQuery.ReconciledAt = field.NewTime(tableName, "reconciled_at") _orderQuery.fillFieldMap() @@ -72,7 +80,15 @@ type orderQuery struct { RefundReason field.String CreatedAt field.Time UpdatedAt field.Time - CouponID field.Int64 // 使用的优惠券ID (0表示未使用) + CouponID field.Int64 // 使用的优惠券ID (0表示未使用) + IsFlagged field.Bool // 问题订单标记;用途:运营标注需复核订单;默认 false。 + FlagReason field.String // 问题标记原因;用途:说明问题点与风险;默认空字符串。 + FlaggedBy field.Int64 // 问题标记操作者ID;用途:审计追踪;默认 0 表示未标记。 + FlaggedAt field.Time // 问题标记时间;用途:记录标记时效;未标记为空。 + IsReconciled field.Bool // 对账状态;用途:标识是否完成人工对账;默认 false。 + ReconcileNote field.String // 对账说明;用途:记录对账备注与结论;默认空字符串。 + ReconciledBy field.Int64 // 对账操作者ID;用途:审计追踪;默认 0 表示未对账。 + ReconciledAt field.Time // 对账时间;用途:记录完成对账时间;未对账为空。 fieldMap map[string]field.Expr } @@ -108,6 +124,14 @@ func (o *orderQuery) updateTableName(table string) *orderQuery { o.CreatedAt = field.NewTime(table, "created_at") o.UpdatedAt = field.NewTime(table, "updated_at") o.CouponID = field.NewInt64(table, "coupon_id") + o.IsFlagged = field.NewBool(table, "is_flagged") + o.FlagReason = field.NewString(table, "flag_reason") + o.FlaggedBy = field.NewInt64(table, "flagged_by") + o.FlaggedAt = field.NewTime(table, "flagged_at") + o.IsReconciled = field.NewBool(table, "is_reconciled") + o.ReconcileNote = field.NewString(table, "reconcile_note") + o.ReconciledBy = field.NewInt64(table, "reconciled_by") + o.ReconciledAt = field.NewTime(table, "reconciled_at") o.fillFieldMap() @@ -138,7 +162,7 @@ func (o *orderQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (o *orderQuery) fillFieldMap() { - o.fieldMap = make(map[string]field.Expr, 19) + o.fieldMap = make(map[string]field.Expr, 27) o.fieldMap["id"] = o.ID o.fieldMap["tenant_id"] = o.TenantID o.fieldMap["user_id"] = o.UserID @@ -158,6 +182,14 @@ func (o *orderQuery) fillFieldMap() { o.fieldMap["created_at"] = o.CreatedAt o.fieldMap["updated_at"] = o.UpdatedAt o.fieldMap["coupon_id"] = o.CouponID + o.fieldMap["is_flagged"] = o.IsFlagged + o.fieldMap["flag_reason"] = o.FlagReason + o.fieldMap["flagged_by"] = o.FlaggedBy + o.fieldMap["flagged_at"] = o.FlaggedAt + o.fieldMap["is_reconciled"] = o.IsReconciled + o.fieldMap["reconcile_note"] = o.ReconcileNote + o.fieldMap["reconciled_by"] = o.ReconciledBy + o.fieldMap["reconciled_at"] = o.ReconciledAt } func (o orderQuery) clone(db *gorm.DB) orderQuery { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 1f2fc4b..65da3ab 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1349,6 +1349,90 @@ const docTemplate = `{ } } }, + "/super/v1/orders/{id}/flag": { + "post": { + "description": "Flag or unflag an order as problematic", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Flag order", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Flag form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperOrderFlagForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/orders/{id}/reconcile": { + "post": { + "description": "Mark or unmark order reconciliation status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Reconcile order", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Reconcile form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperOrderReconcileForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/orders/{id}/refund": { "post": { "description": "Refund order", @@ -8734,6 +8818,19 @@ const docTemplate = `{ } } }, + "dto.SuperOrderFlagForm": { + "type": "object", + "properties": { + "is_flagged": { + "description": "IsFlagged 是否标记为问题订单。", + "type": "boolean" + }, + "reason": { + "description": "Reason 标记原因(标记为问题时必填)。", + "type": "string" + } + } + }, "dto.SuperOrderItem": { "type": "object", "properties": { @@ -8769,10 +8866,30 @@ const docTemplate = `{ } ] }, + "flag_reason": { + "description": "FlagReason 问题标记原因。", + "type": "string" + }, + "flagged_at": { + "description": "FlaggedAt 标记时间(RFC3339)。", + "type": "string" + }, + "flagged_by": { + "description": "FlaggedBy 标记操作者ID。", + "type": "integer" + }, "id": { "description": "ID 订单ID。", "type": "integer" }, + "is_flagged": { + "description": "IsFlagged 是否标记为问题订单。", + "type": "boolean" + }, + "is_reconciled": { + "description": "IsReconciled 是否完成对账。", + "type": "boolean" + }, "items": { "description": "Items 订单明细行,用于展示具体内容与金额拆分。", "type": "array", @@ -8784,6 +8901,18 @@ const docTemplate = `{ "description": "PaidAt 支付时间(RFC3339)。", "type": "string" }, + "reconcile_note": { + "description": "ReconcileNote 对账说明。", + "type": "string" + }, + "reconciled_at": { + "description": "ReconciledAt 对账时间(RFC3339)。", + "type": "string" + }, + "reconciled_by": { + "description": "ReconciledBy 对账操作者ID。", + "type": "integer" + }, "refunded_at": { "description": "RefundedAt 退款时间(RFC3339)。", "type": "string" @@ -8845,6 +8974,19 @@ const docTemplate = `{ } } }, + "dto.SuperOrderReconcileForm": { + "type": "object", + "properties": { + "is_reconciled": { + "description": "IsReconciled 是否完成对账。", + "type": "boolean" + }, + "note": { + "description": "Note 对账说明(可选)。", + "type": "string" + } + } + }, "dto.SuperOrderRefundForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index fdb78db..e67c700 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1343,6 +1343,90 @@ } } }, + "/super/v1/orders/{id}/flag": { + "post": { + "description": "Flag or unflag an order as problematic", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Flag order", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Flag form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperOrderFlagForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/orders/{id}/reconcile": { + "post": { + "description": "Mark or unmark order reconciliation status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Reconcile order", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Order ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Reconcile form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperOrderReconcileForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/orders/{id}/refund": { "post": { "description": "Refund order", @@ -8728,6 +8812,19 @@ } } }, + "dto.SuperOrderFlagForm": { + "type": "object", + "properties": { + "is_flagged": { + "description": "IsFlagged 是否标记为问题订单。", + "type": "boolean" + }, + "reason": { + "description": "Reason 标记原因(标记为问题时必填)。", + "type": "string" + } + } + }, "dto.SuperOrderItem": { "type": "object", "properties": { @@ -8763,10 +8860,30 @@ } ] }, + "flag_reason": { + "description": "FlagReason 问题标记原因。", + "type": "string" + }, + "flagged_at": { + "description": "FlaggedAt 标记时间(RFC3339)。", + "type": "string" + }, + "flagged_by": { + "description": "FlaggedBy 标记操作者ID。", + "type": "integer" + }, "id": { "description": "ID 订单ID。", "type": "integer" }, + "is_flagged": { + "description": "IsFlagged 是否标记为问题订单。", + "type": "boolean" + }, + "is_reconciled": { + "description": "IsReconciled 是否完成对账。", + "type": "boolean" + }, "items": { "description": "Items 订单明细行,用于展示具体内容与金额拆分。", "type": "array", @@ -8778,6 +8895,18 @@ "description": "PaidAt 支付时间(RFC3339)。", "type": "string" }, + "reconcile_note": { + "description": "ReconcileNote 对账说明。", + "type": "string" + }, + "reconciled_at": { + "description": "ReconciledAt 对账时间(RFC3339)。", + "type": "string" + }, + "reconciled_by": { + "description": "ReconciledBy 对账操作者ID。", + "type": "integer" + }, "refunded_at": { "description": "RefundedAt 退款时间(RFC3339)。", "type": "string" @@ -8839,6 +8968,19 @@ } } }, + "dto.SuperOrderReconcileForm": { + "type": "object", + "properties": { + "is_reconciled": { + "description": "IsReconciled 是否完成对账。", + "type": "boolean" + }, + "note": { + "description": "Note 对账说明(可选)。", + "type": "string" + } + } + }, "dto.SuperOrderRefundForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index a92b94a..67d949f 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1936,6 +1936,15 @@ definitions: - $ref: '#/definitions/dto.OrderTenantLite' description: Tenant 订单所属租户信息。 type: object + dto.SuperOrderFlagForm: + properties: + is_flagged: + description: IsFlagged 是否标记为问题订单。 + type: boolean + reason: + description: Reason 标记原因(标记为问题时必填)。 + type: string + type: object dto.SuperOrderItem: properties: amount_discount: @@ -1958,9 +1967,24 @@ definitions: allOf: - $ref: '#/definitions/consts.Currency' description: Currency 币种。 + flag_reason: + description: FlagReason 问题标记原因。 + type: string + flagged_at: + description: FlaggedAt 标记时间(RFC3339)。 + type: string + flagged_by: + description: FlaggedBy 标记操作者ID。 + type: integer id: description: ID 订单ID。 type: integer + is_flagged: + description: IsFlagged 是否标记为问题订单。 + type: boolean + is_reconciled: + description: IsReconciled 是否完成对账。 + type: boolean items: description: Items 订单明细行,用于展示具体内容与金额拆分。 items: @@ -1969,6 +1993,15 @@ definitions: paid_at: description: PaidAt 支付时间(RFC3339)。 type: string + reconcile_note: + description: ReconcileNote 对账说明。 + type: string + reconciled_at: + description: ReconciledAt 对账时间(RFC3339)。 + type: string + reconciled_by: + description: ReconciledBy 对账操作者ID。 + type: integer refunded_at: description: RefundedAt 退款时间(RFC3339)。 type: string @@ -2007,6 +2040,15 @@ definitions: snapshot: description: Snapshot 明细快照,用于展示内容标题等历史信息。 type: object + dto.SuperOrderReconcileForm: + properties: + is_reconciled: + description: IsReconciled 是否完成对账。 + type: boolean + note: + description: Note 对账说明(可选)。 + type: string + type: object dto.SuperOrderRefundForm: properties: force: @@ -4028,6 +4070,62 @@ paths: summary: Get order tags: - Order + /super/v1/orders/{id}/flag: + post: + consumes: + - application/json + description: Flag or unflag an order as problematic + parameters: + - description: Order ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Flag form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperOrderFlagForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + type: string + summary: Flag order + tags: + - Order + /super/v1/orders/{id}/reconcile: + post: + consumes: + - application/json + description: Mark or unmark order reconciliation status + parameters: + - description: Order ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Reconcile form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperOrderReconcileForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + type: string + summary: Reconcile order + tags: + - Order /super/v1/orders/{id}/refund: post: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 2417100..ee25332 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -47,8 +47,8 @@ ### 2.8 订单与退款 `/superadmin/orders` - 状态:**已完成** -- 已有:列表、详情、退款申请。 -- 缺口:问题订单标记/支付对账等运营增强能力。 +- 已有:列表、详情、退款申请、问题订单标记、对账辅助。 +- 缺口:无显著功能缺口。 ### 2.9 创作者与成员审核 `/superadmin/creators` - 状态:**部分完成** @@ -92,6 +92,5 @@ ## 4) 建议的下一步(按优先级) -1. **订单运营补强**:问题订单标记、支付对账辅助能力。 -2. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理。 -3. **创作者治理补强**:结算账户审批流、提现审核联动流程。 +1. **内容批量处置扩展**:补齐内容/举报的批量下架、封禁与归档处理。 +2. **创作者治理补强**:结算账户审批流、提现审核联动流程。 diff --git a/frontend/superadmin/src/service/OrderService.js b/frontend/superadmin/src/service/OrderService.js index 46d9047..d094a78 100644 --- a/frontend/superadmin/src/service/OrderService.js +++ b/frontend/superadmin/src/service/OrderService.js @@ -20,6 +20,8 @@ export const OrderService = { content_title, type, status, + is_flagged, + is_reconciled, created_at_from, created_at_to, paid_at_from, @@ -49,6 +51,8 @@ export const OrderService = { content_title, type, status, + is_flagged, + is_reconciled, created_at_from: iso(created_at_from), created_at_to: iso(created_at_to), paid_at_from: iso(paid_at_from), @@ -86,5 +90,25 @@ export const OrderService = { idempotency_key } }); + }, + async flagOrder(orderID, { is_flagged, reason } = {}) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(`/super/v1/orders/${orderID}/flag`, { + method: 'POST', + body: { + is_flagged: Boolean(is_flagged), + reason + } + }); + }, + async reconcileOrder(orderID, { is_reconciled, note } = {}) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(`/super/v1/orders/${orderID}/reconcile`, { + method: 'POST', + body: { + is_reconciled: Boolean(is_reconciled), + note + } + }); } }; diff --git a/frontend/superadmin/src/views/superadmin/OrderDetail.vue b/frontend/superadmin/src/views/superadmin/OrderDetail.vue index 2819f18..a33b21c 100644 --- a/frontend/superadmin/src/views/superadmin/OrderDetail.vue +++ b/frontend/superadmin/src/views/superadmin/OrderDetail.vue @@ -17,6 +17,16 @@ const refundLoading = ref(false); const refundForce = ref(false); const refundReason = ref(''); +const flagDialogVisible = ref(false); +const flagLoading = ref(false); +const flagValue = ref(false); +const flagReason = ref(''); + +const reconcileDialogVisible = ref(false); +const reconcileLoading = ref(false); +const reconcileValue = ref(false); +const reconcileNote = ref(''); + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -46,6 +56,14 @@ function getOrderStatusSeverity(value) { } } +function getFlagSeverity(value) { + return value ? 'danger' : 'secondary'; +} + +function getReconcileSeverity(value) { + return value ? 'success' : 'secondary'; +} + function safeJson(value) { try { return JSON.stringify(value ?? null, null, 2); @@ -89,6 +107,66 @@ async function confirmRefund() { } } +function openFlagDialog() { + const order = detail.value?.order; + if (!order) return; + flagValue.value = Boolean(order.is_flagged); + flagReason.value = order.flag_reason || ''; + flagDialogVisible.value = true; +} + +async function confirmFlag() { + const order = detail.value?.order; + if (!order?.id) return; + if (flagValue.value && !flagReason.value.trim()) { + toast.add({ severity: 'warn', summary: '请填写原因', detail: '标记为问题订单时需要填写原因', life: 3000 }); + return; + } + + flagLoading.value = true; + try { + await OrderService.flagOrder(order.id, { + is_flagged: flagValue.value, + reason: flagValue.value ? flagReason.value : '' + }); + toast.add({ severity: 'success', summary: '已更新标记', detail: `订单ID: ${order.id}`, life: 3000 }); + flagDialogVisible.value = false; + await loadDetail(); + } catch (error) { + toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法更新问题标记', life: 4000 }); + } finally { + flagLoading.value = false; + } +} + +function openReconcileDialog() { + const order = detail.value?.order; + if (!order) return; + reconcileValue.value = Boolean(order.is_reconciled); + reconcileNote.value = order.reconcile_note || ''; + reconcileDialogVisible.value = true; +} + +async function confirmReconcile() { + const order = detail.value?.order; + if (!order?.id) return; + + reconcileLoading.value = true; + try { + await OrderService.reconcileOrder(order.id, { + is_reconciled: reconcileValue.value, + note: reconcileValue.value ? reconcileNote.value : '' + }); + toast.add({ severity: 'success', summary: '已更新对账', detail: `订单ID: ${order.id}`, life: 3000 }); + reconcileDialogVisible.value = false; + await loadDetail(); + } catch (error) { + toast.add({ severity: 'error', summary: '操作失败', detail: error?.message || '无法更新对账状态', life: 4000 }); + } finally { + reconcileLoading.value = false; + } +} + watch( () => orderID.value, () => loadDetail(), @@ -104,6 +182,8 @@ watch( OrderID: {{ orderID || '-' }}
+
@@ -140,6 +220,26 @@ watch(
支付时间
{{ formatDate(detail?.order?.paid_at) }}
+
+
问题标记
+ +
+ {{ detail?.order?.flag_reason || '无原因' }} +
+
+ {{ formatDate(detail?.order?.flagged_at) }} +
+
+
+
对账状态
+ +
+ {{ detail?.order?.reconcile_note || '无说明' }} +
+
+ {{ formatDate(detail?.order?.reconciled_at) }} +
+
@@ -191,4 +291,52 @@ watch(