add host withdrawal admin list
This commit is contained in:
parent
40b8b6a8ea
commit
0b846f9069
@ -0,0 +1,12 @@
|
||||
USE hyapp_wallet;
|
||||
|
||||
-- 主播提现后台页会用来源工资用户反查币商侧 COIN_SELLER_COIN 分录;
|
||||
-- counterparty_user_id 是工资来源用户,组合索引避免按资产时间大范围扫描后再过滤来源用户。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_counterparty_time') = 0,
|
||||
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -50,6 +50,7 @@ import (
|
||||
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||
hostsalarysettlementmodule "hyapp-admin-server/internal/modules/hostsalarysettlement"
|
||||
hostwithdrawalmodule "hyapp-admin-server/internal/modules/hostwithdrawal"
|
||||
inviteactivityrewardmodule "hyapp-admin-server/internal/modules/inviteactivityreward"
|
||||
jobmodule "hyapp-admin-server/internal/modules/job"
|
||||
levelconfigmodule "hyapp-admin-server/internal/modules/levelconfig"
|
||||
@ -320,6 +321,7 @@ func main() {
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
HostSalarySettlement: hostsalarysettlementmodule.New(walletDB, userDB, walletv1.NewWalletCronServiceClient(walletConn), cfg.WalletService.RequestTimeout, auditHandler),
|
||||
HostWithdrawal: hostwithdrawalmodule.New(sqlDB, walletDB, userDB),
|
||||
InviteActivityReward: inviteactivityrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
|
||||
|
||||
34
server/admin/internal/modules/hostwithdrawal/dto.go
Normal file
34
server/admin/internal/modules/hostwithdrawal/dto.go
Normal file
@ -0,0 +1,34 @@
|
||||
package hostwithdrawal
|
||||
|
||||
type userDTO struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
type itemDTO struct {
|
||||
ID string `json:"id"`
|
||||
RecordType string `json:"recordType"`
|
||||
Identity string `json:"identity"`
|
||||
SalaryAssetType string `json:"salaryAssetType"`
|
||||
SourceUserID string `json:"sourceUserId"`
|
||||
SourceUser userDTO `json:"sourceUser"`
|
||||
SellerUserID string `json:"sellerUserId"`
|
||||
SellerUser userDTO `json:"sellerUser"`
|
||||
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||
CoinAmount int64 `json:"coinAmount"`
|
||||
Status string `json:"status"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
CommandID string `json:"commandId"`
|
||||
ApplicationID string `json:"applicationId"`
|
||||
FreezeTransactionID string `json:"freezeTransactionId"`
|
||||
AuditTransactionID string `json:"auditTransactionId"`
|
||||
WithdrawMethod string `json:"withdrawMethod"`
|
||||
WithdrawAddress string `json:"withdrawAddress"`
|
||||
AuditRemark string `json:"auditRemark"`
|
||||
AuditImageURL string `json:"auditImageUrl"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
86
server/admin/internal/modules/hostwithdrawal/handler.go
Normal file
86
server/admin/internal/modules/hostwithdrawal/handler.go
Normal file
@ -0,0 +1,86 @@
|
||||
package hostwithdrawal
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Handler {
|
||||
return &Handler{service: NewService(adminDB, walletDB, userDB)}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
query, ok := parseListQuery(c, appctx.FromContext(c.Request.Context()))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, total, err := h.service.List(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInvalidArgument) {
|
||||
response.BadRequest(c, "主播提现筛选参数不正确")
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "获取主播提现列表失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||
}
|
||||
|
||||
func parseListQuery(c *gin.Context, defaultAppCode string) (listQuery, bool) {
|
||||
options := shared.ListOptions(c)
|
||||
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
|
||||
if !ok {
|
||||
response.BadRequest(c, "开始时间不正确")
|
||||
return listQuery{}, false
|
||||
}
|
||||
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
|
||||
if !ok {
|
||||
response.BadRequest(c, "结束时间不正确")
|
||||
return listQuery{}, false
|
||||
}
|
||||
query, err := normalizeListQuery(listQuery{
|
||||
Page: options.Page,
|
||||
PageSize: options.PageSize,
|
||||
AppCode: firstNonEmpty(c.Query("app_code"), c.Query("appCode"), defaultAppCode),
|
||||
RecordType: firstNonEmpty(c.Query("record_type"), c.Query("recordType")),
|
||||
Identity: firstNonEmpty(c.Query("identity"), c.Query("salary_asset_type"), c.Query("salaryAssetType")),
|
||||
Status: options.Status,
|
||||
Keyword: options.Keyword,
|
||||
StartAtMS: startAtMS,
|
||||
EndAtMS: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "主播提现筛选参数不正确")
|
||||
return listQuery{}, false
|
||||
}
|
||||
return query, true
|
||||
}
|
||||
|
||||
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
|
||||
value := strings.TrimSpace(firstNonEmpty(queryValues(c, keys...)...))
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
return parsed, err == nil && parsed >= 0
|
||||
}
|
||||
|
||||
func queryValues(c *gin.Context, keys ...string) []string {
|
||||
values := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
values = append(values, c.Query(key))
|
||||
}
|
||||
return values
|
||||
}
|
||||
119
server/admin/internal/modules/hostwithdrawal/request.go
Normal file
119
server/admin/internal/modules/hostwithdrawal/request.go
Normal file
@ -0,0 +1,119 @@
|
||||
package hostwithdrawal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
recordTypeSalaryTransfer = "salary_transfer"
|
||||
recordTypeWithdrawal = "withdrawal"
|
||||
|
||||
statusCompleted = "completed"
|
||||
statusPending = "pending"
|
||||
statusApproved = "approved"
|
||||
statusRejected = "rejected"
|
||||
|
||||
assetCoinSellerCoin = "COIN_SELLER_COIN"
|
||||
bizSalaryTransfer = "salary_transfer_to_coin_seller"
|
||||
transactionSucceeded = "succeeded"
|
||||
|
||||
assetHostSalary = "HOST_SALARY_USD"
|
||||
assetAgencySalary = "AGENCY_SALARY_USD"
|
||||
assetBDSalary = "BD_SALARY_USD"
|
||||
assetAdminSalary = "ADMIN_SALARY_USD"
|
||||
)
|
||||
|
||||
var errInvalidArgument = errors.New("invalid argument")
|
||||
|
||||
type listQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
AppCode string
|
||||
RecordType string
|
||||
Identity string
|
||||
SalaryAssetType string
|
||||
Status string
|
||||
Keyword string
|
||||
StartAtMS int64
|
||||
EndAtMS int64
|
||||
}
|
||||
|
||||
func normalizeListQuery(query listQuery) (listQuery, error) {
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize < 1 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
if query.PageSize > 100 {
|
||||
query.PageSize = 100
|
||||
}
|
||||
query.AppCode = strings.ToLower(strings.TrimSpace(query.AppCode))
|
||||
query.RecordType = strings.TrimSpace(query.RecordType)
|
||||
query.Identity = strings.TrimSpace(query.Identity)
|
||||
query.Status = strings.TrimSpace(query.Status)
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
if query.StartAtMS > 0 && query.EndAtMS > 0 && query.StartAtMS >= query.EndAtMS {
|
||||
return listQuery{}, errInvalidArgument
|
||||
}
|
||||
|
||||
switch strings.ToLower(query.RecordType) {
|
||||
case "", "all":
|
||||
query.RecordType = ""
|
||||
case recordTypeSalaryTransfer:
|
||||
query.RecordType = recordTypeSalaryTransfer
|
||||
case recordTypeWithdrawal:
|
||||
query.RecordType = recordTypeWithdrawal
|
||||
default:
|
||||
return listQuery{}, errInvalidArgument
|
||||
}
|
||||
|
||||
salaryAssetType, identity, err := normalizeIdentity(query.Identity)
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
query.SalaryAssetType = salaryAssetType
|
||||
query.Identity = identity
|
||||
|
||||
switch strings.ToLower(query.Status) {
|
||||
case "", "all":
|
||||
query.Status = ""
|
||||
case statusCompleted:
|
||||
query.Status = statusCompleted
|
||||
case statusPending:
|
||||
query.Status = statusPending
|
||||
case statusApproved:
|
||||
query.Status = statusApproved
|
||||
case statusRejected:
|
||||
query.Status = statusRejected
|
||||
default:
|
||||
return listQuery{}, errInvalidArgument
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func normalizeIdentity(value string) (string, string, error) {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "", "ALL":
|
||||
return "", "", nil
|
||||
case "HOST", assetHostSalary:
|
||||
return assetHostSalary, "host", nil
|
||||
case "AGENCY", assetAgencySalary:
|
||||
return assetAgencySalary, "agency", nil
|
||||
case "BD", assetBDSalary:
|
||||
return assetBDSalary, "bd", nil
|
||||
case "ADMIN", "BD_LEADER", "BD_LEADER_ADMIN", assetAdminSalary:
|
||||
return assetAdminSalary, "bd_leader_admin", nil
|
||||
default:
|
||||
return "", "", errInvalidArgument
|
||||
}
|
||||
}
|
||||
|
||||
func identityFromAsset(assetType string) string {
|
||||
_, identity, err := normalizeIdentity(assetType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return identity
|
||||
}
|
||||
15
server/admin/internal/modules/hostwithdrawal/routes.go
Normal file
15
server/admin/internal/modules/hostwithdrawal/routes.go
Normal file
@ -0,0 +1,15 @@
|
||||
package hostwithdrawal
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
protected.GET("/admin/host-withdrawals", middleware.RequirePermission("host-withdrawal:view"), h.List)
|
||||
}
|
||||
603
server/admin/internal/modules/hostwithdrawal/service.go
Normal file
603
server/admin/internal/modules/hostwithdrawal/service.go
Normal file
@ -0,0 +1,603 @@
|
||||
package hostwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
adminDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
userDB *sql.DB
|
||||
}
|
||||
|
||||
type userProfile struct {
|
||||
UserID int64
|
||||
DisplayUserID string
|
||||
Username string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
|
||||
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, query listQuery) ([]itemDTO, int64, error) {
|
||||
var err error
|
||||
query, err = normalizeListQuery(query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if s == nil || s.adminDB == nil || s.walletDB == nil {
|
||||
return nil, 0, fmt.Errorf("host withdrawal storage is not configured")
|
||||
}
|
||||
|
||||
keywordUserIDs, err := s.resolveKeywordUserIDs(ctx, query.AppCode, query.Keyword)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
fetchLimit := offset(query.Page, query.PageSize) + query.PageSize
|
||||
|
||||
items := make([]itemDTO, 0, fetchLimit)
|
||||
total := int64(0)
|
||||
if query.RecordType == "" || query.RecordType == recordTypeSalaryTransfer {
|
||||
// 工资转币商只展示已完成账本事实;pending/approved/rejected 属于提现申请状态,不能误筛到钱包转账。
|
||||
if query.Status == "" || query.Status == statusCompleted {
|
||||
count, err := s.countSalaryTransfers(ctx, query, keywordUserIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total += count
|
||||
records, err := s.listSalaryTransfers(ctx, query, keywordUserIDs, fetchLimit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, records...)
|
||||
}
|
||||
}
|
||||
if query.RecordType == "" || query.RecordType == recordTypeWithdrawal {
|
||||
// 提现申请状态来自后台申请表;completed 只代表钱包转币商完成态,所以提现分支直接跳过。
|
||||
if query.Status != statusCompleted {
|
||||
count, err := s.countWithdrawalApplications(ctx, query, keywordUserIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total += count
|
||||
records, err := s.listWithdrawalApplications(ctx, query, keywordUserIDs, fetchLimit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, records...)
|
||||
}
|
||||
}
|
||||
|
||||
enriched, err := s.enrichUsers(ctx, query.AppCode, items)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return pageMergedItems(enriched, query.Page, query.PageSize), total, nil
|
||||
}
|
||||
|
||||
func (s *Service) countSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
|
||||
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
|
||||
var total int64
|
||||
err := s.walletDB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM wallet_entries e
|
||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||
`+whereSQL,
|
||||
args...,
|
||||
).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Service) listSalaryTransfers(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
whereSQL, args := salaryTransferWhere(query, keywordUserIDs)
|
||||
rows, err := s.walletDB.QueryContext(ctx, `
|
||||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, e.counterparty_user_id,
|
||||
e.available_delta, e.available_after, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||||
FROM wallet_entries e
|
||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||
`+whereSQL+`
|
||||
ORDER BY e.created_at_ms DESC, e.entry_id DESC
|
||||
LIMIT ?`,
|
||||
append(args, limit)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]itemDTO, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
transactionID string
|
||||
commandID string
|
||||
sellerUserID int64
|
||||
sourceUserID int64
|
||||
availableDelta int64
|
||||
sellerBalanceAfter int64
|
||||
metadataJSON string
|
||||
createdAtMS int64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &transactionID, &commandID, &sellerUserID, &sourceUserID, &availableDelta, &sellerBalanceAfter, &metadataJSON, &createdAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, err := parseMetadataJSON(metadataJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value := metadataInt64(metadata, "source_user_id", "sourceUserId"); value > 0 {
|
||||
sourceUserID = value
|
||||
}
|
||||
if value := metadataInt64(metadata, "seller_user_id", "sellerUserId"); value > 0 {
|
||||
sellerUserID = value
|
||||
}
|
||||
assetType := firstNonEmpty(metadataString(metadata, "salary_asset_type"), query.SalaryAssetType)
|
||||
coinAmount := metadataInt64(metadata, "coin_amount", "coinAmount")
|
||||
if coinAmount == 0 {
|
||||
coinAmount = absInt64(availableDelta)
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
metadata["seller_balance_after"] = sellerBalanceAfter
|
||||
items = append(items, itemDTO{
|
||||
ID: fmt.Sprintf("%s:%d", recordTypeSalaryTransfer, entryID),
|
||||
RecordType: recordTypeSalaryTransfer,
|
||||
Identity: identityFromAsset(assetType),
|
||||
SalaryAssetType: assetType,
|
||||
SourceUserID: formatOptionalID(sourceUserID),
|
||||
SourceUser: userDTO{UserID: formatOptionalID(sourceUserID)},
|
||||
SellerUserID: formatOptionalID(sellerUserID),
|
||||
SellerUser: userDTO{UserID: formatOptionalID(sellerUserID)},
|
||||
USDMinorAmount: metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor"),
|
||||
CoinAmount: coinAmount,
|
||||
Status: statusCompleted,
|
||||
TransactionID: transactionID,
|
||||
CommandID: commandID,
|
||||
Metadata: metadata,
|
||||
CreatedAtMS: createdAtMS,
|
||||
UpdatedAtMS: createdAtMS,
|
||||
})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) countWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64) (int64, error) {
|
||||
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
|
||||
var total int64
|
||||
err := s.adminDB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM admin_user_withdrawal_applications a
|
||||
`+whereSQL,
|
||||
args...,
|
||||
).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Service) listWithdrawalApplications(ctx context.Context, query listQuery, keywordUserIDs []int64, limit int) ([]itemDTO, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
whereSQL, args := withdrawalWhere(query, keywordUserIDs)
|
||||
rows, err := s.adminDB.QueryContext(ctx, `
|
||||
SELECT id, user_id, salary_asset_type, withdraw_amount_minor, withdraw_method, withdraw_address,
|
||||
freeze_command_id, freeze_transaction_id, audit_command_id, audit_transaction_id,
|
||||
status, COALESCE(audit_remark, ''), audit_image_url, created_at_ms, updated_at_ms
|
||||
FROM admin_user_withdrawal_applications a
|
||||
`+whereSQL+`
|
||||
ORDER BY a.created_at_ms DESC, a.id DESC
|
||||
LIMIT ?`,
|
||||
append(args, limit)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]itemDTO, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
userIDText string
|
||||
assetType string
|
||||
amountMinor int64
|
||||
withdrawMethod string
|
||||
withdrawAddress string
|
||||
freezeCommandID string
|
||||
freezeTransactionID string
|
||||
auditCommandID string
|
||||
auditTransactionID string
|
||||
status string
|
||||
auditRemark string
|
||||
auditImageURL string
|
||||
createdAtMS int64
|
||||
updatedAtMS int64
|
||||
)
|
||||
if err := rows.Scan(&id, &userIDText, &assetType, &amountMinor, &withdrawMethod, &withdrawAddress, &freezeCommandID, &freezeTransactionID, &auditCommandID, &auditTransactionID, &status, &auditRemark, &auditImageURL, &createdAtMS, &updatedAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceUserID := parseInt64OrZero(userIDText)
|
||||
items = append(items, itemDTO{
|
||||
ID: fmt.Sprintf("%s:%d", recordTypeWithdrawal, id),
|
||||
RecordType: recordTypeWithdrawal,
|
||||
Identity: identityFromAsset(assetType),
|
||||
SalaryAssetType: assetType,
|
||||
SourceUserID: formatOptionalID(sourceUserID),
|
||||
SourceUser: userDTO{UserID: firstNonEmpty(formatOptionalID(sourceUserID), userIDText)},
|
||||
USDMinorAmount: amountMinor,
|
||||
Status: status,
|
||||
CommandID: firstNonEmpty(auditCommandID, freezeCommandID),
|
||||
ApplicationID: strconv.FormatInt(id, 10),
|
||||
FreezeTransactionID: freezeTransactionID,
|
||||
AuditTransactionID: auditTransactionID,
|
||||
WithdrawMethod: withdrawMethod,
|
||||
WithdrawAddress: withdrawAddress,
|
||||
AuditRemark: auditRemark,
|
||||
AuditImageURL: auditImageURL,
|
||||
Metadata: map[string]any{},
|
||||
CreatedAtMS: createdAtMS,
|
||||
UpdatedAtMS: updatedAtMS,
|
||||
})
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func salaryTransferWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
|
||||
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ?"
|
||||
args := []any{query.AppCode, assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded}
|
||||
if query.SalaryAssetType != "" {
|
||||
// 身份维度是工资资产类型,来源写在交易 metadata 中;只筛工资转币商事实,不触碰普通币商出货或后台进货。
|
||||
where += " AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ?"
|
||||
args = append(args, query.SalaryAssetType)
|
||||
}
|
||||
if query.StartAtMS > 0 {
|
||||
where += " AND e.created_at_ms >= ?"
|
||||
args = append(args, query.StartAtMS)
|
||||
}
|
||||
if query.EndAtMS > 0 {
|
||||
where += " AND e.created_at_ms < ?"
|
||||
args = append(args, query.EndAtMS)
|
||||
}
|
||||
if keywordSQL, keywordArgs := salaryTransferKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
|
||||
where += " AND (" + keywordSQL + ")"
|
||||
args = append(args, keywordArgs...)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func salaryTransferKeywordSQL(keyword string, userIDs []int64) (string, []any) {
|
||||
clauses := make([]string, 0, 4)
|
||||
args := make([]any, 0)
|
||||
if len(userIDs) > 0 {
|
||||
clauses = append(clauses, "e.user_id IN ("+placeholders(len(userIDs))+")")
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
clauses = append(clauses, "e.counterparty_user_id IN ("+placeholders(len(userIDs))+")")
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
}
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
clauses = append(clauses, "e.transaction_id LIKE ?", "wt.command_id LIKE ?", "CAST(e.user_id AS CHAR) LIKE ?", "CAST(e.counterparty_user_id AS CHAR) LIKE ?")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
return strings.Join(clauses, " OR "), args
|
||||
}
|
||||
|
||||
func withdrawalWhere(query listQuery, keywordUserIDs []int64) (string, []any) {
|
||||
where := "WHERE a.app_code = ?"
|
||||
args := []any{query.AppCode}
|
||||
if query.SalaryAssetType != "" {
|
||||
where += " AND a.salary_asset_type = ?"
|
||||
args = append(args, query.SalaryAssetType)
|
||||
}
|
||||
if query.Status != "" {
|
||||
where += " AND a.status = ?"
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.StartAtMS > 0 {
|
||||
where += " AND a.created_at_ms >= ?"
|
||||
args = append(args, query.StartAtMS)
|
||||
}
|
||||
if query.EndAtMS > 0 {
|
||||
where += " AND a.created_at_ms < ?"
|
||||
args = append(args, query.EndAtMS)
|
||||
}
|
||||
if keywordSQL, keywordArgs := withdrawalKeywordSQL(query.Keyword, keywordUserIDs); keywordSQL != "" {
|
||||
where += " AND (" + keywordSQL + ")"
|
||||
args = append(args, keywordArgs...)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func withdrawalKeywordSQL(keyword string, userIDs []int64) (string, []any) {
|
||||
clauses := make([]string, 0, 8)
|
||||
args := make([]any, 0)
|
||||
if len(userIDs) > 0 {
|
||||
clauses = append(clauses, "a.user_id IN ("+placeholders(len(userIDs))+")")
|
||||
for _, id := range userIDs {
|
||||
args = append(args, strconv.FormatInt(id, 10))
|
||||
}
|
||||
}
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
clauses = append(clauses,
|
||||
"CAST(a.id AS CHAR) LIKE ?",
|
||||
"a.user_id LIKE ?",
|
||||
"a.withdraw_method LIKE ?",
|
||||
"a.withdraw_address LIKE ?",
|
||||
"a.freeze_transaction_id LIKE ?",
|
||||
"a.audit_transaction_id LIKE ?",
|
||||
"a.approver_name LIKE ?",
|
||||
)
|
||||
args = append(args, like, like, like, like, like, like, like)
|
||||
}
|
||||
return strings.Join(clauses, " OR "), args
|
||||
}
|
||||
|
||||
func (s *Service) resolveKeywordUserIDs(ctx context.Context, appCode string, keyword string) ([]int64, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return nil, nil
|
||||
}
|
||||
userIDs := make([]int64, 0, 4)
|
||||
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
|
||||
// 账务事实可能早于用户资料补全;数字关键字直接加入候选,避免资料缺失吞掉真实流水。
|
||||
userIDs = append(userIDs, numeric)
|
||||
}
|
||||
if s == nil || s.userDB == nil {
|
||||
return positiveUniqueInt64s(userIDs), nil
|
||||
}
|
||||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
|
||||
args := append([]any{appCode}, matchArgs...)
|
||||
rows, err := s.userDB.QueryContext(ctx, `
|
||||
SELECT u.user_id
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND `+matchSQL+`
|
||||
ORDER BY u.updated_at_ms DESC, u.user_id DESC
|
||||
LIMIT 1000`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
return positiveUniqueInt64s(userIDs), rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) enrichUsers(ctx context.Context, appCode string, items []itemDTO) ([]itemDTO, error) {
|
||||
userIDs := make([]int64, 0, len(items)*2)
|
||||
for _, item := range items {
|
||||
if id := parseInt64OrZero(item.SourceUserID); id > 0 {
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
if id := parseInt64OrZero(item.SellerUserID); id > 0 {
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
profiles, err := s.userProfiles(ctx, appCode, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]itemDTO(nil), items...)
|
||||
for i := range out {
|
||||
if id := parseInt64OrZero(out[i].SourceUserID); id > 0 {
|
||||
if profile, ok := profiles[id]; ok {
|
||||
out[i].SourceUser = userDTOFromProfile(profile)
|
||||
}
|
||||
}
|
||||
if id := parseInt64OrZero(out[i].SellerUserID); id > 0 {
|
||||
if profile, ok := profiles[id]; ok {
|
||||
out[i].SellerUser = userDTOFromProfile(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
|
||||
result := make(map[int64]userProfile, len(userIDs))
|
||||
if s == nil || s.userDB == nil || len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
userIDs = positiveUniqueInt64s(userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, appCode)
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id IN (%s)`,
|
||||
placeholders(len(userIDs)),
|
||||
), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var profile userProfile
|
||||
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[profile.UserID] = profile
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func userDTOFromProfile(profile userProfile) userDTO {
|
||||
return userDTO{
|
||||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||||
DisplayUserID: profile.DisplayUserID,
|
||||
Username: profile.Username,
|
||||
Avatar: profile.Avatar,
|
||||
}
|
||||
}
|
||||
|
||||
func pageMergedItems(items []itemDTO, page int, pageSize int) []itemDTO {
|
||||
sort.SliceStable(items, func(i int, j int) bool {
|
||||
if items[i].CreatedAtMS != items[j].CreatedAtMS {
|
||||
return items[i].CreatedAtMS > items[j].CreatedAtMS
|
||||
}
|
||||
if items[i].RecordType != items[j].RecordType {
|
||||
return items[i].RecordType < items[j].RecordType
|
||||
}
|
||||
return recordNumericID(items[i]) > recordNumericID(items[j])
|
||||
})
|
||||
start := offset(page, pageSize)
|
||||
if start >= len(items) {
|
||||
return []itemDTO{}
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
return items[start:end]
|
||||
}
|
||||
|
||||
func recordNumericID(item itemDTO) int64 {
|
||||
if item.RecordType == recordTypeWithdrawal {
|
||||
return parseInt64OrZero(item.ApplicationID)
|
||||
}
|
||||
parts := strings.Split(item.ID, ":")
|
||||
if len(parts) == 2 {
|
||||
return parseInt64OrZero(parts[1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseMetadataJSON(value string) (map[string]any, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "null" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metadata == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func metadataString(metadata map[string]any, key string) string {
|
||||
value, ok := metadata[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if typed, ok := value.(string); ok {
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func metadataInt64(metadata map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
value, ok := metadata[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case int:
|
||||
return int64(typed)
|
||||
case string:
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatOptionalID(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func parseInt64OrZero(value string) int64 {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func absInt64(value int64) int64 {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func offset(page int, pageSize int) int {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||||
}
|
||||
|
||||
func positiveUniqueInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
141
server/admin/internal/modules/hostwithdrawal/service_test.go
Normal file
141
server/admin/internal/modules/hostwithdrawal/service_test.go
Normal file
@ -0,0 +1,141 @@
|
||||
package hostwithdrawal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeListQueryMapsIdentityAndRejectsBadInput(t *testing.T) {
|
||||
query, err := normalizeListQuery(listQuery{
|
||||
Page: -1,
|
||||
PageSize: 1000,
|
||||
AppCode: " LaLu ",
|
||||
RecordType: "all",
|
||||
Identity: "BD_LEADER",
|
||||
Status: "all",
|
||||
Keyword: " 168557 ",
|
||||
StartAtMS: 100,
|
||||
EndAtMS: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalize query failed: %v", err)
|
||||
}
|
||||
if query.Page != 1 || query.PageSize != 100 || query.AppCode != "lalu" || query.RecordType != "" || query.Status != "" || query.Keyword != "168557" {
|
||||
t.Fatalf("normalized basic fields mismatch: %+v", query)
|
||||
}
|
||||
if query.SalaryAssetType != assetAdminSalary || query.Identity != "bd_leader_admin" {
|
||||
t.Fatalf("identity mapping mismatch: %+v", query)
|
||||
}
|
||||
|
||||
if _, err := normalizeListQuery(listQuery{Status: "done"}); err == nil {
|
||||
t.Fatal("expected invalid status to fail")
|
||||
}
|
||||
if _, err := normalizeListQuery(listQuery{StartAtMS: 200, EndAtMS: 200}); err == nil {
|
||||
t.Fatal("expected closed time range to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalaryTransferWhereUsesIdentityStatusTimeAndKeywordUsers(t *testing.T) {
|
||||
where, args := salaryTransferWhere(listQuery{
|
||||
AppCode: "lalu",
|
||||
SalaryAssetType: assetHostSalary,
|
||||
Keyword: "cmd",
|
||||
StartAtMS: 100,
|
||||
EndAtMS: 200,
|
||||
}, []int64{11, 22})
|
||||
|
||||
wantWhere := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ? AND wt.status = ? AND JSON_UNQUOTE(JSON_EXTRACT(wt.metadata_json, '$.salary_asset_type')) = ? AND e.created_at_ms >= ? AND e.created_at_ms < ? AND (e.user_id IN (?,?) OR e.counterparty_user_id IN (?,?) OR e.transaction_id LIKE ? OR wt.command_id LIKE ? OR CAST(e.user_id AS CHAR) LIKE ? OR CAST(e.counterparty_user_id AS CHAR) LIKE ?)"
|
||||
if where != wantWhere {
|
||||
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
|
||||
}
|
||||
wantArgs := []any{"lalu", assetCoinSellerCoin, bizSalaryTransfer, transactionSucceeded, assetHostSalary, int64(100), int64(200), int64(11), int64(22), int64(11), int64(22), "%cmd%", "%cmd%", "%cmd%", "%cmd%"}
|
||||
if !reflect.DeepEqual(args, wantArgs) {
|
||||
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalWhereUsesStatusIdentityTimeAndKeywordUsers(t *testing.T) {
|
||||
where, args := withdrawalWhere(listQuery{
|
||||
AppCode: "lalu",
|
||||
SalaryAssetType: assetBDSalary,
|
||||
Status: statusPending,
|
||||
Keyword: "upi",
|
||||
StartAtMS: 100,
|
||||
EndAtMS: 200,
|
||||
}, []int64{77})
|
||||
|
||||
wantWhere := "WHERE a.app_code = ? AND a.salary_asset_type = ? AND a.status = ? AND a.created_at_ms >= ? AND a.created_at_ms < ? AND (a.user_id IN (?) OR CAST(a.id AS CHAR) LIKE ? OR a.user_id LIKE ? OR a.withdraw_method LIKE ? OR a.withdraw_address LIKE ? OR a.freeze_transaction_id LIKE ? OR a.audit_transaction_id LIKE ? OR a.approver_name LIKE ?)"
|
||||
if where != wantWhere {
|
||||
t.Fatalf("where mismatch:\nwant %s\n got %s", wantWhere, where)
|
||||
}
|
||||
wantArgs := []any{"lalu", assetBDSalary, statusPending, int64(100), int64(200), "77", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%", "%upi%"}
|
||||
if !reflect.DeepEqual(args, wantArgs) {
|
||||
t.Fatalf("args mismatch:\nwant %#v\n got %#v", wantArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageMergedItemsSortsAndSlicesStableMixedSources(t *testing.T) {
|
||||
items := []itemDTO{
|
||||
{ID: "salary_transfer:8", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
|
||||
{ID: "withdrawal:7", RecordType: recordTypeWithdrawal, ApplicationID: "7", CreatedAtMS: 100},
|
||||
{ID: "salary_transfer:9", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 100},
|
||||
{ID: "withdrawal:11", RecordType: recordTypeWithdrawal, ApplicationID: "11", CreatedAtMS: 90},
|
||||
{ID: "salary_transfer:1", RecordType: recordTypeSalaryTransfer, CreatedAtMS: 80},
|
||||
}
|
||||
|
||||
page := pageMergedItems(items, 1, 4)
|
||||
gotIDs := []string{page[0].ID, page[1].ID, page[2].ID, page[3].ID}
|
||||
wantIDs := []string{"salary_transfer:9", "salary_transfer:8", "withdrawal:7", "withdrawal:11"}
|
||||
if !reflect.DeepEqual(gotIDs, wantIDs) {
|
||||
t.Fatalf("page sort mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
|
||||
}
|
||||
|
||||
secondPage := pageMergedItems(items, 2, 2)
|
||||
gotIDs = []string{secondPage[0].ID, secondPage[1].ID}
|
||||
wantIDs = []string{"withdrawal:7", "withdrawal:11"}
|
||||
if !reflect.DeepEqual(gotIDs, wantIDs) {
|
||||
t.Fatalf("second page mismatch:\nwant %#v\n got %#v", wantIDs, gotIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesExposesHostWithdrawalsEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
group := engine.Group("/api/v1")
|
||||
RegisterRoutes(group, &Handler{})
|
||||
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == "GET" && route.Path == "/api/v1/admin/host-withdrawals" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("host withdrawals route was not registered")
|
||||
}
|
||||
|
||||
func TestHostWithdrawalIndexSQLKeepsRequiredReadPaths(t *testing.T) {
|
||||
adminSQL, err := os.ReadFile("../../../migrations/072_host_withdrawal_navigation.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read admin migration failed: %v", err)
|
||||
}
|
||||
walletSQL, err := os.ReadFile("../../../../../scripts/mysql/054_wallet_entries_asset_counterparty_time_index.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read wallet migration failed: %v", err)
|
||||
}
|
||||
for _, snippet := range []string{
|
||||
"idx_admin_withdrawal_app_time (app_code, created_at_ms, id)",
|
||||
"idx_admin_withdrawal_app_asset_time (app_code, salary_asset_type, created_at_ms, id)",
|
||||
"idx_admin_withdrawal_app_status_asset_time (app_code, status, salary_asset_type, created_at_ms, id)",
|
||||
"idx_admin_withdrawal_app_user_time (app_code, user_id, created_at_ms, id)",
|
||||
} {
|
||||
if !strings.Contains(string(adminSQL), snippet) {
|
||||
t.Fatalf("admin migration missing index snippet %q", snippet)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(walletSQL), "idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)") {
|
||||
t.Fatal("wallet migration missing counterparty index")
|
||||
}
|
||||
}
|
||||
@ -104,6 +104,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "币商工资兑换比例", Code: "coin-seller:exchange-rate", Kind: "button"},
|
||||
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
|
||||
{Name: "币商流水查看", Code: "coin-seller-ledger:view", Kind: "menu"},
|
||||
{Name: "主播提现查看", Code: "host-withdrawal:view", Kind: "menu"},
|
||||
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
|
||||
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
|
||||
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
|
||||
@ -342,6 +343,7 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &hostOrgID, Title: "工资政策", Code: "host-agency-policy", Path: "/host/salary-policies", Icon: "wallet", PermissionCode: "host-agency-policy:view", Sort: 85, Visible: true},
|
||||
{ParentID: &hostOrgID, Title: "工资结算", Code: "host-salary-settlement", Path: "/host/salary-settlements", Icon: "receipt", PermissionCode: "host-salary-settlement:view", Sort: 86, Visible: true},
|
||||
{ParentID: &hostOrgID, Title: "Coin Saller列表", Code: "host-org-coin-sellers", Path: "/host/coin-sellers", Icon: "wallet", PermissionCode: "coin-seller:view", Sort: 87, Visible: true},
|
||||
{ParentID: &hostOrgID, Title: "主播提现", Code: "host-withdrawals", Path: "/host/withdrawals", Icon: "receipt", PermissionCode: "host-withdrawal:view", Sort: 88, Visible: true},
|
||||
}
|
||||
for _, menu := range children {
|
||||
if _, err := s.syncDefaultMenu(s.db, menu); err != nil {
|
||||
@ -642,7 +644,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||
"bd:view", "bd:create", "bd:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
@ -659,7 +661,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"upload:create",
|
||||
}
|
||||
case "auditor":
|
||||
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
||||
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
||||
case "readonly":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -689,6 +691,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"host-agency-policy:view",
|
||||
"team-salary-policy:view",
|
||||
"host-salary-settlement:view",
|
||||
"host-withdrawal:view",
|
||||
"agency:view",
|
||||
"bd:view",
|
||||
"coin-seller:view",
|
||||
@ -750,7 +753,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
@ -764,7 +767,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
||||
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) {
|
||||
"finance-operation:user-wallet-debit",
|
||||
"finance-operation:coin-seller-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit",
|
||||
"host-withdrawal:view",
|
||||
} {
|
||||
if _, ok := permissions[code]; !ok {
|
||||
t.Fatalf("finance permission %s missing from default seed", code)
|
||||
@ -46,6 +47,7 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) {
|
||||
"finance-operation:user-wallet-debit",
|
||||
"finance-operation:coin-seller-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit",
|
||||
"host-withdrawal:view",
|
||||
} {
|
||||
if _, ok := opsPermissions[code]; !ok {
|
||||
t.Fatalf("ops-admin default permissions missing %s", code)
|
||||
|
||||
@ -28,6 +28,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
"hyapp-admin-server/internal/modules/hostorg"
|
||||
"hyapp-admin-server/internal/modules/hostsalarysettlement"
|
||||
"hyapp-admin-server/internal/modules/hostwithdrawal"
|
||||
"hyapp-admin-server/internal/modules/inviteactivityreward"
|
||||
"hyapp-admin-server/internal/modules/job"
|
||||
"hyapp-admin-server/internal/modules/levelconfig"
|
||||
@ -87,6 +88,7 @@ type Handlers struct {
|
||||
HostAgencyPolicy *hostagencypolicy.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
HostSalarySettlement *hostsalarysettlement.Handler
|
||||
HostWithdrawal *hostwithdrawal.Handler
|
||||
InviteActivityReward *inviteactivityreward.Handler
|
||||
Job *job.Handler
|
||||
LevelConfig *levelconfig.Handler
|
||||
@ -161,6 +163,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||
hostwithdrawal.RegisterRoutes(protected, h.HostWithdrawal)
|
||||
inviteactivityreward.RegisterRoutes(protected, h.InviteActivityReward)
|
||||
audit.RegisterRoutes(protected, h.Audit)
|
||||
payment.RegisterRoutes(protected, h.Payment)
|
||||
|
||||
70
server/admin/migrations/072_host_withdrawal_navigation.sql
Normal file
70
server/admin/migrations/072_host_withdrawal_navigation.sql
Normal file
@ -0,0 +1,70 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 主播提现页只读工资转币商和工资提现申请事实;菜单和权限放在团队管理下,不承接财务审核动作。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('主播提现查看', 'host-withdrawal:view', 'menu', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '主播提现', 'host-withdrawals', '/host/withdrawals', 'receipt', 'host-withdrawal:view', 88, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'host-org'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('platform-admin', 'ops-admin', 'auditor', 'readonly')
|
||||
AND admin_permission.code = 'host-withdrawal:view';
|
||||
|
||||
-- 后台提现申请列表会按 app/时间、身份、状态和用户检索;补组合索引避免新页面常规筛选退化成全表扫描。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_time') = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_time (app_code, created_at_ms, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_asset_time') = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_asset_time (app_code, salary_asset_type, created_at_ms, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_status_asset_time') = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_status_asset_time (app_code, status, salary_asset_type, created_at_ms, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_user_time') = 0,
|
||||
'ALTER TABLE admin_user_withdrawal_applications ADD INDEX idx_admin_withdrawal_app_user_time (app_code, user_id, created_at_ms, id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -63,7 +63,8 @@ CREATE TABLE IF NOT EXISTS wallet_entries (
|
||||
KEY idx_wallet_entries_user_time (app_code, user_id, created_at_ms),
|
||||
KEY idx_wallet_entries_tx (app_code, transaction_id),
|
||||
KEY idx_wallet_entries_asset_time (app_code, asset_type, created_at_ms),
|
||||
KEY idx_wallet_entries_asset_user_time (app_code, asset_type, user_id, created_at_ms, entry_id)
|
||||
KEY idx_wallet_entries_asset_user_time (app_code, asset_type, user_id, created_at_ms, entry_id),
|
||||
KEY idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包复式流水表';
|
||||
|
||||
-- 金币流水和统计回放会按 app/asset/user/time 顺序读取钱包分录;
|
||||
@ -77,6 +78,17 @@ PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 工资转币商查询从币商侧分录反查来源工资用户;老表缺对手用户组合索引时,
|
||||
-- source_user 关键词会在 COIN_SELLER_COIN 分录中做大范围过滤。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_entries' AND INDEX_NAME = 'idx_wallet_entries_asset_counterparty_time') = 0,
|
||||
'ALTER TABLE wallet_entries ADD INDEX idx_wallet_entries_asset_counterparty_time (app_code, asset_type, counterparty_user_id, created_at_ms, entry_id)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_outbox (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user