feat: add payout account review flow

This commit is contained in:
2026-01-16 15:17:43 +08:00
parent daaacc3fa4
commit 028c462eaa
21 changed files with 1100 additions and 151 deletions

View File

@@ -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"`
}

View File

@@ -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<int>/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)
}

View File

@@ -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<int>/review -> payoutAccounts.Review")
router.Post("/super/v1/payout-accounts/:id<int>/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(

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
})
})
}