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

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