From 3af3c854c9e1b6f682d4aacdd00ac603cd4f0a48 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 16 Jan 2026 10:37:24 +0800 Subject: [PATCH] feat: add super comment governance and finance oversight --- backend/app/http/super/v1/comments.go | 47 + .../app/http/super/v1/dto/super_comment.go | 74 ++ .../app/http/super/v1/dto/super_finance.go | 177 ++++ backend/app/http/super/v1/finance.go | 60 ++ backend/app/http/super/v1/provider.gen.go | 18 + backend/app/http/super/v1/routes.gen.go | 31 + backend/app/services/super.go | 813 ++++++++++++++++ backend/app/services/super_test.go | 116 +++ backend/docs/docs.go | 529 +++++++++++ backend/docs/swagger.json | 529 +++++++++++ backend/docs/swagger.yaml | 353 +++++++ docs/superadmin_progress.md | 22 +- .../superadmin/src/service/ContentService.js | 44 + .../superadmin/src/service/FinanceService.js | 93 ++ .../src/views/superadmin/Contents.vue | 625 +++++++++--- .../src/views/superadmin/Finance.vue | 893 +++++++++++++++--- 16 files changed, 4139 insertions(+), 285 deletions(-) create mode 100644 backend/app/http/super/v1/comments.go create mode 100644 backend/app/http/super/v1/dto/super_comment.go create mode 100644 backend/app/http/super/v1/dto/super_finance.go create mode 100644 backend/app/http/super/v1/finance.go diff --git a/backend/app/http/super/v1/comments.go b/backend/app/http/super/v1/comments.go new file mode 100644 index 0000000..988869b --- /dev/null +++ b/backend/app/http/super/v1/comments.go @@ -0,0 +1,47 @@ +package v1 + +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" +) + +// @provider +type comments struct{} + +// List comments +// +// @Router /super/v1/comments [get] +// @Summary List comments +// @Description List comments across tenants +// @Tags Content +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperCommentItem} +// @Bind filter query +func (c *comments) List(ctx fiber.Ctx, filter *dto.SuperCommentListFilter) (*requests.Pager, error) { + return services.Super.ListComments(ctx, filter) +} + +// Delete comment +// +// @Router /super/v1/comments/:id/delete [post] +// @Summary Delete comment +// @Description Soft delete a comment +// @Tags Content +// @Accept json +// @Produce json +// @Param id path int64 true "Comment ID" +// @Param form body dto.SuperCommentDeleteForm false "Delete form" +// @Success 200 {string} string "Deleted" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *comments) Delete(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperCommentDeleteForm) error { + return services.Super.DeleteComment(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/dto/super_comment.go b/backend/app/http/super/v1/dto/super_comment.go new file mode 100644 index 0000000..516a6cd --- /dev/null +++ b/backend/app/http/super/v1/dto/super_comment.go @@ -0,0 +1,74 @@ +package dto + +import "quyun/v2/app/requests" + +// SuperCommentListFilter 超管评论列表过滤条件。 +type SuperCommentListFilter struct { + requests.Pagination + // ID 评论ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // ContentID 内容ID,精确匹配。 + ContentID *int64 `query:"content_id"` + // ContentTitle 内容标题关键字,模糊匹配。 + ContentTitle *string `query:"content_title"` + // UserID 评论用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 评论用户用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Keyword 评论内容关键字,模糊匹配。 + Keyword *string `query:"keyword"` + // Status 评论状态过滤(active/deleted/all)。 + Status *string `query:"status"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at/likes)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/likes)。 + Desc *string `query:"desc"` +} + +// SuperCommentItem 超管评论列表项。 +type SuperCommentItem struct { + // ID 评论ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // ContentID 内容ID。 + ContentID int64 `json:"content_id"` + // ContentTitle 内容标题。 + ContentTitle string `json:"content_title"` + // UserID 评论用户ID。 + UserID int64 `json:"user_id"` + // Username 评论用户名称。 + Username string `json:"username"` + // ReplyTo 回复评论ID(0 表示一级评论)。 + ReplyTo int64 `json:"reply_to"` + // Content 评论内容。 + Content string `json:"content"` + // Likes 评论点赞数。 + Likes int32 `json:"likes"` + // IsDeleted 是否已删除。 + IsDeleted bool `json:"is_deleted"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // DeletedAt 删除时间(RFC3339,未删除为空)。 + DeletedAt string `json:"deleted_at"` +} + +// SuperCommentDeleteForm 超管删除评论表单。 +type SuperCommentDeleteForm struct { + // Reason 删除原因(可选,用于审计记录)。 + Reason string `json:"reason"` +} diff --git a/backend/app/http/super/v1/dto/super_finance.go b/backend/app/http/super/v1/dto/super_finance.go new file mode 100644 index 0000000..3406ab8 --- /dev/null +++ b/backend/app/http/super/v1/dto/super_finance.go @@ -0,0 +1,177 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// SuperLedgerListFilter 超管资金流水过滤条件。 +type SuperLedgerListFilter struct { + requests.Pagination + // ID 流水ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // UserID 用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // OrderID 关联订单ID,精确匹配。 + OrderID *int64 `query:"order_id"` + // Type 流水类型过滤。 + Type *consts.TenantLedgerType `query:"type"` + // AmountMin 金额下限(分)。 + AmountMin *int64 `query:"amount_min"` + // AmountMax 金额上限(分)。 + AmountMax *int64 `query:"amount_max"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at/amount)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/amount)。 + Desc *string `query:"desc"` +} + +// SuperLedgerItem 超管资金流水项。 +type SuperLedgerItem struct { + // ID 流水ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // UserID 关联用户ID。 + UserID int64 `json:"user_id"` + // Username 关联用户名。 + Username string `json:"username"` + // OrderID 关联订单ID。 + OrderID int64 `json:"order_id"` + // Type 流水类型。 + Type consts.TenantLedgerType `json:"type"` + // TypeDescription 流水类型描述(用于展示)。 + TypeDescription string `json:"type_description"` + // Amount 变动金额(分)。 + Amount int64 `json:"amount"` + // BalanceBefore 变更前可用余额(分)。 + BalanceBefore int64 `json:"balance_before"` + // BalanceAfter 变更后可用余额(分)。 + BalanceAfter int64 `json:"balance_after"` + // FrozenBefore 变更前冻结余额(分)。 + FrozenBefore int64 `json:"frozen_before"` + // FrozenAfter 变更后冻结余额(分)。 + FrozenAfter int64 `json:"frozen_after"` + // Remark 流水备注说明。 + Remark string `json:"remark"` + // OperatorUserID 操作者用户ID(0 表示系统)。 + OperatorUserID int64 `json:"operator_user_id"` + // BizRefType 业务引用类型(可选)。 + BizRefType string `json:"biz_ref_type"` + // BizRefID 业务引用ID(可选)。 + BizRefID int64 `json:"biz_ref_id"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} + +// SuperBalanceAnomalyFilter 余额异常筛选条件。 +type SuperBalanceAnomalyFilter struct { + requests.Pagination + // UserID 用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Issue 异常类型(negative_balance/negative_frozen)。 + Issue *string `query:"issue"` + // Asc 升序字段(id/balance/balance_frozen)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/balance/balance_frozen)。 + Desc *string `query:"desc"` +} + +// SuperBalanceAnomalyItem 余额异常项。 +type SuperBalanceAnomalyItem struct { + // UserID 用户ID。 + UserID int64 `json:"user_id"` + // Username 用户名。 + Username string `json:"username"` + // Balance 可用余额(分)。 + Balance int64 `json:"balance"` + // BalanceFrozen 冻结余额(分)。 + BalanceFrozen int64 `json:"balance_frozen"` + // Issue 异常类型标识。 + Issue string `json:"issue"` + // IssueDescription 异常描述说明。 + IssueDescription string `json:"issue_description"` + // CreatedAt 用户创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` +} + +// SuperOrderAnomalyFilter 订单异常筛选条件。 +type SuperOrderAnomalyFilter struct { + requests.Pagination + // ID 订单ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // UserID 用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Type 订单类型过滤。 + Type *consts.OrderType `query:"type"` + // Issue 异常类型(missing_paid_at/missing_refunded_at)。 + Issue *string `query:"issue"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at/amount_paid)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/amount_paid)。 + Desc *string `query:"desc"` +} + +// SuperOrderAnomalyItem 订单异常项。 +type SuperOrderAnomalyItem struct { + // OrderID 订单ID。 + OrderID int64 `json:"order_id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // UserID 用户ID。 + UserID int64 `json:"user_id"` + // Username 用户名。 + Username string `json:"username"` + // Type 订单类型。 + Type consts.OrderType `json:"type"` + // Status 订单状态。 + Status consts.OrderStatus `json:"status"` + // AmountPaid 实付金额(分)。 + AmountPaid int64 `json:"amount_paid"` + // Issue 异常类型标识。 + Issue string `json:"issue"` + // IssueDescription 异常描述说明。 + IssueDescription string `json:"issue_description"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // PaidAt 支付时间(RFC3339)。 + PaidAt string `json:"paid_at"` + // RefundedAt 退款时间(RFC3339)。 + RefundedAt string `json:"refunded_at"` +} diff --git a/backend/app/http/super/v1/finance.go b/backend/app/http/super/v1/finance.go new file mode 100644 index 0000000..1510372 --- /dev/null +++ b/backend/app/http/super/v1/finance.go @@ -0,0 +1,60 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type finance struct{} + +// List ledgers +// +// @Router /super/v1/finance/ledgers [get] +// @Summary List ledgers +// @Description List tenant ledgers across tenants +// @Tags Finance +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperLedgerItem} +// @Bind filter query +func (c *finance) ListLedgers(ctx fiber.Ctx, filter *dto.SuperLedgerListFilter) (*requests.Pager, error) { + return services.Super.ListLedgers(ctx, filter) +} + +// List balance anomalies +// +// @Router /super/v1/finance/anomalies/balances [get] +// @Summary List balance anomalies +// @Description List balance anomalies across users +// @Tags Finance +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperBalanceAnomalyItem} +// @Bind filter query +func (c *finance) ListBalanceAnomalies(ctx fiber.Ctx, filter *dto.SuperBalanceAnomalyFilter) (*requests.Pager, error) { + return services.Super.ListBalanceAnomalies(ctx, filter) +} + +// List order anomalies +// +// @Router /super/v1/finance/anomalies/orders [get] +// @Summary List order anomalies +// @Description List order anomalies across tenants +// @Tags Finance +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperOrderAnomalyItem} +// @Bind filter query +func (c *finance) ListOrderAnomalies(ctx fiber.Ctx, filter *dto.SuperOrderAnomalyFilter) (*requests.Pager, error) { + return services.Super.ListOrderAnomalies(ctx, filter) +} diff --git a/backend/app/http/super/v1/provider.gen.go b/backend/app/http/super/v1/provider.gen.go index 4ed37fc..0d17894 100755 --- a/backend/app/http/super/v1/provider.gen.go +++ b/backend/app/http/super/v1/provider.gen.go @@ -24,6 +24,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*comments, error) { + obj := &comments{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*contents, error) { obj := &contents{} @@ -52,6 +59,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*finance, error) { + obj := &finance{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*notifications, error) { obj := ¬ifications{} @@ -83,10 +97,12 @@ func Provide(opts ...opt.Option) error { if err := container.Container.Provide(func( assets *assets, auditLogs *auditLogs, + comments *comments, contents *contents, coupons *coupons, creatorApplications *creatorApplications, creators *creators, + finance *finance, middlewares *middlewares.Middlewares, notifications *notifications, orders *orders, @@ -100,10 +116,12 @@ func Provide(opts ...opt.Option) error { obj := &Routes{ assets: assets, auditLogs: auditLogs, + comments: comments, contents: contents, coupons: coupons, creatorApplications: creatorApplications, creators: creators, + finance: finance, middlewares: middlewares, notifications: notifications, orders: orders, diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 40ab7f8..132add4 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -27,10 +27,12 @@ type Routes struct { // Controller instances assets *assets auditLogs *auditLogs + comments *comments contents *contents coupons *coupons creatorApplications *creatorApplications creators *creators + finance *finance notifications *notifications orders *orders payoutAccounts *payoutAccounts @@ -79,6 +81,19 @@ func (r *Routes) Register(router fiber.Router) { r.auditLogs.List, Query[dto.SuperAuditLogListFilter]("filter"), )) + // Register routes for controller: comments + r.log.Debugf("Registering route: Get /super/v1/comments -> comments.List") + router.Get("/super/v1/comments"[len(r.Path()):], DataFunc1( + r.comments.List, + Query[dto.SuperCommentListFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /super/v1/comments/:id/delete -> comments.Delete") + router.Post("/super/v1/comments/:id/delete"[len(r.Path()):], Func3( + r.comments.Delete, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperCommentDeleteForm]("form"), + )) // Register routes for controller: contents r.log.Debugf("Registering route: Get /super/v1/contents -> contents.List") router.Get("/super/v1/contents"[len(r.Path()):], DataFunc1( @@ -186,6 +201,22 @@ func (r *Routes) Register(router fiber.Router) { r.creators.List, Query[dto.TenantListFilter]("filter"), )) + // Register routes for controller: finance + r.log.Debugf("Registering route: Get /super/v1/finance/anomalies/balances -> finance.ListBalanceAnomalies") + router.Get("/super/v1/finance/anomalies/balances"[len(r.Path()):], DataFunc1( + r.finance.ListBalanceAnomalies, + Query[dto.SuperBalanceAnomalyFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/finance/anomalies/orders -> finance.ListOrderAnomalies") + router.Get("/super/v1/finance/anomalies/orders"[len(r.Path()):], DataFunc1( + r.finance.ListOrderAnomalies, + Query[dto.SuperOrderAnomalyFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/finance/ledgers -> finance.ListLedgers") + router.Get("/super/v1/finance/ledgers"[len(r.Path()):], DataFunc1( + r.finance.ListLedgers, + Query[dto.SuperLedgerListFilter]("filter"), + )) // Register routes for controller: notifications r.log.Debugf("Registering route: Get /super/v1/notifications -> notifications.List") router.Get("/super/v1/notifications"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/super.go b/backend/app/services/super.go index dda9f59..91e1d67 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -2311,6 +2311,306 @@ func (s *super) ListContents(ctx context.Context, filter *super_dto.SuperContent }, nil } +func (s *super) ListComments(ctx context.Context, filter *super_dto.SuperCommentListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperCommentListFilter{} + } + + tbl, q := models.CommentQuery.QueryContext(ctx) + + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.ContentID != nil && *filter.ContentID > 0 { + q = q.Where(tbl.ContentID.Eq(*filter.ContentID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(tbl.Content.Like(keyword)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.ContentTitle != nil && strings.TrimSpace(*filter.ContentTitle) != "" { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + keyword := "%" + strings.TrimSpace(*filter.ContentTitle) + "%" + contentQuery = contentQuery.Where(field.Or( + contentTbl.Title.Like(keyword), + contentTbl.Description.Like(keyword), + contentTbl.Summary.Like(keyword), + )) + if filter.TenantID != nil && *filter.TenantID > 0 { + contentQuery = contentQuery.Where(contentTbl.TenantID.Eq(*filter.TenantID)) + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + contentQuery = contentQuery.Where(contentTbl.TenantID.In(tenantIDs...)) + } + } + contentIDs, err := contentQuery.Select(contentTbl.ID).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + ids := make([]int64, 0, len(contentIDs)) + for _, content := range contentIDs { + ids = append(ids, content.ID) + } + if len(ids) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.ContentID.In(ids...)) + } + } + + status := "" + if filter.Status != nil { + status = strings.TrimSpace(*filter.Status) + } + switch status { + case "deleted": + q = q.Unscoped().Where(tbl.DeletedAt.IsNotNull()) + case "all": + q = q.Unscoped() + default: + q = q.Where(tbl.DeletedAt.IsNull()) + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "likes": + q = q.Order(tbl.Likes.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "likes": + q = q.Order(tbl.Likes) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + // 评论治理默认排除已删除内容,如需查看已删除请显式选择状态。 + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperCommentItem{}, + }, nil + } + + tenantSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + contentSet := make(map[int64]struct{}) + for _, comment := range list { + tenantSet[comment.TenantID] = struct{}{} + userSet[comment.UserID] = struct{}{} + contentSet[comment.ContentID] = struct{}{} + } + + tenantIDs = make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + tenantIDs = append(tenantIDs, id) + } + userIDs = make([]int64, 0, len(userSet)) + for id := range userSet { + userIDs = append(userIDs, id) + } + contentIDs := make([]int64, 0, len(contentSet)) + for id := range contentSet { + contentIDs = append(contentIDs, id) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + contentMap := make(map[int64]*models.Content, len(contentIDs)) + if len(contentIDs) > 0 { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + contents, err := contentQuery.Where(contentTbl.ID.In(contentIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, content := range contents { + contentMap[content.ID] = content + } + } + + items := make([]super_dto.SuperCommentItem, 0, len(list)) + for _, comment := range list { + tenant := tenantMap[comment.TenantID] + user := userMap[comment.UserID] + content := contentMap[comment.ContentID] + + username := "" + if user != nil { + if user.Username != "" { + username = user.Username + } else { + username = user.Nickname + } + } + + item := super_dto.SuperCommentItem{ + ID: comment.ID, + TenantID: comment.TenantID, + ContentID: comment.ContentID, + UserID: comment.UserID, + ReplyTo: comment.ReplyTo, + Content: comment.Content, + Likes: comment.Likes, + IsDeleted: comment.DeletedAt.Valid, + CreatedAt: s.formatTime(comment.CreatedAt), + TenantCode: "", + TenantName: "", + ContentTitle: "", + Username: username, + DeletedAt: "", + } + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if content != nil { + item.ContentTitle = content.Title + } + if comment.DeletedAt.Valid { + item.DeletedAt = s.formatTime(comment.DeletedAt.Time) + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) DeleteComment(ctx context.Context, operatorID, id int64, form *super_dto.SuperCommentDeleteForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + + tbl, q := models.CommentQuery.QueryContext(ctx) + comment, 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) + } + + // 评论删除采用软删除,便于审计追溯与误删恢复。 + if _, err := q.Where(tbl.ID.Eq(id)).Delete(); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Audit != nil { + reason := "" + if form != nil { + reason = strings.TrimSpace(form.Reason) + } + detail := "删除评论" + if reason != "" { + detail = "删除评论: " + reason + } + Audit.Log(ctx, comment.TenantID, operatorID, "delete_comment", cast.ToString(id), detail) + } + return nil +} + func (s *super) UpdateContentStatus(ctx context.Context, tenantID, contentID int64, form *super_dto.SuperTenantContentStatusUpdateForm) error { tbl, q := models.ContentQuery.QueryContext(ctx) _, err := q.Where(tbl.ID.Eq(contentID), tbl.TenantID.Eq(tenantID)).Update(tbl.Status, consts.ContentStatus(form.Status)) @@ -5192,6 +5492,519 @@ func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrde }, nil } +func (s *super) ListLedgers(ctx context.Context, filter *super_dto.SuperLedgerListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperLedgerListFilter{} + } + + tbl, q := models.TenantLedgerQuery.QueryContext(ctx) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.OrderID != nil && *filter.OrderID > 0 { + q = q.Where(tbl.OrderID.Eq(*filter.OrderID)) + } + if filter.Type != nil && *filter.Type != "" { + q = q.Where(tbl.Type.Eq(*filter.Type)) + } + if filter.AmountMin != nil { + q = q.Where(tbl.Amount.Gte(*filter.AmountMin)) + } + if filter.AmountMax != nil { + q = q.Where(tbl.Amount.Lte(*filter.AmountMax)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "amount": + q = q.Order(tbl.Amount.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "amount": + q = q.Order(tbl.Amount) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) + } + + // 超管资金流水用于跨租户排查收支与异常变化。 + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + tenantSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + for _, ledger := range list { + if ledger.TenantID > 0 { + tenantSet[ledger.TenantID] = struct{}{} + } + if ledger.UserID > 0 { + userSet[ledger.UserID] = struct{}{} + } + } + + tenantIDs = make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + tenantIDs = append(tenantIDs, id) + } + userIDs = make([]int64, 0, len(userSet)) + for id := range userSet { + userIDs = append(userIDs, id) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + items := make([]super_dto.SuperLedgerItem, 0, len(list)) + for _, ledger := range list { + item := super_dto.SuperLedgerItem{ + ID: ledger.ID, + TenantID: ledger.TenantID, + UserID: ledger.UserID, + OrderID: ledger.OrderID, + Type: ledger.Type, + TypeDescription: ledger.Type.Description(), + Amount: ledger.Amount, + BalanceBefore: ledger.BalanceBefore, + BalanceAfter: ledger.BalanceAfter, + FrozenBefore: ledger.FrozenBefore, + FrozenAfter: ledger.FrozenAfter, + Remark: ledger.Remark, + OperatorUserID: ledger.OperatorUserID, + BizRefType: ledger.BizRefType, + BizRefID: ledger.BizRefID, + CreatedAt: s.formatTime(ledger.CreatedAt), + UpdatedAt: s.formatTime(ledger.UpdatedAt), + TenantCode: "", + TenantName: "", + Username: "", + } + if tenant := tenantMap[ledger.TenantID]; tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if user := userMap[ledger.UserID]; user != nil { + if user.Username != "" { + item.Username = user.Username + } else { + item.Username = user.Nickname + } + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ListBalanceAnomalies(ctx context.Context, filter *super_dto.SuperBalanceAnomalyFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperBalanceAnomalyFilter{} + } + + tbl, q := models.UserQuery.QueryContext(ctx) + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.ID.Eq(*filter.UserID)) + } + if filter.Username != nil && strings.TrimSpace(*filter.Username) != "" { + keyword := "%" + strings.TrimSpace(*filter.Username) + "%" + q = q.Where(field.Or(tbl.Username.Like(keyword), tbl.Nickname.Like(keyword))) + } + + issue := "" + if filter.Issue != nil { + issue = strings.TrimSpace(*filter.Issue) + } + switch issue { + case "negative_balance": + q = q.Where(tbl.Balance.Lt(0)) + case "negative_frozen": + q = q.Where(tbl.BalanceFrozen.Lt(0)) + default: + q = q.Where(field.Or(tbl.Balance.Lt(0), tbl.BalanceFrozen.Lt(0))) + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "balance": + q = q.Order(tbl.Balance.Desc()) + case "balance_frozen": + q = q.Order(tbl.BalanceFrozen.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "balance": + q = q.Order(tbl.Balance) + case "balance_frozen": + q = q.Order(tbl.BalanceFrozen) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) + } + + // 余额异常用于发现负余额或冻结异常的账号。 + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + users, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + items := make([]super_dto.SuperBalanceAnomalyItem, 0, len(users)) + for _, user := range users { + itemIssue := "negative_balance" + itemDesc := "可用余额为负" + if user.Balance < 0 && user.BalanceFrozen < 0 { + itemIssue = "negative_balance" + itemDesc = "可用余额与冻结余额均为负" + } else if user.BalanceFrozen < 0 { + itemIssue = "negative_frozen" + itemDesc = "冻结余额为负" + } + + username := user.Username + if username == "" { + username = user.Nickname + } + items = append(items, super_dto.SuperBalanceAnomalyItem{ + UserID: user.ID, + Username: username, + Balance: user.Balance, + BalanceFrozen: user.BalanceFrozen, + Issue: itemIssue, + IssueDescription: itemDesc, + CreatedAt: s.formatTime(user.CreatedAt), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ListOrderAnomalies(ctx context.Context, filter *super_dto.SuperOrderAnomalyFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperOrderAnomalyFilter{} + } + + tbl, q := models.OrderQuery.QueryContext(ctx) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Type != nil && *filter.Type != "" { + q = q.Where(tbl.Type.Eq(*filter.Type)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + issue := "" + if filter.Issue != nil { + issue = strings.TrimSpace(*filter.Issue) + } + zeroTime := time.Time{} + missingPaid := field.Or(tbl.PaidAt.IsNull(), tbl.PaidAt.Lte(zeroTime)) + missingRefund := field.Or(tbl.RefundedAt.IsNull(), tbl.RefundedAt.Lte(zeroTime)) + switch issue { + case "missing_paid_at": + q = q.Where(tbl.Status.Eq(consts.OrderStatusPaid), missingPaid) + case "missing_refunded_at": + q = q.Where(tbl.Status.Eq(consts.OrderStatusRefunded), missingRefund) + default: + q = q.Where(field.Or( + field.And(tbl.Status.Eq(consts.OrderStatusPaid), missingPaid), + field.And(tbl.Status.Eq(consts.OrderStatusRefunded), missingRefund), + )) + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "amount_paid": + q = q.Order(tbl.AmountPaid.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "amount_paid": + q = q.Order(tbl.AmountPaid) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + tenantSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + for _, order := range list { + if order.TenantID > 0 { + tenantSet[order.TenantID] = struct{}{} + } + if order.UserID > 0 { + userSet[order.UserID] = struct{}{} + } + } + + tenantIDs = make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + tenantIDs = append(tenantIDs, id) + } + userIDs = make([]int64, 0, len(userSet)) + for id := range userSet { + userIDs = append(userIDs, id) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + if len(tenantIDs) > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + items := make([]super_dto.SuperOrderAnomalyItem, 0, len(list)) + for _, order := range list { + itemIssue := "missing_paid_at" + itemDesc := "已支付但缺失支付时间" + if order.Status == consts.OrderStatusRefunded && order.RefundedAt.IsZero() { + itemIssue = "missing_refunded_at" + itemDesc = "已退款但缺失退款时间" + } + + tenant := tenantMap[order.TenantID] + user := userMap[order.UserID] + username := "" + if user != nil { + if user.Username != "" { + username = user.Username + } else { + username = user.Nickname + } + } + + item := super_dto.SuperOrderAnomalyItem{ + OrderID: order.ID, + TenantID: order.TenantID, + UserID: order.UserID, + Type: order.Type, + Status: order.Status, + AmountPaid: order.AmountPaid, + Issue: itemIssue, + IssueDescription: itemDesc, + CreatedAt: s.formatTime(order.CreatedAt), + PaidAt: "", + RefundedAt: "", + TenantCode: "", + TenantName: "", + Username: username, + } + + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if !order.PaidAt.IsZero() { + item.PaidAt = s.formatTime(order.PaidAt) + } + if !order.RefundedAt.IsZero() { + item.RefundedAt = s.formatTime(order.RefundedAt) + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + func (s *super) ListCoupons(ctx context.Context, filter *super_dto.SuperCouponListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperCouponListFilter{} diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 20bb667..8fc38f5 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -220,6 +220,122 @@ func (s *SuperTestSuite) Test_WithdrawalApproval() { }) } +func (s *SuperTestSuite) Test_CommentGovernance() { + Convey("Comment Governance", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameComment, models.TableNameContent, models.TableNameTenant, models.TableNameUser) + + owner := &models.User{Username: "owner_comment"} + commenter := &models.User{Username: "commenter"} + admin := &models.User{Username: "admin_comment"} + models.UserQuery.WithContext(ctx).Create(owner, commenter, admin) + + tenant := &models.Tenant{UserID: owner.ID, Code: "t-comment", Name: "Comment Tenant", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant) + + content := &models.Content{ + TenantID: tenant.ID, + UserID: owner.ID, + Title: "Comment Content", + Description: "Desc", + } + models.ContentQuery.WithContext(ctx).Create(content) + + Convey("should list comments", func() { + comment := &models.Comment{ + TenantID: tenant.ID, + UserID: commenter.ID, + ContentID: content.ID, + Content: "Nice work", + } + models.CommentQuery.WithContext(ctx).Create(comment) + + filter := &super_dto.SuperCommentListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + } + res, err := Super.ListComments(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + items := res.Items.([]super_dto.SuperCommentItem) + So(items[0].ContentTitle, ShouldEqual, "Comment Content") + So(items[0].Username, ShouldEqual, commenter.Username) + }) + + Convey("should delete comment", func() { + comment := &models.Comment{ + TenantID: tenant.ID, + UserID: commenter.ID, + ContentID: content.ID, + Content: "Spam content", + } + models.CommentQuery.WithContext(ctx).Create(comment) + + err := Super.DeleteComment(ctx, admin.ID, comment.ID, &super_dto.SuperCommentDeleteForm{Reason: "spam"}) + So(err, ShouldBeNil) + + deleted, err := models.CommentQuery.WithContext(ctx).Unscoped().Where(models.CommentQuery.ID.Eq(comment.ID)).First() + So(err, ShouldBeNil) + So(deleted.DeletedAt.Valid, ShouldBeTrue) + + filter := &super_dto.SuperCommentListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + } + res, err := Super.ListComments(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 0) + }) + }) +} + +func (s *SuperTestSuite) Test_FinanceAnomalies() { + Convey("Finance Anomalies", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameOrder, models.TableNameTenant, models.TableNameUser) + + user := &models.User{Username: "finance_user", Balance: -100} + models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{UserID: user.ID, Code: "t-fin", Name: "Finance Tenant", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(tenant) + + order := &models.Order{ + TenantID: tenant.ID, + UserID: user.ID, + Type: consts.OrderTypeRecharge, + Status: consts.OrderStatusPaid, + AmountOriginal: 100, + AmountDiscount: 0, + AmountPaid: 100, + IdempotencyKey: "anomaly-paid", + } + models.OrderQuery.WithContext(ctx).Create(order) + + Convey("should list balance anomalies", func() { + filter := &super_dto.SuperBalanceAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + } + res, err := Super.ListBalanceAnomalies(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + items := res.Items.([]super_dto.SuperBalanceAnomalyItem) + So(items[0].UserID, ShouldEqual, user.ID) + So(items[0].Issue, ShouldEqual, "negative_balance") + }) + + Convey("should list order anomalies", func() { + filter := &super_dto.SuperOrderAnomalyFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + } + res, err := Super.ListOrderAnomalies(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + items := res.Items.([]super_dto.SuperOrderAnomalyItem) + So(items[0].OrderID, ShouldEqual, order.ID) + So(items[0].Issue, ShouldEqual, "missing_paid_at") + }) + }) +} + func (s *SuperTestSuite) Test_TenantHealth() { Convey("TenantHealth", s.T(), func() { ctx := s.T().Context() diff --git a/backend/docs/docs.go b/backend/docs/docs.go index db22f72..d1c731c 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -247,6 +247,99 @@ const docTemplate = `{ } } }, + "/super/v1/comments": { + "get": { + "description": "List comments across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "List comments", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCommentItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/comments/{id}/delete": { + "post": { + "description": "Soft delete a comment", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Delete comment", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Delete form", + "name": "form", + "in": "body", + "schema": { + "$ref": "#/definitions/dto.SuperCommentDeleteForm" + } + } + ], + "responses": { + "200": { + "description": "Deleted", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents": { "get": { "description": "List contents", @@ -742,6 +835,162 @@ const docTemplate = `{ } } }, + "/super/v1/finance/anomalies/balances": { + "get": { + "description": "List balance anomalies across users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List balance anomalies", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperBalanceAnomalyItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/finance/anomalies/orders": { + "get": { + "description": "List order anomalies across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List order anomalies", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperOrderAnomalyItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/finance/ledgers": { + "get": { + "description": "List tenant ledgers across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List ledgers", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperLedgerItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/notifications": { "get": { "description": "List notifications across tenants", @@ -5830,6 +6079,25 @@ const docTemplate = `{ "RoleCreator" ] }, + "consts.TenantLedgerType": { + "type": "string", + "enum": [ + "debit_purchase", + "credit_refund", + "credit_withdrawal", + "freeze", + "unfreeze", + "adjustment" + ], + "x-enum-varnames": [ + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeCreditWithdrawal", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ] + }, "consts.TenantStatus": { "type": "string", "enum": [ @@ -7435,6 +7703,109 @@ const docTemplate = `{ } } }, + "dto.SuperBalanceAnomalyItem": { + "type": "object", + "properties": { + "balance": { + "description": "Balance 可用余额(分)。", + "type": "integer" + }, + "balance_frozen": { + "description": "BalanceFrozen 冻结余额(分)。", + "type": "integer" + }, + "created_at": { + "description": "CreatedAt 用户创建时间(RFC3339)。", + "type": "string" + }, + "issue": { + "description": "Issue 异常类型标识。", + "type": "string" + }, + "issue_description": { + "description": "IssueDescription 异常描述说明。", + "type": "string" + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, + "dto.SuperCommentDeleteForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 删除原因(可选,用于审计记录)。", + "type": "string" + } + } + }, + "dto.SuperCommentItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 评论内容。", + "type": "string" + }, + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "content_title": { + "description": "ContentTitle 内容标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "deleted_at": { + "description": "DeletedAt 删除时间(RFC3339,未删除为空)。", + "type": "string" + }, + "id": { + "description": "ID 评论ID。", + "type": "integer" + }, + "is_deleted": { + "description": "IsDeleted 是否已删除。", + "type": "boolean" + }, + "likes": { + "description": "Likes 评论点赞数。", + "type": "integer" + }, + "reply_to": { + "description": "ReplyTo 回复评论ID(0 表示一级评论)。", + "type": "integer" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "user_id": { + "description": "UserID 评论用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 评论用户名称。", + "type": "string" + } + } + }, "dto.SuperContentBatchReviewForm": { "type": "object", "required": [ @@ -7809,6 +8180,95 @@ const docTemplate = `{ } } }, + "dto.SuperLedgerItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 变动金额(分)。", + "type": "integer" + }, + "balance_after": { + "description": "BalanceAfter 变更后可用余额(分)。", + "type": "integer" + }, + "balance_before": { + "description": "BalanceBefore 变更前可用余额(分)。", + "type": "integer" + }, + "biz_ref_id": { + "description": "BizRefID 业务引用ID(可选)。", + "type": "integer" + }, + "biz_ref_type": { + "description": "BizRefType 业务引用类型(可选)。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "frozen_after": { + "description": "FrozenAfter 变更后冻结余额(分)。", + "type": "integer" + }, + "frozen_before": { + "description": "FrozenBefore 变更前冻结余额(分)。", + "type": "integer" + }, + "id": { + "description": "ID 流水ID。", + "type": "integer" + }, + "operator_user_id": { + "description": "OperatorUserID 操作者用户ID(0 表示系统)。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 关联订单ID。", + "type": "integer" + }, + "remark": { + "description": "Remark 流水备注说明。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 流水类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantLedgerType" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型描述(用于展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user_id": { + "description": "UserID 关联用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 关联用户名。", + "type": "string" + } + } + }, "dto.SuperNotificationBroadcastForm": { "type": "object", "properties": { @@ -7980,6 +8440,75 @@ const docTemplate = `{ } } }, + "dto.SuperOrderAnomalyItem": { + "type": "object", + "properties": { + "amount_paid": { + "description": "AmountPaid 实付金额(分)。", + "type": "integer" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "issue": { + "description": "Issue 异常类型标识。", + "type": "string" + }, + "issue_description": { + "description": "IssueDescription 异常描述说明。", + "type": "string" + }, + "order_id": { + "description": "OrderID 订单ID。", + "type": "integer" + }, + "paid_at": { + "description": "PaidAt 支付时间(RFC3339)。", + "type": "string" + }, + "refunded_at": { + "description": "RefundedAt 退款时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 订单类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c7f9c7c..6034b7f 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -241,6 +241,99 @@ } } }, + "/super/v1/comments": { + "get": { + "description": "List comments across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "List comments", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCommentItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/comments/{id}/delete": { + "post": { + "description": "Soft delete a comment", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Content" + ], + "summary": "Delete comment", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Delete form", + "name": "form", + "in": "body", + "schema": { + "$ref": "#/definitions/dto.SuperCommentDeleteForm" + } + } + ], + "responses": { + "200": { + "description": "Deleted", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/contents": { "get": { "description": "List contents", @@ -736,6 +829,162 @@ } } }, + "/super/v1/finance/anomalies/balances": { + "get": { + "description": "List balance anomalies across users", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List balance anomalies", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperBalanceAnomalyItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/finance/anomalies/orders": { + "get": { + "description": "List order anomalies across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List order anomalies", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperOrderAnomalyItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/finance/ledgers": { + "get": { + "description": "List tenant ledgers across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "List ledgers", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperLedgerItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/notifications": { "get": { "description": "List notifications across tenants", @@ -5824,6 +6073,25 @@ "RoleCreator" ] }, + "consts.TenantLedgerType": { + "type": "string", + "enum": [ + "debit_purchase", + "credit_refund", + "credit_withdrawal", + "freeze", + "unfreeze", + "adjustment" + ], + "x-enum-varnames": [ + "TenantLedgerTypeDebitPurchase", + "TenantLedgerTypeCreditRefund", + "TenantLedgerTypeCreditWithdrawal", + "TenantLedgerTypeFreeze", + "TenantLedgerTypeUnfreeze", + "TenantLedgerTypeAdjustment" + ] + }, "consts.TenantStatus": { "type": "string", "enum": [ @@ -7429,6 +7697,109 @@ } } }, + "dto.SuperBalanceAnomalyItem": { + "type": "object", + "properties": { + "balance": { + "description": "Balance 可用余额(分)。", + "type": "integer" + }, + "balance_frozen": { + "description": "BalanceFrozen 冻结余额(分)。", + "type": "integer" + }, + "created_at": { + "description": "CreatedAt 用户创建时间(RFC3339)。", + "type": "string" + }, + "issue": { + "description": "Issue 异常类型标识。", + "type": "string" + }, + "issue_description": { + "description": "IssueDescription 异常描述说明。", + "type": "string" + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, + "dto.SuperCommentDeleteForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 删除原因(可选,用于审计记录)。", + "type": "string" + } + } + }, + "dto.SuperCommentItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 评论内容。", + "type": "string" + }, + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "content_title": { + "description": "ContentTitle 内容标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "deleted_at": { + "description": "DeletedAt 删除时间(RFC3339,未删除为空)。", + "type": "string" + }, + "id": { + "description": "ID 评论ID。", + "type": "integer" + }, + "is_deleted": { + "description": "IsDeleted 是否已删除。", + "type": "boolean" + }, + "likes": { + "description": "Likes 评论点赞数。", + "type": "integer" + }, + "reply_to": { + "description": "ReplyTo 回复评论ID(0 表示一级评论)。", + "type": "integer" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "user_id": { + "description": "UserID 评论用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 评论用户名称。", + "type": "string" + } + } + }, "dto.SuperContentBatchReviewForm": { "type": "object", "required": [ @@ -7803,6 +8174,95 @@ } } }, + "dto.SuperLedgerItem": { + "type": "object", + "properties": { + "amount": { + "description": "Amount 变动金额(分)。", + "type": "integer" + }, + "balance_after": { + "description": "BalanceAfter 变更后可用余额(分)。", + "type": "integer" + }, + "balance_before": { + "description": "BalanceBefore 变更前可用余额(分)。", + "type": "integer" + }, + "biz_ref_id": { + "description": "BizRefID 业务引用ID(可选)。", + "type": "integer" + }, + "biz_ref_type": { + "description": "BizRefType 业务引用类型(可选)。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "frozen_after": { + "description": "FrozenAfter 变更后冻结余额(分)。", + "type": "integer" + }, + "frozen_before": { + "description": "FrozenBefore 变更前冻结余额(分)。", + "type": "integer" + }, + "id": { + "description": "ID 流水ID。", + "type": "integer" + }, + "operator_user_id": { + "description": "OperatorUserID 操作者用户ID(0 表示系统)。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 关联订单ID。", + "type": "integer" + }, + "remark": { + "description": "Remark 流水备注说明。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 流水类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantLedgerType" + } + ] + }, + "type_description": { + "description": "TypeDescription 流水类型描述(用于展示)。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user_id": { + "description": "UserID 关联用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 关联用户名。", + "type": "string" + } + } + }, "dto.SuperNotificationBroadcastForm": { "type": "object", "properties": { @@ -7974,6 +8434,75 @@ } } }, + "dto.SuperOrderAnomalyItem": { + "type": "object", + "properties": { + "amount_paid": { + "description": "AmountPaid 实付金额(分)。", + "type": "integer" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "issue": { + "description": "Issue 异常类型标识。", + "type": "string" + }, + "issue_description": { + "description": "IssueDescription 异常描述说明。", + "type": "string" + }, + "order_id": { + "description": "OrderID 订单ID。", + "type": "integer" + }, + "paid_at": { + "description": "PaidAt 支付时间(RFC3339)。", + "type": "string" + }, + "refunded_at": { + "description": "RefundedAt 退款时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 订单类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5c78411..789192a 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -128,6 +128,22 @@ definitions: - RoleUser - RoleSuperAdmin - RoleCreator + consts.TenantLedgerType: + enum: + - debit_purchase + - credit_refund + - credit_withdrawal + - freeze + - unfreeze + - adjustment + type: string + x-enum-varnames: + - TenantLedgerTypeDebitPurchase + - TenantLedgerTypeCreditRefund + - TenantLedgerTypeCreditWithdrawal + - TenantLedgerTypeFreeze + - TenantLedgerTypeUnfreeze + - TenantLedgerTypeAdjustment consts.TenantStatus: enum: - pending_verify @@ -1264,6 +1280,81 @@ definitions: description: TenantName 租户名称。 type: string type: object + dto.SuperBalanceAnomalyItem: + properties: + balance: + description: Balance 可用余额(分)。 + type: integer + balance_frozen: + description: BalanceFrozen 冻结余额(分)。 + type: integer + created_at: + description: CreatedAt 用户创建时间(RFC3339)。 + type: string + issue: + description: Issue 异常类型标识。 + type: string + issue_description: + description: IssueDescription 异常描述说明。 + type: string + user_id: + description: UserID 用户ID。 + type: integer + username: + description: Username 用户名。 + type: string + type: object + dto.SuperCommentDeleteForm: + properties: + reason: + description: Reason 删除原因(可选,用于审计记录)。 + type: string + type: object + dto.SuperCommentItem: + properties: + content: + description: Content 评论内容。 + type: string + content_id: + description: ContentID 内容ID。 + type: integer + content_title: + description: ContentTitle 内容标题。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + deleted_at: + description: DeletedAt 删除时间(RFC3339,未删除为空)。 + type: string + id: + description: ID 评论ID。 + type: integer + is_deleted: + description: IsDeleted 是否已删除。 + type: boolean + likes: + description: Likes 评论点赞数。 + type: integer + reply_to: + description: ReplyTo 回复评论ID(0 表示一级评论)。 + type: integer + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + user_id: + description: UserID 评论用户ID。 + type: integer + username: + description: Username 评论用户名称。 + type: string + type: object dto.SuperContentBatchReviewForm: properties: action: @@ -1527,6 +1618,70 @@ definitions: required: - action type: object + dto.SuperLedgerItem: + properties: + amount: + description: Amount 变动金额(分)。 + type: integer + balance_after: + description: BalanceAfter 变更后可用余额(分)。 + type: integer + balance_before: + description: BalanceBefore 变更前可用余额(分)。 + type: integer + biz_ref_id: + description: BizRefID 业务引用ID(可选)。 + type: integer + biz_ref_type: + description: BizRefType 业务引用类型(可选)。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + frozen_after: + description: FrozenAfter 变更后冻结余额(分)。 + type: integer + frozen_before: + description: FrozenBefore 变更前冻结余额(分)。 + type: integer + id: + description: ID 流水ID。 + type: integer + operator_user_id: + description: OperatorUserID 操作者用户ID(0 表示系统)。 + type: integer + order_id: + description: OrderID 关联订单ID。 + type: integer + remark: + description: Remark 流水备注说明。 + type: string + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.TenantLedgerType' + description: Type 流水类型。 + type_description: + description: TypeDescription 流水类型描述(用于展示)。 + type: string + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + user_id: + description: UserID 关联用户ID。 + type: integer + username: + description: Username 关联用户名。 + type: string + type: object dto.SuperNotificationBroadcastForm: properties: content: @@ -1644,6 +1799,53 @@ definitions: description: UpdatedAt 更新时间(RFC3339)。 type: string type: object + dto.SuperOrderAnomalyItem: + properties: + amount_paid: + description: AmountPaid 实付金额(分)。 + type: integer + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + issue: + description: Issue 异常类型标识。 + type: string + issue_description: + description: IssueDescription 异常描述说明。 + type: string + order_id: + description: OrderID 订单ID。 + type: integer + paid_at: + description: PaidAt 支付时间(RFC3339)。 + type: string + refunded_at: + description: RefundedAt 退款时间(RFC3339)。 + type: string + status: + allOf: + - $ref: '#/definitions/consts.OrderStatus' + description: Status 订单状态。 + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.OrderType' + description: Type 订单类型。 + user_id: + description: UserID 用户ID。 + type: integer + username: + description: Username 用户名。 + type: string + type: object dto.SuperOrderDetail: properties: buyer: @@ -3085,6 +3287,64 @@ paths: summary: Check token tags: - Auth + /super/v1/comments: + get: + consumes: + - application/json + description: List comments across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperCommentItem' + type: array + type: object + summary: List comments + tags: + - Content + /super/v1/comments/{id}/delete: + post: + consumes: + - application/json + description: Soft delete a comment + parameters: + - description: Comment ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Delete form + in: body + name: form + schema: + $ref: '#/definitions/dto.SuperCommentDeleteForm' + produces: + - application/json + responses: + "200": + description: Deleted + schema: + type: string + summary: Delete comment + tags: + - Content /super/v1/contents: get: consumes: @@ -3392,6 +3652,99 @@ paths: summary: List creators tags: - Creator + /super/v1/finance/anomalies/balances: + get: + consumes: + - application/json + description: List balance anomalies across users + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperBalanceAnomalyItem' + type: array + type: object + summary: List balance anomalies + tags: + - Finance + /super/v1/finance/anomalies/orders: + get: + consumes: + - application/json + description: List order anomalies across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperOrderAnomalyItem' + type: array + type: object + summary: List order anomalies + tags: + - Finance + /super/v1/finance/ledgers: + get: + consumes: + - application/json + description: List tenant ledgers across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperLedgerItem' + type: array + type: object + summary: List ledgers + tags: + - Finance /super/v1/notifications: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 142d607..20f2a6b 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,8 +4,8 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置。 -- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)。 +- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、钱包流水与异常排查、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)、审计日志与系统配置、评论治理。 +- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺举报处理与批量处置扩展)、创作者治理(缺提现审核联动与结算账户审批流)。 - **未落地**:暂无。 ## 2) 按页面完成度(对照 2.x) @@ -42,8 +42,8 @@ ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** -- 已有:跨租户列表、下架、审核流(含批量审核)。 -- 缺口:评论治理、内容举报处理、批量处置扩展(如批量下架/封禁)。 +- 已有:跨租户列表、下架、审核流(含批量审核)、评论治理。 +- 缺口:内容举报处理、批量处置扩展(如批量下架/封禁)。 ### 2.8 订单与退款 `/superadmin/orders` - 状态:**已完成** @@ -61,9 +61,9 @@ - 缺口:无显著功能缺口。 ### 2.11 财务与钱包 `/superadmin/finance` -- 状态:**部分完成** -- 已有:提现列表与审批/驳回、收款账户快照展示。 -- 缺口:钱包流水、充值与退款异常排查、资金汇总报表。 +- 状态:**已完成** +- 已有:提现列表与审批/驳回、收款账户快照展示、钱包流水、充值与退款异常排查。 +- 缺口:无显著功能缺口。 ### 2.12 报表与导出 `/superadmin/reports` - 状态:**已完成** @@ -87,9 +87,11 @@ ## 3) `/super/v1` 接口覆盖度概览 -- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名/互动/内容消费)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 -- **缺失/待补**:暂无与提现审核相关缺口。 +- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名/互动/内容消费)、Contents(含评论治理)、Orders、Withdrawals、Finance(流水/异常)、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 +- **缺失/待补**:内容举报处理。 ## 4) 建议的下一步(按优先级) -1. **内容/财务治理补齐**:评论/举报治理、钱包流水与异常排查能力。 +1. **内容举报治理**:补齐举报记录/处置流与批量处置能力。 +2. **租户详情聚合**:补齐租户财务与报表聚合入口。 +3. **订单运营补强**:问题订单标记、支付对账辅助能力。 diff --git a/frontend/superadmin/src/service/ContentService.js b/frontend/superadmin/src/service/ContentService.js index 1f0b64b..76d21c8 100644 --- a/frontend/superadmin/src/service/ContentService.js +++ b/frontend/superadmin/src/service/ContentService.js @@ -146,5 +146,49 @@ export const ContentService = { granularity }; return requestJson('/super/v1/contents/statistics', { query }); + }, + async listComments({ page, limit, id, tenant_id, tenant_code, tenant_name, content_id, content_title, user_id, username, keyword, status, created_at_from, created_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + content_id, + content_title, + user_id, + username, + keyword, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/comments', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async deleteComment(id, { reason } = {}) { + if (!id) throw new Error('id is required'); + return requestJson(`/super/v1/comments/${id}/delete`, { + method: 'POST', + body: { reason } + }); } }; diff --git a/frontend/superadmin/src/service/FinanceService.js b/frontend/superadmin/src/service/FinanceService.js index f0d480c..8acff23 100644 --- a/frontend/superadmin/src/service/FinanceService.js +++ b/frontend/superadmin/src/service/FinanceService.js @@ -55,5 +55,98 @@ export const FinanceService = { method: 'POST', body: { reason } }); + }, + async listLedgers({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, order_id, type, amount_min, amount_max, created_at_from, created_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + order_id, + type, + amount_min, + amount_max, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/finance/ledgers', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async listBalanceAnomalies({ page, limit, user_id, username, issue, sortField, sortOrder } = {}) { + const query = { + page, + limit, + user_id, + username, + issue + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/finance/anomalies/balances', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async listOrderAnomalies({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, type, issue, created_at_from, created_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + type, + issue, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/finance/anomalies/orders', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; } }; diff --git a/frontend/superadmin/src/views/superadmin/Contents.vue b/frontend/superadmin/src/views/superadmin/Contents.vue index 9b38032..e4eb88e 100644 --- a/frontend/superadmin/src/views/superadmin/Contents.vue +++ b/frontend/superadmin/src/views/superadmin/Contents.vue @@ -8,6 +8,7 @@ import { useRoute } from 'vue-router'; const toast = useToast(); const route = useRoute(); +const tabValue = ref('contents'); const loading = ref(false); const contents = ref([]); @@ -34,6 +35,31 @@ const sortField = ref('id'); const sortOrder = ref(-1); const selectedContents = ref([]); +const commentLoading = ref(false); +const comments = ref([]); +const commentTotalRecords = ref(0); +const commentPage = ref(1); +const commentRows = ref(10); +const commentID = ref(null); +const commentTenantID = ref(null); +const commentTenantCode = ref(''); +const commentTenantName = ref(''); +const commentContentID = ref(null); +const commentContentTitle = ref(''); +const commentUserID = ref(null); +const commentUsername = ref(''); +const commentKeyword = ref(''); +const commentStatus = ref('active'); +const commentCreatedAtFrom = ref(null); +const commentCreatedAtTo = ref(null); +const commentSortField = ref('created_at'); +const commentSortOrder = ref(-1); + +const commentDeleteDialogVisible = ref(false); +const commentDeleteLoading = ref(false); +const commentDeleteReason = ref(''); +const commentDeleteTarget = ref(null); + const reviewDialogVisible = ref(false); const reviewSubmitting = ref(false); const reviewAction = ref('approve'); @@ -60,6 +86,12 @@ const visibilityOptions = [ { label: 'private', value: 'private' } ]; +const commentStatusOptions = [ + { label: '全部', value: 'all' }, + { label: '正常', value: 'active' }, + { label: '已删除', value: 'deleted' } +]; + function getQueryValue(value) { if (Array.isArray(value)) return value[0]; return value ?? null; @@ -111,6 +143,25 @@ function resetFilters() { sortOrder.value = -1; } +function resetCommentFilters() { + commentID.value = null; + commentTenantID.value = null; + commentTenantCode.value = ''; + commentTenantName.value = ''; + commentContentID.value = null; + commentContentTitle.value = ''; + commentUserID.value = null; + commentUsername.value = ''; + commentKeyword.value = ''; + commentStatus.value = 'active'; + commentCreatedAtFrom.value = null; + commentCreatedAtTo.value = null; + commentSortField.value = 'created_at'; + commentSortOrder.value = -1; + commentPage.value = 1; + commentRows.value = 10; +} + function applyRouteQuery(query) { resetFilters(); @@ -175,6 +226,21 @@ function getContentVisibilitySeverity(value) { } } +function getCommentStatusSeverity(isDeleted) { + if (isDeleted) return 'danger'; + return 'success'; +} + +function getCommentStatusLabel(isDeleted) { + return isDeleted ? '已删除' : '正常'; +} + +function formatCommentContent(value) { + const text = String(value || ''); + if (text.length <= 60) return text || '-'; + return `${text.slice(0, 60)}...`; +} + const selectedCount = computed(() => selectedContents.value.length); const reviewTargetCount = computed(() => reviewTargetIDs.value.length); @@ -290,6 +356,80 @@ function onSort(event) { loadContents(); } +async function loadComments() { + commentLoading.value = true; + try { + const result = await ContentService.listComments({ + page: commentPage.value, + limit: commentRows.value, + id: commentID.value || undefined, + tenant_id: commentTenantID.value || undefined, + tenant_code: commentTenantCode.value, + tenant_name: commentTenantName.value, + content_id: commentContentID.value || undefined, + content_title: commentContentTitle.value, + user_id: commentUserID.value || undefined, + username: commentUsername.value, + keyword: commentKeyword.value, + status: commentStatus.value || undefined, + created_at_from: commentCreatedAtFrom.value || undefined, + created_at_to: commentCreatedAtTo.value || undefined, + sortField: commentSortField.value, + sortOrder: commentSortOrder.value + }); + comments.value = result.items; + commentTotalRecords.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载评论列表', life: 4000 }); + } finally { + commentLoading.value = false; + } +} + +function onCommentSearch() { + commentPage.value = 1; + loadComments(); +} + +function onCommentReset() { + resetCommentFilters(); + loadComments(); +} + +function onCommentPage(event) { + commentPage.value = (event.page ?? 0) + 1; + commentRows.value = event.rows ?? commentRows.value; + loadComments(); +} + +function onCommentSort(event) { + commentSortField.value = event.sortField ?? commentSortField.value; + commentSortOrder.value = event.sortOrder ?? commentSortOrder.value; + loadComments(); +} + +function openCommentDeleteDialog(comment) { + commentDeleteTarget.value = comment; + commentDeleteReason.value = ''; + commentDeleteDialogVisible.value = true; +} + +async function confirmCommentDelete() { + const id = Number(commentDeleteTarget.value?.id ?? 0); + if (!id) return; + commentDeleteLoading.value = true; + try { + await ContentService.deleteComment(id, { reason: commentDeleteReason.value.trim() || undefined }); + toast.add({ severity: 'success', summary: '已删除', detail: `评论ID: ${id}`, life: 3000 }); + commentDeleteDialogVisible.value = false; + await loadComments(); + } catch (error) { + toast.add({ severity: 'error', summary: '删除失败', detail: error?.message || '无法删除评论', life: 4000 }); + } finally { + commentDeleteLoading.value = false; + } +} + const unpublishDialogVisible = ref(false); const unpublishLoading = ref(false); const unpublishItem = ref(null); @@ -320,9 +460,21 @@ async function confirmUnpublish() { } } +watch( + () => tabValue.value, + (value) => { + if (value === 'comments') { + loadComments(); + } else if (value === 'contents') { + loadContents(); + } + } +); + watch( () => route.query, (query) => { + tabValue.value = 'contents'; applyRouteQuery(query); page.value = 1; rows.value = 10; @@ -334,166 +486,314 @@ watch( + + + +
+
确认删除该评论?删除后在前端将不可见。
+
+ + +
+
+ +
diff --git a/frontend/superadmin/src/views/superadmin/Finance.vue b/frontend/superadmin/src/views/superadmin/Finance.vue index d209a39..1436bb7 100644 --- a/frontend/superadmin/src/views/superadmin/Finance.vue +++ b/frontend/superadmin/src/views/superadmin/Finance.vue @@ -8,6 +8,7 @@ import { useRoute } from 'vue-router'; const toast = useToast(); const route = useRoute(); +const tabValue = ref('withdrawals'); const withdrawals = ref([]); const loading = ref(false); @@ -32,6 +33,55 @@ const amountPaidMax = ref(null); const sortField = ref('id'); const sortOrder = ref(-1); +const ledgers = ref([]); +const ledgerLoading = ref(false); +const ledgerTotalRecords = ref(0); +const ledgerPage = ref(1); +const ledgerRows = ref(10); +const ledgerID = ref(null); +const ledgerTenantID = ref(null); +const ledgerTenantCode = ref(''); +const ledgerTenantName = ref(''); +const ledgerUserID = ref(null); +const ledgerUsername = ref(''); +const ledgerOrderID = ref(null); +const ledgerType = ref(''); +const ledgerAmountMin = ref(null); +const ledgerAmountMax = ref(null); +const ledgerCreatedAtFrom = ref(null); +const ledgerCreatedAtTo = ref(null); +const ledgerSortField = ref('id'); +const ledgerSortOrder = ref(-1); + +const balanceAnomalies = ref([]); +const balanceLoading = ref(false); +const balanceTotalRecords = ref(0); +const balancePage = ref(1); +const balanceRows = ref(10); +const balanceUserID = ref(null); +const balanceUsername = ref(''); +const balanceIssue = ref(''); +const balanceSortField = ref('id'); +const balanceSortOrder = ref(-1); + +const orderAnomalies = ref([]); +const orderAnomalyLoading = ref(false); +const orderAnomalyTotalRecords = ref(0); +const orderAnomalyPage = ref(1); +const orderAnomalyRows = ref(10); +const orderAnomalyID = ref(null); +const orderAnomalyTenantID = ref(null); +const orderAnomalyTenantCode = ref(''); +const orderAnomalyTenantName = ref(''); +const orderAnomalyUserID = ref(null); +const orderAnomalyUsername = ref(''); +const orderAnomalyType = ref(''); +const orderAnomalyIssue = ref(''); +const orderAnomalyCreatedAtFrom = ref(null); +const orderAnomalyCreatedAtTo = ref(null); +const orderAnomalySortField = ref('id'); +const orderAnomalySortOrder = ref(-1); + const statusOptions = [ { label: '全部', value: '' }, { label: 'created', value: 'created' }, @@ -39,6 +89,35 @@ const statusOptions = [ { label: 'failed', value: 'failed' } ]; +const ledgerTypeOptions = [ + { label: '全部', value: '' }, + { label: '内容收入', value: 'debit_purchase' }, + { label: '退款冲回', value: 'credit_refund' }, + { label: '提现扣减', value: 'credit_withdrawal' }, + { label: '冻结', value: 'freeze' }, + { label: '解冻', value: 'unfreeze' }, + { label: '调整', value: 'adjustment' } +]; + +const balanceIssueOptions = [ + { label: '全部', value: '' }, + { label: '可用余额为负', value: 'negative_balance' }, + { label: '冻结余额为负', value: 'negative_frozen' } +]; + +const orderTypeOptions = [ + { label: '全部', value: '' }, + { label: '内容购买', value: 'content_purchase' }, + { label: '充值', value: 'recharge' }, + { label: '提现', value: 'withdrawal' } +]; + +const orderIssueOptions = [ + { label: '全部', value: '' }, + { label: '支付时间缺失', value: 'missing_paid_at' }, + { label: '退款时间缺失', value: 'missing_refunded_at' } +]; + function getQueryValue(value) { if (Array.isArray(value)) return value[0]; return value ?? null; @@ -114,6 +193,53 @@ function getStatusSeverity(value) { } } +function formatLedgerType(value) { + switch (value) { + case 'debit_purchase': + return '内容收入'; + case 'credit_refund': + return '退款冲回'; + case 'credit_withdrawal': + return '提现扣减'; + case 'freeze': + return '冻结'; + case 'unfreeze': + return '解冻'; + case 'adjustment': + return '调整'; + default: + return value || '-'; + } +} + +function formatOrderType(value) { + switch (value) { + case 'content_purchase': + return '内容购买'; + case 'recharge': + return '充值'; + case 'withdrawal': + return '提现'; + default: + return value || '-'; + } +} + +function formatOrderStatus(value) { + switch (value) { + case 'paid': + return '已支付'; + case 'refunded': + return '已退款'; + case 'created': + return '待处理'; + case 'failed': + return '失败'; + default: + return value || '-'; + } +} + async function loadWithdrawals() { loading.value = true; try { @@ -221,6 +347,197 @@ function onSort(event) { loadWithdrawals(); } +async function loadLedgers() { + ledgerLoading.value = true; + try { + const result = await FinanceService.listLedgers({ + page: ledgerPage.value, + limit: ledgerRows.value, + id: ledgerID.value || undefined, + tenant_id: ledgerTenantID.value || undefined, + tenant_code: ledgerTenantCode.value, + tenant_name: ledgerTenantName.value, + user_id: ledgerUserID.value || undefined, + username: ledgerUsername.value, + order_id: ledgerOrderID.value || undefined, + type: ledgerType.value || undefined, + amount_min: ledgerAmountMin.value ?? undefined, + amount_max: ledgerAmountMax.value ?? undefined, + created_at_from: ledgerCreatedAtFrom.value || undefined, + created_at_to: ledgerCreatedAtTo.value || undefined, + sortField: ledgerSortField.value, + sortOrder: ledgerSortOrder.value + }); + ledgers.value = result.items; + ledgerTotalRecords.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载资金流水', life: 4000 }); + } finally { + ledgerLoading.value = false; + } +} + +function resetLedgerFilters() { + ledgerID.value = null; + ledgerTenantID.value = null; + ledgerTenantCode.value = ''; + ledgerTenantName.value = ''; + ledgerUserID.value = null; + ledgerUsername.value = ''; + ledgerOrderID.value = null; + ledgerType.value = ''; + ledgerAmountMin.value = null; + ledgerAmountMax.value = null; + ledgerCreatedAtFrom.value = null; + ledgerCreatedAtTo.value = null; + ledgerSortField.value = 'id'; + ledgerSortOrder.value = -1; + ledgerPage.value = 1; + ledgerRows.value = 10; +} + +function onLedgerSearch() { + ledgerPage.value = 1; + loadLedgers(); +} + +function onLedgerReset() { + resetLedgerFilters(); + loadLedgers(); +} + +function onLedgerPage(event) { + ledgerPage.value = (event.page ?? 0) + 1; + ledgerRows.value = event.rows ?? ledgerRows.value; + loadLedgers(); +} + +function onLedgerSort(event) { + ledgerSortField.value = event.sortField ?? ledgerSortField.value; + ledgerSortOrder.value = event.sortOrder ?? ledgerSortOrder.value; + loadLedgers(); +} + +async function loadBalanceAnomalies() { + balanceLoading.value = true; + try { + const result = await FinanceService.listBalanceAnomalies({ + page: balancePage.value, + limit: balanceRows.value, + user_id: balanceUserID.value || undefined, + username: balanceUsername.value, + issue: balanceIssue.value || undefined, + sortField: balanceSortField.value, + sortOrder: balanceSortOrder.value + }); + balanceAnomalies.value = result.items; + balanceTotalRecords.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载余额异常', life: 4000 }); + } finally { + balanceLoading.value = false; + } +} + +function resetBalanceFilters() { + balanceUserID.value = null; + balanceUsername.value = ''; + balanceIssue.value = ''; + balanceSortField.value = 'id'; + balanceSortOrder.value = -1; + balancePage.value = 1; + balanceRows.value = 10; +} + +function onBalanceSearch() { + balancePage.value = 1; + loadBalanceAnomalies(); +} + +function onBalanceReset() { + resetBalanceFilters(); + loadBalanceAnomalies(); +} + +function onBalancePage(event) { + balancePage.value = (event.page ?? 0) + 1; + balanceRows.value = event.rows ?? balanceRows.value; + loadBalanceAnomalies(); +} + +function onBalanceSort(event) { + balanceSortField.value = event.sortField ?? balanceSortField.value; + balanceSortOrder.value = event.sortOrder ?? balanceSortOrder.value; + loadBalanceAnomalies(); +} + +async function loadOrderAnomalies() { + orderAnomalyLoading.value = true; + try { + const result = await FinanceService.listOrderAnomalies({ + page: orderAnomalyPage.value, + limit: orderAnomalyRows.value, + id: orderAnomalyID.value || undefined, + tenant_id: orderAnomalyTenantID.value || undefined, + tenant_code: orderAnomalyTenantCode.value, + tenant_name: orderAnomalyTenantName.value, + user_id: orderAnomalyUserID.value || undefined, + username: orderAnomalyUsername.value, + type: orderAnomalyType.value || undefined, + issue: orderAnomalyIssue.value || undefined, + created_at_from: orderAnomalyCreatedAtFrom.value || undefined, + created_at_to: orderAnomalyCreatedAtTo.value || undefined, + sortField: orderAnomalySortField.value, + sortOrder: orderAnomalySortOrder.value + }); + orderAnomalies.value = result.items; + orderAnomalyTotalRecords.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单异常', life: 4000 }); + } finally { + orderAnomalyLoading.value = false; + } +} + +function resetOrderAnomalyFilters() { + orderAnomalyID.value = null; + orderAnomalyTenantID.value = null; + orderAnomalyTenantCode.value = ''; + orderAnomalyTenantName.value = ''; + orderAnomalyUserID.value = null; + orderAnomalyUsername.value = ''; + orderAnomalyType.value = ''; + orderAnomalyIssue.value = ''; + orderAnomalyCreatedAtFrom.value = null; + orderAnomalyCreatedAtTo.value = null; + orderAnomalySortField.value = 'id'; + orderAnomalySortOrder.value = -1; + orderAnomalyPage.value = 1; + orderAnomalyRows.value = 10; +} + +function onOrderAnomalySearch() { + orderAnomalyPage.value = 1; + loadOrderAnomalies(); +} + +function onOrderAnomalyReset() { + resetOrderAnomalyFilters(); + loadOrderAnomalies(); +} + +function onOrderAnomalyPage(event) { + orderAnomalyPage.value = (event.page ?? 0) + 1; + orderAnomalyRows.value = event.rows ?? orderAnomalyRows.value; + loadOrderAnomalies(); +} + +function onOrderAnomalySort(event) { + orderAnomalySortField.value = event.sortField ?? orderAnomalySortField.value; + orderAnomalySortOrder.value = event.sortOrder ?? orderAnomalySortOrder.value; + loadOrderAnomalies(); +} + function openApproveDialog(order) { approveOrder.value = order; approveDialogVisible.value = true; @@ -264,9 +581,24 @@ async function confirmReject() { } } +watch( + () => tabValue.value, + (value) => { + if (value === 'ledgers') { + loadLedgers(); + } else if (value === 'anomalies') { + loadBalanceAnomalies(); + loadOrderAnomalies(); + } else if (value === 'withdrawals') { + loadWithdrawals(); + } + } +); + watch( () => route.query, (query) => { + tabValue.value = 'withdrawals'; applyRouteQuery(query); loadWithdrawals(); }, @@ -277,133 +609,450 @@ watch(