feat: add payout account review flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user