diff --git a/backend/app/http/super/v1/dto/super_payout.go b/backend/app/http/super/v1/dto/super_payout.go index e66dd65..3dbca59 100644 --- a/backend/app/http/super/v1/dto/super_payout.go +++ b/backend/app/http/super/v1/dto/super_payout.go @@ -1,6 +1,9 @@ package dto -import "quyun/v2/app/requests" +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) // SuperPayoutAccountListFilter 超管结算账户列表过滤条件。 type SuperPayoutAccountListFilter struct { @@ -17,6 +20,8 @@ type SuperPayoutAccountListFilter struct { Username *string `query:"username"` // Type 账户类型过滤(bank/alipay)。 Type *string `query:"type"` + // Status 审核状态过滤(pending/approved/rejected)。 + Status *consts.PayoutAccountStatus `query:"status"` // CreatedAtFrom 创建时间起始(RFC3339)。 CreatedAtFrom *string `query:"created_at_from"` // CreatedAtTo 创建时间结束(RFC3339)。 @@ -45,8 +50,25 @@ type SuperPayoutAccountItem struct { Account string `json:"account"` // Realname 收款人姓名。 Realname string `json:"realname"` + // Status 审核状态。 + Status consts.PayoutAccountStatus `json:"status"` + // StatusDescription 审核状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // ReviewedBy 审核操作者ID。 + ReviewedBy int64 `json:"reviewed_by"` + // ReviewedAt 审核时间(RFC3339)。 + ReviewedAt string `json:"reviewed_at"` + // ReviewReason 审核说明/驳回原因。 + ReviewReason string `json:"review_reason"` // CreatedAt 创建时间(RFC3339)。 CreatedAt string `json:"created_at"` // UpdatedAt 更新时间(RFC3339)。 UpdatedAt string `json:"updated_at"` } + +type SuperPayoutAccountReviewForm struct { + // Action 审核动作(approve/reject)。 + Action string `json:"action" validate:"required,oneof=approve reject"` + // Reason 审核说明(驳回时必填)。 + Reason string `json:"reason"` +} diff --git a/backend/app/http/super/v1/payout_accounts.go b/backend/app/http/super/v1/payout_accounts.go index b28866a..00b3529 100644 --- a/backend/app/http/super/v1/payout_accounts.go +++ b/backend/app/http/super/v1/payout_accounts.go @@ -43,3 +43,21 @@ func (c *payoutAccounts) List(ctx fiber.Ctx, filter *dto.SuperPayoutAccountListF func (c *payoutAccounts) Remove(ctx fiber.Ctx, user *models.User, id int64) error { return services.Super.RemovePayoutAccount(ctx, user.ID, id) } + +// Review payout account +// +// @Router /super/v1/payout-accounts/:id/review [post] +// @Summary Review payout account +// @Description Review payout account across tenants +// @Tags Finance +// @Accept json +// @Produce json +// @Param id path int64 true "Payout account ID" +// @Param form body dto.SuperPayoutAccountReviewForm true "Review form" +// @Success 200 {string} string "Reviewed" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *payoutAccounts) Review(ctx fiber.Ctx, user *models.User, id int64, form *dto.SuperPayoutAccountReviewForm) error { + return services.Super.ReviewPayoutAccount(ctx, user.ID, id, form) +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index b7f9da3..482d8f5 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -305,6 +305,13 @@ func (r *Routes) Register(router fiber.Router) { r.payoutAccounts.List, Query[dto.SuperPayoutAccountListFilter]("filter"), )) + r.log.Debugf("Registering route: Post /super/v1/payout-accounts/:id/review -> payoutAccounts.Review") + router.Post("/super/v1/payout-accounts/:id/review"[len(r.Path()):], Func3( + r.payoutAccounts.Review, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[dto.SuperPayoutAccountReviewForm]("form"), + )) // Register routes for controller: reports r.log.Debugf("Registering route: Get /super/v1/reports/overview -> reports.Overview") router.Get("/super/v1/reports/overview"[len(r.Path()):], DataFunc1( diff --git a/backend/app/http/v1/dto/creator.go b/backend/app/http/v1/dto/creator.go index 9f2fdd6..f8f9df2 100644 --- a/backend/app/http/v1/dto/creator.go +++ b/backend/app/http/v1/dto/creator.go @@ -1,6 +1,9 @@ package dto -import "quyun/v2/app/requests" +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) type ApplyForm struct { // Name 频道/创作者名称。 @@ -206,6 +209,14 @@ type PayoutAccount struct { Account string `json:"account"` // Realname 收款人姓名。 Realname string `json:"realname"` + // Status 审核状态(pending/approved/rejected)。 + Status consts.PayoutAccountStatus `json:"status"` + // StatusDescription 审核状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // ReviewedAt 审核时间(RFC3339)。 + ReviewedAt string `json:"reviewed_at"` + // ReviewReason 审核说明/驳回原因。 + ReviewReason string `json:"review_reason"` } type WithdrawForm struct { diff --git a/backend/app/services/creator.go b/backend/app/services/creator.go index a624eee..aeae55f 100644 --- a/backend/app/services/creator.go +++ b/backend/app/services/creator.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "strconv" + "strings" "time" "quyun/v2/app/errorx" @@ -864,7 +865,8 @@ func (s *creator) ListPayoutAccounts(ctx context.Context, tenantID, userID int64 return nil, err } - list, err := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.TenantID.Eq(tid)).Find() + tbl, q := models.PayoutAccountQuery.QueryContext(ctx) + list, err := q.Where(tbl.TenantID.Eq(tid)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } @@ -872,11 +874,15 @@ func (s *creator) ListPayoutAccounts(ctx context.Context, tenantID, userID int64 var data []creator_dto.PayoutAccount for _, v := range list { data = append(data, creator_dto.PayoutAccount{ - ID: v.ID, - Type: v.Type, - Name: v.Name, - Account: v.Account, - Realname: v.Realname, + ID: v.ID, + Type: string(v.Type), + Name: v.Name, + Account: v.Account, + Realname: v.Realname, + Status: v.Status, + StatusDescription: v.Status.Description(), + ReviewedAt: s.formatTime(v.ReviewedAt), + ReviewReason: v.ReviewReason, }) } return data, nil @@ -892,10 +898,11 @@ func (s *creator) AddPayoutAccount(ctx context.Context, tenantID, userID int64, pa := &models.PayoutAccount{ TenantID: tid, UserID: uid, - Type: form.Type, + Type: consts.PayoutAccountType(form.Type), Name: form.Name, Account: form.Account, Realname: form.Realname, + Status: consts.PayoutAccountStatusPending, } if err := models.PayoutAccountQuery.WithContext(ctx).Create(pa); err != nil { return errorx.ErrDatabaseError.WithCause(err) @@ -909,9 +916,8 @@ func (s *creator) RemovePayoutAccount(ctx context.Context, tenantID, userID, id return err } - _, err = models.PayoutAccountQuery.WithContext(ctx). - Where(models.PayoutAccountQuery.ID.Eq(id), models.PayoutAccountQuery.TenantID.Eq(tid)). - Delete() + tbl, q := models.PayoutAccountQuery.QueryContext(ctx) + _, err = q.Where(tbl.ID.Eq(id), tbl.TenantID.Eq(tid)).Delete() if err != nil { return errorx.ErrDatabaseError.WithCause(err) } @@ -925,7 +931,7 @@ func (s *creator) Withdraw(ctx context.Context, tenantID, userID int64, form *cr } uid := userID - // Validate User Real-name Status + // 校验用户实名认证状态,未通过不允许提现。 user, err := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(uid)).First() if err != nil { return errorx.ErrDatabaseError.WithCause(err) @@ -939,19 +945,25 @@ func (s *creator) Withdraw(ctx context.Context, tenantID, userID int64, form *cr return errorx.ErrBadRequest.WithMsg("金额无效") } - // Validate Payout Account - account, err := models.PayoutAccountQuery.WithContext(ctx). - Where(models.PayoutAccountQuery.ID.Eq(form.AccountID), models.PayoutAccountQuery.TenantID.Eq(tid)). - First() + // 校验收款账户可用性与审核状态。 + tbl, q := models.PayoutAccountQuery.QueryContext(ctx) + account, err := q.Where(tbl.ID.Eq(form.AccountID), tbl.TenantID.Eq(tid)).First() if err != nil { return errorx.ErrRecordNotFound.WithMsg("收款账户不存在") } + if account.Status != consts.PayoutAccountStatusApproved { + reason := strings.TrimSpace(account.ReviewReason) + if account.Status == consts.PayoutAccountStatusRejected && reason != "" { + return errorx.ErrPreconditionFailed.WithMsg("收款账户审核未通过:" + reason) + } + return errorx.ErrPreconditionFailed.WithMsg("收款账户未审核通过") + } // 将收款账户快照写入订单,便于超管审核与打款核对。 snapshotPayload, err := json.Marshal(fields.OrdersWithdrawalSnapshot{ Method: form.Method, AccountID: account.ID, - AccountType: account.Type, + AccountType: string(account.Type), AccountName: account.Name, Account: account.Account, AccountRealname: account.Realname, @@ -1032,6 +1044,13 @@ func (s *creator) getTenantID(ctx context.Context, tenantID, userID int64) (int6 return t.ID, nil } +func (s *creator) formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + func (s *creator) validateContentAssets( ctx context.Context, tx *models.Query, diff --git a/backend/app/services/creator_test.go b/backend/app/services/creator_test.go index 25099af..fb555d5 100644 --- a/backend/app/services/creator_test.go +++ b/backend/app/services/creator_test.go @@ -247,6 +247,7 @@ func (s *CreatorTestSuite) Test_PayoutAccount() { So(err, ShouldBeNil) So(len(list), ShouldEqual, 1) So(list[0].Account, ShouldEqual, "user@example.com") + So(list[0].Status, ShouldEqual, consts.PayoutAccountStatusPending) // Remove err = Creator.RemovePayoutAccount(ctx, tenantID, u.ID, list[0].ID) @@ -285,10 +286,11 @@ func (s *CreatorTestSuite) Test_Withdraw() { pa := &models.PayoutAccount{ TenantID: t.ID, UserID: u.ID, - Type: "bank", + Type: consts.PayoutAccountTypeBank, Name: "Bank", Account: "123", Realname: "Creator", + Status: consts.PayoutAccountStatusApproved, } models.PayoutAccountQuery.WithContext(ctx).Create(pa) @@ -316,7 +318,7 @@ func (s *CreatorTestSuite) Test_Withdraw() { var snap fields.OrdersWithdrawalSnapshot So(json.Unmarshal(o.Snapshot.Data().Data, &snap), ShouldBeNil) So(snap.AccountID, ShouldEqual, pa.ID) - So(snap.AccountType, ShouldEqual, pa.Type) + So(snap.AccountType, ShouldEqual, string(pa.Type)) So(snap.AccountName, ShouldEqual, pa.Name) So(snap.Account, ShouldEqual, pa.Account) So(snap.AccountRealname, ShouldEqual, pa.Realname) diff --git a/backend/app/services/super.go b/backend/app/services/super.go index da2575d..ed0baf9 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -14,6 +14,7 @@ import ( super_dto "quyun/v2/app/http/super/v1/dto" v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" jwt_provider "quyun/v2/providers/jwt" @@ -1150,7 +1151,10 @@ func (s *super) ListPayoutAccounts(ctx context.Context, filter *super_dto.SuperP q = q.Where(tbl.UserID.Eq(*filter.UserID)) } if filter.Type != nil && strings.TrimSpace(*filter.Type) != "" { - q = q.Where(tbl.Type.Eq(strings.TrimSpace(*filter.Type))) + q = q.Where(tbl.Type.Eq(consts.PayoutAccountType(strings.TrimSpace(*filter.Type)))) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) } tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) @@ -1273,18 +1277,23 @@ func (s *super) ListPayoutAccounts(ctx context.Context, filter *super_dto.SuperP } items = append(items, super_dto.SuperPayoutAccountItem{ - ID: pa.ID, - TenantID: pa.TenantID, - TenantCode: tenantCode, - TenantName: tenantName, - UserID: pa.UserID, - Username: username, - Type: pa.Type, - Name: pa.Name, - Account: pa.Account, - Realname: pa.Realname, - CreatedAt: s.formatTime(pa.CreatedAt), - UpdatedAt: s.formatTime(pa.UpdatedAt), + ID: pa.ID, + TenantID: pa.TenantID, + TenantCode: tenantCode, + TenantName: tenantName, + UserID: pa.UserID, + Username: username, + Type: string(pa.Type), + Name: pa.Name, + Account: pa.Account, + Realname: pa.Realname, + Status: pa.Status, + StatusDescription: pa.Status.Description(), + ReviewedBy: pa.ReviewedBy, + ReviewedAt: s.formatTime(pa.ReviewedAt), + ReviewReason: pa.ReviewReason, + CreatedAt: s.formatTime(pa.CreatedAt), + UpdatedAt: s.formatTime(pa.UpdatedAt), }) } @@ -1322,6 +1331,84 @@ func (s *super) RemovePayoutAccount(ctx context.Context, operatorID, id int64) e return nil } +func (s *super) ReviewPayoutAccount(ctx context.Context, operatorID, id int64, form *super_dto.SuperPayoutAccountReviewForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if id == 0 { + return errorx.ErrBadRequest.WithMsg("结算账户ID不能为空") + } + if form == nil { + return errorx.ErrBadRequest.WithMsg("审核参数不能为空") + } + + action := strings.ToLower(strings.TrimSpace(form.Action)) + if action != "approve" && action != "reject" { + return errorx.ErrBadRequest.WithMsg("审核动作非法") + } + reason := strings.TrimSpace(form.Reason) + if action == "reject" && reason == "" { + return errorx.ErrBadRequest.WithMsg("驳回原因不能为空") + } + + nextStatus := consts.PayoutAccountStatusApproved + if action == "reject" { + nextStatus = consts.PayoutAccountStatusRejected + } + + var account *models.PayoutAccount + // 事务内校验状态并写入审核结果,避免并发重复审核。 + err := models.Q.Transaction(func(tx *models.Query) error { + tbl, q := tx.PayoutAccount.QueryContext(ctx) + existing, err := q.Where(tbl.ID.Eq(id)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("结算账户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if existing.Status != consts.PayoutAccountStatusPending { + return errorx.ErrStatusConflict.WithMsg("结算账户已完成审核") + } + + now := time.Now() + updates := map[string]interface{}{ + "status": nextStatus, + "reviewed_by": operatorID, + "reviewed_at": now, + "review_reason": reason, + "updated_at": now, + } + if action == "approve" { + updates["review_reason"] = "" + } + if _, err := q.Where(tbl.ID.Eq(existing.ID)).Updates(updates); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + account = existing + return nil + }) + if err != nil { + return err + } + + title := "结算账户审核结果" + detail := "结算账户审核通过" + if action == "reject" { + detail = "结算账户审核驳回" + if reason != "" { + detail += ",原因:" + reason + } + } + if Notification != nil && account != nil { + _ = Notification.Send(ctx, account.TenantID, account.UserID, string(consts.NotificationTypeAudit), title, detail) + } + if Audit != nil && account != nil { + Audit.Log(ctx, account.TenantID, operatorID, "review_payout_account", cast.ToString(account.ID), detail) + } + return nil +} + func (s *super) ListTenantJoinRequests(ctx context.Context, filter *super_dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) { if filter == nil { filter = &super_dto.SuperTenantJoinRequestListFilter{} @@ -8008,6 +8095,26 @@ func (s *super) ApproveWithdrawal(ctx context.Context, operatorID, id int64) err return errorx.ErrStatusConflict.WithMsg("订单状态不正确") } + snapshot := o.Snapshot.Data() + if snapshot.Kind == "withdrawal" && len(snapshot.Data) > 0 { + var snap fields.OrdersWithdrawalSnapshot + if err := json.Unmarshal(snapshot.Data, &snap); err != nil { + return errorx.ErrInternalError.WithCause(err).WithMsg("解析提现快照失败") + } + if snap.AccountID > 0 { + account, err := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.ID.Eq(snap.AccountID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("收款账户不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if account.Status != consts.PayoutAccountStatusApproved { + return errorx.ErrPreconditionFailed.WithMsg("收款账户未审核通过") + } + } + } + // Mark as Paid (Assumes external transfer done) _, err = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).Updates(&models.Order{ Status: consts.OrderStatusPaid, diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index 3b0ce41..e38b1f5 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -733,3 +733,67 @@ func (s *SuperTestSuite) Test_OrderGovernance() { }) }) } + +func (s *SuperTestSuite) Test_PayoutAccountReview() { + Convey("PayoutAccountReview", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNamePayoutAccount, models.TableNameUser, models.TableNameTenant) + + admin := &models.User{Username: "payout_admin"} + owner := &models.User{Username: "payout_owner"} + models.UserQuery.WithContext(ctx).Create(admin, owner) + + tenant := &models.Tenant{ + UserID: owner.ID, + Name: "Payout Tenant", + Code: "payout", + Status: consts.TenantStatusVerified, + } + models.TenantQuery.WithContext(ctx).Create(tenant) + + account := &models.PayoutAccount{ + TenantID: tenant.ID, + UserID: owner.ID, + Type: consts.PayoutAccountTypeBank, + Name: "Bank", + Account: "123", + Realname: "Owner", + Status: consts.PayoutAccountStatusPending, + } + models.PayoutAccountQuery.WithContext(ctx).Create(account) + + Convey("should approve payout account", func() { + err := Super.ReviewPayoutAccount(ctx, admin.ID, account.ID, &super_dto.SuperPayoutAccountReviewForm{ + Action: "approve", + }) + So(err, ShouldBeNil) + + reloaded, _ := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.ID.Eq(account.ID)).First() + So(reloaded.Status, ShouldEqual, consts.PayoutAccountStatusApproved) + So(reloaded.ReviewedBy, ShouldEqual, admin.ID) + So(reloaded.ReviewedAt.IsZero(), ShouldBeFalse) + }) + + Convey("should require reason when rejecting", func() { + account2 := &models.PayoutAccount{ + TenantID: tenant.ID, + UserID: owner.ID, + Type: consts.PayoutAccountTypeAlipay, + Name: "Alipay", + Account: "user@example.com", + Realname: "Owner", + Status: consts.PayoutAccountStatusPending, + } + models.PayoutAccountQuery.WithContext(ctx).Create(account2) + + err := Super.ReviewPayoutAccount(ctx, admin.ID, account2.ID, &super_dto.SuperPayoutAccountReviewForm{ + Action: "reject", + }) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.ErrBadRequest.Code) + }) + }) +} diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index c07c6dd..26b2e2c 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -52,6 +52,9 @@ field_type: type: consts.CouponType user_coupons: status: consts.UserCouponStatus + payout_accounts: + type: consts.PayoutAccountType + status: consts.PayoutAccountStatus notification_templates: type: consts.NotificationType field_relate: diff --git a/backend/database/migrations/20260116063357_add_payout_account_review.sql b/backend/database/migrations/20260116063357_add_payout_account_review.sql new file mode 100644 index 0000000..854dfbe --- /dev/null +++ b/backend/database/migrations/20260116063357_add_payout_account_review.sql @@ -0,0 +1,33 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE payout_accounts + ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS reviewed_by BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS review_reason VARCHAR(255) NOT NULL DEFAULT ''; + +COMMENT ON COLUMN payout_accounts.status IS '结算账户审核状态;用途:控制提现账户可用性;默认 pending。'; +COMMENT ON COLUMN payout_accounts.reviewed_by IS '结算账户审核操作者ID;用途:审计追踪;默认 0 表示未审核。'; +COMMENT ON COLUMN payout_accounts.reviewed_at IS '结算账户审核时间;用途:记录审核完成时间;未审核为空。'; +COMMENT ON COLUMN payout_accounts.review_reason IS '结算账户审核说明;用途:驳回原因或备注;默认空字符串。'; + +-- 历史数据默认视为已审核通过,避免新增流程影响既有提现。 +UPDATE payout_accounts +SET status = 'approved' +WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS payout_accounts_status_idx ON payout_accounts(status); +CREATE INDEX IF NOT EXISTS payout_accounts_reviewed_at_idx ON payout_accounts(reviewed_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS payout_accounts_reviewed_at_idx; +DROP INDEX IF EXISTS payout_accounts_status_idx; + +ALTER TABLE payout_accounts + DROP COLUMN IF EXISTS review_reason, + DROP COLUMN IF EXISTS reviewed_at, + DROP COLUMN IF EXISTS reviewed_by, + DROP COLUMN IF EXISTS status; +-- +goose StatementEnd diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index a3de8e8..fc251f6 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -40,9 +40,9 @@ type Content struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured - Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` + Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index ddbf1b6..d462865 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -46,12 +46,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") - _contentQuery.Comments = contentQueryHasManyComments{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Comments", "Comment"), - } - _contentQuery.Author = contentQueryBelongsToAuthor{ db: db.Session(&gorm.Session{}), @@ -64,6 +58,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("ContentAssets", "ContentAsset"), } + _contentQuery.Comments = contentQueryHasManyComments{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Comments", "Comment"), + } + _contentQuery.fillFieldMap() return _contentQuery @@ -94,12 +94,12 @@ type contentQuery struct { DeletedAt field.Field Key field.String // Musical key/tone IsPinned field.Bool // Whether content is pinned/featured - Comments contentQueryHasManyComments - - Author contentQueryBelongsToAuthor + Author contentQueryBelongsToAuthor ContentAssets contentQueryHasManyContentAssets + Comments contentQueryHasManyComments + fieldMap map[string]field.Expr } @@ -195,104 +195,23 @@ func (c *contentQuery) fillFieldMap() { func (c contentQuery) clone(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool) - c.Comments.db = db.Session(&gorm.Session{Initialized: true}) - c.Comments.db.Statement.ConnPool = db.Statement.ConnPool c.Author.db = db.Session(&gorm.Session{Initialized: true}) c.Author.db.Statement.ConnPool = db.Statement.ConnPool c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool + c.Comments.db = db.Session(&gorm.Session{Initialized: true}) + c.Comments.db.Statement.ConnPool = db.Statement.ConnPool return c } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) - c.Comments.db = db.Session(&gorm.Session{}) c.Author.db = db.Session(&gorm.Session{}) c.ContentAssets.db = db.Session(&gorm.Session{}) + c.Comments.db = db.Session(&gorm.Session{}) return c } -type contentQueryHasManyComments struct { - db *gorm.DB - - field.RelationField -} - -func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { - a.db = a.db.Session(session) - return &a -} - -func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { - return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} -} - -func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { - a.db = a.db.Unscoped() - return &a -} - -type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } - -func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { - return result, a.tx.Find(&result) -} - -func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Clear() error { - return a.tx.Clear() -} - -func (a contentQueryHasManyCommentsTx) Count() int64 { - return a.tx.Count() -} - -func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { - a.tx = a.tx.Unscoped() - return &a -} - type contentQueryBelongsToAuthor struct { db *gorm.DB @@ -455,6 +374,87 @@ func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyConte return &a } +type contentQueryHasManyComments struct { + db *gorm.DB + + field.RelationField +} + +func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { + a.db = a.db.Session(session) + return &a +} + +func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { + return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} +} + +func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { + a.db = a.db.Unscoped() + return &a +} + +type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } + +func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { + return result, a.tx.Find(&result) +} + +func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Clear() error { + return a.tx.Clear() +} + +func (a contentQueryHasManyCommentsTx) Count() int64 { + return a.tx.Count() +} + +func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { + a.tx = a.tx.Unscoped() + return &a +} + type contentQueryDo struct{ gen.DO } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/backend/database/models/payout_accounts.gen.go b/backend/database/models/payout_accounts.gen.go index 9e4c44d..662cd8c 100644 --- a/backend/database/models/payout_accounts.gen.go +++ b/backend/database/models/payout_accounts.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/pkg/consts" + "go.ipao.vip/gen" ) @@ -15,15 +17,19 @@ const TableNamePayoutAccount = "payout_accounts" // PayoutAccount mapped from table type PayoutAccount struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` - UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` - Type string `gorm:"column:type;type:character varying(32);not null" json:"type"` - Name string `gorm:"column:name;type:character varying(128);not null" json:"name"` - Account string `gorm:"column:account;type:character varying(128);not null" json:"account"` - Realname string `gorm:"column:realname;type:character varying(128);not null" json:"realname"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;default:now()" json:"updated_at"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"` + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null" json:"tenant_id"` + UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` + Type consts.PayoutAccountType `gorm:"column:type;type:character varying(32);not null" json:"type"` + Name string `gorm:"column:name;type:character varying(128);not null" json:"name"` + Account string `gorm:"column:account;type:character varying(128);not null" json:"account"` + Realname string `gorm:"column:realname;type:character varying(128);not null" json:"realname"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;default:now()" json:"updated_at"` + Status consts.PayoutAccountStatus `gorm:"column:status;type:character varying(32);not null;default:pending;comment:结算账户审核状态;用途:控制提现账户可用性;默认 pending。" json:"status"` // 结算账户审核状态;用途:控制提现账户可用性;默认 pending。 + ReviewedBy int64 `gorm:"column:reviewed_by;type:bigint;not null;comment:结算账户审核操作者ID;用途:审计追踪;默认 0 表示未审核。" json:"reviewed_by"` // 结算账户审核操作者ID;用途:审计追踪;默认 0 表示未审核。 + ReviewedAt time.Time `gorm:"column:reviewed_at;type:timestamp with time zone;comment:结算账户审核时间;用途:记录审核完成时间;未审核为空。" json:"reviewed_at"` // 结算账户审核时间;用途:记录审核完成时间;未审核为空。 + ReviewReason string `gorm:"column:review_reason;type:character varying(255);not null;comment:结算账户审核说明;用途:驳回原因或备注;默认空字符串。" json:"review_reason"` // 结算账户审核说明;用途:驳回原因或备注;默认空字符串。 } // Quick operations without importing query package diff --git a/backend/database/models/payout_accounts.query.gen.go b/backend/database/models/payout_accounts.query.gen.go index 01caa3e..a078a39 100644 --- a/backend/database/models/payout_accounts.query.gen.go +++ b/backend/database/models/payout_accounts.query.gen.go @@ -28,12 +28,16 @@ func newPayoutAccount(db *gorm.DB, opts ...gen.DOOption) payoutAccountQuery { _payoutAccountQuery.ID = field.NewInt64(tableName, "id") _payoutAccountQuery.TenantID = field.NewInt64(tableName, "tenant_id") _payoutAccountQuery.UserID = field.NewInt64(tableName, "user_id") - _payoutAccountQuery.Type = field.NewString(tableName, "type") + _payoutAccountQuery.Type = field.NewField(tableName, "type") _payoutAccountQuery.Name = field.NewString(tableName, "name") _payoutAccountQuery.Account = field.NewString(tableName, "account") _payoutAccountQuery.Realname = field.NewString(tableName, "realname") _payoutAccountQuery.CreatedAt = field.NewTime(tableName, "created_at") _payoutAccountQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + _payoutAccountQuery.Status = field.NewField(tableName, "status") + _payoutAccountQuery.ReviewedBy = field.NewInt64(tableName, "reviewed_by") + _payoutAccountQuery.ReviewedAt = field.NewTime(tableName, "reviewed_at") + _payoutAccountQuery.ReviewReason = field.NewString(tableName, "review_reason") _payoutAccountQuery.fillFieldMap() @@ -43,16 +47,20 @@ func newPayoutAccount(db *gorm.DB, opts ...gen.DOOption) payoutAccountQuery { type payoutAccountQuery struct { payoutAccountQueryDo payoutAccountQueryDo - ALL field.Asterisk - ID field.Int64 - TenantID field.Int64 - UserID field.Int64 - Type field.String - Name field.String - Account field.String - Realname field.String - CreatedAt field.Time - UpdatedAt field.Time + ALL field.Asterisk + ID field.Int64 + TenantID field.Int64 + UserID field.Int64 + Type field.Field + Name field.String + Account field.String + Realname field.String + CreatedAt field.Time + UpdatedAt field.Time + Status field.Field // 结算账户审核状态;用途:控制提现账户可用性;默认 pending。 + ReviewedBy field.Int64 // 结算账户审核操作者ID;用途:审计追踪;默认 0 表示未审核。 + ReviewedAt field.Time // 结算账户审核时间;用途:记录审核完成时间;未审核为空。 + ReviewReason field.String // 结算账户审核说明;用途:驳回原因或备注;默认空字符串。 fieldMap map[string]field.Expr } @@ -72,12 +80,16 @@ func (p *payoutAccountQuery) updateTableName(table string) *payoutAccountQuery { p.ID = field.NewInt64(table, "id") p.TenantID = field.NewInt64(table, "tenant_id") p.UserID = field.NewInt64(table, "user_id") - p.Type = field.NewString(table, "type") + p.Type = field.NewField(table, "type") p.Name = field.NewString(table, "name") p.Account = field.NewString(table, "account") p.Realname = field.NewString(table, "realname") p.CreatedAt = field.NewTime(table, "created_at") p.UpdatedAt = field.NewTime(table, "updated_at") + p.Status = field.NewField(table, "status") + p.ReviewedBy = field.NewInt64(table, "reviewed_by") + p.ReviewedAt = field.NewTime(table, "reviewed_at") + p.ReviewReason = field.NewString(table, "review_reason") p.fillFieldMap() @@ -110,7 +122,7 @@ func (p *payoutAccountQuery) GetFieldByName(fieldName string) (field.OrderExpr, } func (p *payoutAccountQuery) fillFieldMap() { - p.fieldMap = make(map[string]field.Expr, 9) + p.fieldMap = make(map[string]field.Expr, 13) p.fieldMap["id"] = p.ID p.fieldMap["tenant_id"] = p.TenantID p.fieldMap["user_id"] = p.UserID @@ -120,6 +132,10 @@ func (p *payoutAccountQuery) fillFieldMap() { p.fieldMap["realname"] = p.Realname p.fieldMap["created_at"] = p.CreatedAt p.fieldMap["updated_at"] = p.UpdatedAt + p.fieldMap["status"] = p.Status + p.fieldMap["reviewed_by"] = p.ReviewedBy + p.fieldMap["reviewed_at"] = p.ReviewedAt + p.fieldMap["review_reason"] = p.ReviewReason } func (p payoutAccountQuery) clone(db *gorm.DB) payoutAccountQuery { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 520b7ea..ba7d37d 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -1594,6 +1594,48 @@ const docTemplate = `{ } } }, + "/super/v1/payout-accounts/{id}/review": { + "post": { + "description": "Review payout account across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Review payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Payout account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/reports/export": { "post": { "description": "Export platform report data", @@ -6278,6 +6320,19 @@ const docTemplate = `{ "OrderTypeWithdrawal" ] }, + "consts.PayoutAccountStatus": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ], + "x-enum-varnames": [ + "PayoutAccountStatusPending", + "PayoutAccountStatusApproved", + "PayoutAccountStatusRejected" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -7432,6 +7487,26 @@ const docTemplate = `{ "description": "Realname 收款人姓名。", "type": "string" }, + "review_reason": { + "description": "ReviewReason 审核说明/驳回原因。", + "type": "string" + }, + "reviewed_at": { + "description": "ReviewedAt 审核时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 审核状态(pending/approved/rejected)。", + "allOf": [ + { + "$ref": "#/definitions/consts.PayoutAccountStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 审核状态描述(用于展示)。", + "type": "string" + }, "type": { "description": "Type 账户类型(bank/alipay)。", "type": "string" @@ -9094,6 +9169,30 @@ const docTemplate = `{ "description": "Realname 收款人姓名。", "type": "string" }, + "review_reason": { + "description": "ReviewReason 审核说明/驳回原因。", + "type": "string" + }, + "reviewed_at": { + "description": "ReviewedAt 审核时间(RFC3339)。", + "type": "string" + }, + "reviewed_by": { + "description": "ReviewedBy 审核操作者ID。", + "type": "integer" + }, + "status": { + "description": "Status 审核状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.PayoutAccountStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 审核状态描述(用于展示)。", + "type": "string" + }, "tenant_code": { "description": "TenantCode 租户编码。", "type": "string" @@ -9124,6 +9223,26 @@ const docTemplate = `{ } } }, + "dto.SuperPayoutAccountReviewForm": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "reason": { + "description": "Reason 审核说明(驳回时必填)。", + "type": "string" + } + } + }, "dto.SuperReportExportForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 93e5dbb..73c7e97 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -1588,6 +1588,48 @@ } } }, + "/super/v1/payout-accounts/{id}/review": { + "post": { + "description": "Review payout account across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Finance" + ], + "summary": "Review payout account", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Payout account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperPayoutAccountReviewForm" + } + } + ], + "responses": { + "200": { + "description": "Reviewed", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/reports/export": { "post": { "description": "Export platform report data", @@ -6272,6 +6314,19 @@ "OrderTypeWithdrawal" ] }, + "consts.PayoutAccountStatus": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ], + "x-enum-varnames": [ + "PayoutAccountStatusPending", + "PayoutAccountStatusApproved", + "PayoutAccountStatusRejected" + ] + }, "consts.Role": { "type": "string", "enum": [ @@ -7426,6 +7481,26 @@ "description": "Realname 收款人姓名。", "type": "string" }, + "review_reason": { + "description": "ReviewReason 审核说明/驳回原因。", + "type": "string" + }, + "reviewed_at": { + "description": "ReviewedAt 审核时间(RFC3339)。", + "type": "string" + }, + "status": { + "description": "Status 审核状态(pending/approved/rejected)。", + "allOf": [ + { + "$ref": "#/definitions/consts.PayoutAccountStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 审核状态描述(用于展示)。", + "type": "string" + }, "type": { "description": "Type 账户类型(bank/alipay)。", "type": "string" @@ -9088,6 +9163,30 @@ "description": "Realname 收款人姓名。", "type": "string" }, + "review_reason": { + "description": "ReviewReason 审核说明/驳回原因。", + "type": "string" + }, + "reviewed_at": { + "description": "ReviewedAt 审核时间(RFC3339)。", + "type": "string" + }, + "reviewed_by": { + "description": "ReviewedBy 审核操作者ID。", + "type": "integer" + }, + "status": { + "description": "Status 审核状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.PayoutAccountStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 审核状态描述(用于展示)。", + "type": "string" + }, "tenant_code": { "description": "TenantCode 租户编码。", "type": "string" @@ -9118,6 +9217,26 @@ } } }, + "dto.SuperPayoutAccountReviewForm": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "description": "Action 审核动作(approve/reject)。", + "type": "string", + "enum": [ + "approve", + "reject" + ] + }, + "reason": { + "description": "Reason 审核说明(驳回时必填)。", + "type": "string" + } + } + }, "dto.SuperReportExportForm": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5bdd9fc..3be1495 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -118,6 +118,16 @@ definitions: - OrderTypeContentPurchase - OrderTypeRecharge - OrderTypeWithdrawal + consts.PayoutAccountStatus: + enum: + - pending + - approved + - rejected + type: string + x-enum-varnames: + - PayoutAccountStatusPending + - PayoutAccountStatusApproved + - PayoutAccountStatusRejected consts.Role: enum: - user @@ -940,6 +950,19 @@ definitions: realname: description: Realname 收款人姓名。 type: string + review_reason: + description: ReviewReason 审核说明/驳回原因。 + type: string + reviewed_at: + description: ReviewedAt 审核时间(RFC3339)。 + type: string + status: + allOf: + - $ref: '#/definitions/consts.PayoutAccountStatus' + description: Status 审核状态(pending/approved/rejected)。 + status_description: + description: StatusDescription 审核状态描述(用于展示)。 + type: string type: description: Type 账户类型(bank/alipay)。 type: string @@ -2100,6 +2123,22 @@ definitions: realname: description: Realname 收款人姓名。 type: string + review_reason: + description: ReviewReason 审核说明/驳回原因。 + type: string + reviewed_at: + description: ReviewedAt 审核时间(RFC3339)。 + type: string + reviewed_by: + description: ReviewedBy 审核操作者ID。 + type: integer + status: + allOf: + - $ref: '#/definitions/consts.PayoutAccountStatus' + description: Status 审核状态。 + status_description: + description: StatusDescription 审核状态描述(用于展示)。 + type: string tenant_code: description: TenantCode 租户编码。 type: string @@ -2122,6 +2161,20 @@ definitions: description: Username 用户名。 type: string type: object + dto.SuperPayoutAccountReviewForm: + properties: + action: + description: Action 审核动作(approve/reject)。 + enum: + - approve + - reject + type: string + reason: + description: Reason 审核说明(驳回时必填)。 + type: string + required: + - action + type: object dto.SuperReportExportForm: properties: end_at: @@ -4266,6 +4319,34 @@ paths: summary: Remove payout account tags: - Finance + /super/v1/payout-accounts/{id}/review: + post: + consumes: + - application/json + description: Review payout account across tenants + parameters: + - description: Payout account ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Review form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperPayoutAccountReviewForm' + produces: + - application/json + responses: + "200": + description: Reviewed + schema: + type: string + summary: Review payout account + tags: + - Finance /super/v1/reports/export: post: consumes: diff --git a/backend/pkg/consts/payout_account.gen.go b/backend/pkg/consts/payout_account.gen.go new file mode 100644 index 0000000..45f4ffe --- /dev/null +++ b/backend/pkg/consts/payout_account.gen.go @@ -0,0 +1,179 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: - +// Revision: - +// Build Date: - +// Built By: - + +package consts + +import ( + "database/sql/driver" + "errors" + "fmt" + "strings" +) + +const ( + // PayoutAccountStatusPending is a PayoutAccountStatus of type pending. + PayoutAccountStatusPending PayoutAccountStatus = "pending" + // PayoutAccountStatusApproved is a PayoutAccountStatus of type approved. + PayoutAccountStatusApproved PayoutAccountStatus = "approved" + // PayoutAccountStatusRejected is a PayoutAccountStatus of type rejected. + PayoutAccountStatusRejected PayoutAccountStatus = "rejected" +) + +var ErrInvalidPayoutAccountStatus = fmt.Errorf("not a valid PayoutAccountStatus, try [%s]", strings.Join(_PayoutAccountStatusNames, ", ")) + +var _PayoutAccountStatusNames = []string{ + string(PayoutAccountStatusPending), + string(PayoutAccountStatusApproved), + string(PayoutAccountStatusRejected), +} + +// PayoutAccountStatusNames returns a list of possible string values of PayoutAccountStatus. +func PayoutAccountStatusNames() []string { + tmp := make([]string, len(_PayoutAccountStatusNames)) + copy(tmp, _PayoutAccountStatusNames) + return tmp +} + +// PayoutAccountStatusValues returns a list of the values for PayoutAccountStatus +func PayoutAccountStatusValues() []PayoutAccountStatus { + return []PayoutAccountStatus{ + PayoutAccountStatusPending, + PayoutAccountStatusApproved, + PayoutAccountStatusRejected, + } +} + +// String implements the Stringer interface. +func (x PayoutAccountStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x PayoutAccountStatus) IsValid() bool { + _, err := ParsePayoutAccountStatus(string(x)) + return err == nil +} + +var _PayoutAccountStatusValue = map[string]PayoutAccountStatus{ + "pending": PayoutAccountStatusPending, + "approved": PayoutAccountStatusApproved, + "rejected": PayoutAccountStatusRejected, +} + +// ParsePayoutAccountStatus attempts to convert a string to a PayoutAccountStatus. +func ParsePayoutAccountStatus(name string) (PayoutAccountStatus, error) { + if x, ok := _PayoutAccountStatusValue[name]; ok { + return x, nil + } + return PayoutAccountStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidPayoutAccountStatus) +} + +var errPayoutAccountStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *PayoutAccountStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = PayoutAccountStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParsePayoutAccountStatus(v) + case []byte: + *x, err = ParsePayoutAccountStatus(string(v)) + case PayoutAccountStatus: + *x = v + case *PayoutAccountStatus: + if v == nil { + return errPayoutAccountStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errPayoutAccountStatusNilPtr + } + *x, err = ParsePayoutAccountStatus(*v) + default: + return errors.New("invalid type for PayoutAccountStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x PayoutAccountStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *PayoutAccountStatus) Set(val string) error { + v, err := ParsePayoutAccountStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *PayoutAccountStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *PayoutAccountStatus) Type() string { + return "PayoutAccountStatus" +} + +type NullPayoutAccountStatus struct { + PayoutAccountStatus PayoutAccountStatus + Valid bool +} + +func NewNullPayoutAccountStatus(val interface{}) (x NullPayoutAccountStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullPayoutAccountStatus) Scan(value interface{}) (err error) { + if value == nil { + x.PayoutAccountStatus, x.Valid = PayoutAccountStatus(""), false + return + } + + err = x.PayoutAccountStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullPayoutAccountStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.PayoutAccountStatus), nil +} + +type NullPayoutAccountStatusStr struct { + NullPayoutAccountStatus +} + +func NewNullPayoutAccountStatusStr(val interface{}) (x NullPayoutAccountStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullPayoutAccountStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.PayoutAccountStatus.String(), nil +} diff --git a/backend/pkg/consts/payout_account.go b/backend/pkg/consts/payout_account.go new file mode 100644 index 0000000..b4b7c18 --- /dev/null +++ b/backend/pkg/consts/payout_account.go @@ -0,0 +1,29 @@ +package consts + +import "quyun/v2/app/requests" + +// swagger:enum PayoutAccountStatus +// ENUM( pending, approved, rejected ) +type PayoutAccountStatus string + +func (t PayoutAccountStatus) Description() string { + switch t { + case PayoutAccountStatusPending: + return "待审核" + case PayoutAccountStatusApproved: + return "已通过" + case PayoutAccountStatusRejected: + return "已驳回" + default: + return "未知状态" + } +} + +func PayoutAccountStatusItems() []requests.KV { + values := PayoutAccountStatusValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} diff --git a/frontend/superadmin/src/service/CreatorService.js b/frontend/superadmin/src/service/CreatorService.js index 1691ced..1e5b4e8 100644 --- a/frontend/superadmin/src/service/CreatorService.js +++ b/frontend/superadmin/src/service/CreatorService.js @@ -110,7 +110,7 @@ export const CreatorService = { body: { action, reason } }); }, - async listPayoutAccounts({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, type, created_at_from, created_at_to } = {}) { + async listPayoutAccounts({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, type, status, created_at_from, created_at_to } = {}) { const iso = (d) => { if (!d) return undefined; const date = d instanceof Date ? d : new Date(d); @@ -127,6 +127,7 @@ export const CreatorService = { user_id, username, type, + status, created_at_from: iso(created_at_from), created_at_to: iso(created_at_to) }; @@ -143,6 +144,16 @@ export const CreatorService = { if (!accountID) throw new Error('accountID is required'); return requestJson(`/super/v1/payout-accounts/${accountID}`, { method: 'DELETE' }); }, + async reviewPayoutAccount(accountID, { action, reason } = {}) { + if (!accountID) throw new Error('accountID is required'); + return requestJson(`/super/v1/payout-accounts/${accountID}/review`, { + method: 'POST', + body: { + action, + reason + } + }); + }, async createInvite(tenantID, { max_uses, expires_at, remark } = {}) { if (!tenantID) throw new Error('tenantID is required'); return requestJson(`/super/v1/tenants/${tenantID}/invites`, { diff --git a/frontend/superadmin/src/views/superadmin/Creators.vue b/frontend/superadmin/src/views/superadmin/Creators.vue index d749db0..e2ba215 100644 --- a/frontend/superadmin/src/views/superadmin/Creators.vue +++ b/frontend/superadmin/src/views/superadmin/Creators.vue @@ -74,6 +74,7 @@ const payoutTenantName = ref(''); const payoutUserID = ref(null); const payoutUsername = ref(''); const payoutType = ref(''); +const payoutStatus = ref(''); const payoutCreatedAtFrom = ref(null); const payoutCreatedAtTo = ref(null); @@ -84,6 +85,18 @@ const joinStatusOptions = [ { label: '已驳回', value: 'rejected' } ]; +const payoutStatusOptions = [ + { label: '全部', value: '' }, + { label: '待审核', value: 'pending' }, + { label: '已通过', value: 'approved' }, + { label: '已驳回', value: 'rejected' } +]; + +const payoutReviewOptions = [ + { label: '通过', value: 'approve' }, + { label: '驳回', value: 'reject' } +]; + const reviewDialogVisible = ref(false); const reviewSubmitting = ref(false); const reviewAction = ref('approve'); @@ -100,6 +113,12 @@ const payoutRemoveDialogVisible = ref(false); const payoutRemoveSubmitting = ref(false); const payoutRemoveTarget = ref(null); +const payoutReviewDialogVisible = ref(false); +const payoutReviewSubmitting = ref(false); +const payoutReviewAction = ref('approve'); +const payoutReviewReason = ref(''); +const payoutReviewTarget = ref(null); + const inviteDialogVisible = ref(false); const inviteSubmitting = ref(false); const inviteTenantID = ref(null); @@ -140,6 +159,19 @@ function getJoinStatusSeverity(value) { } } +function getPayoutStatusSeverity(value) { + switch (value) { + case 'pending': + return 'warn'; + case 'approved': + return 'success'; + case 'rejected': + return 'danger'; + default: + return 'secondary'; + } +} + async function ensureStatusOptionsLoaded() { if (statusOptions.value.length > 0) return; statusOptionsLoading.value = true; @@ -240,6 +272,7 @@ async function loadPayoutAccounts() { user_id: payoutUserID.value || undefined, username: payoutUsername.value, type: payoutType.value || undefined, + status: payoutStatus.value || undefined, created_at_from: payoutCreatedAtFrom.value || undefined, created_at_to: payoutCreatedAtTo.value || undefined }); @@ -345,6 +378,7 @@ function onPayoutReset() { payoutUserID.value = null; payoutUsername.value = ''; payoutType.value = ''; + payoutStatus.value = ''; payoutCreatedAtFrom.value = null; payoutCreatedAtTo.value = null; payoutAccountsPage.value = 1; @@ -466,6 +500,35 @@ async function confirmRemovePayoutAccount() { } } +function openPayoutReviewDialog(row, action) { + payoutReviewTarget.value = row; + payoutReviewAction.value = action || 'approve'; + payoutReviewReason.value = ''; + payoutReviewDialogVisible.value = true; +} + +async function confirmPayoutReview() { + const targetID = payoutReviewTarget.value?.id; + if (!targetID) return; + const reason = payoutReviewReason.value.trim(); + if (payoutReviewAction.value === 'reject' && !reason) { + toast.add({ severity: 'warn', summary: '请输入原因', detail: '驳回时需填写原因', life: 3000 }); + return; + } + + payoutReviewSubmitting.value = true; + try { + await CreatorService.reviewPayoutAccount(targetID, { action: payoutReviewAction.value, reason }); + toast.add({ severity: 'success', summary: '审核完成', detail: `账户ID: ${targetID}`, life: 3000 }); + payoutReviewDialogVisible.value = false; + await loadPayoutAccounts(); + } catch (error) { + toast.add({ severity: 'error', summary: '审核失败', detail: error?.message || '无法审核结算账户', life: 4000 }); + } finally { + payoutReviewSubmitting.value = false; + } +} + function openInviteDialog(row) { inviteTenantID.value = Number(row?.tenant_id ?? row?.tenant?.id ?? 0) || null; inviteMaxUses.value = 1; @@ -826,6 +889,9 @@ onMounted(() => { + + + +
+ + +
+ + + +