bi报表和财务系统

This commit is contained in:
local 2026-06-28 23:24:57 +08:00
parent 1409529f10
commit 151cf495e4
17 changed files with 1447 additions and 21 deletions

View File

@ -288,7 +288,7 @@ func main() {
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),

View File

@ -40,6 +40,7 @@ type Client interface {
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
ListTemporaryRechargeOrders(ctx context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
@ -187,6 +188,10 @@ func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *wal
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}
func (c *GRPCClient) CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.CreateTemporaryRechargeOrder(ctx, req)
}
func (c *GRPCClient) GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
return c.client.GetTemporaryRechargeOrder(ctx, req)
}

View File

@ -361,6 +361,32 @@ func (DataScope) TableName() string {
return "admin_data_scopes"
}
type UserMoneyScope struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_admin_user_money_scopes_user;index:uk_admin_user_money_scope,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:idx_admin_user_money_scopes_app_region;index:uk_admin_user_money_scope,unique;not null" json:"appCode"`
RegionID int64 `gorm:"index:idx_admin_user_money_scopes_app_region;index:uk_admin_user_money_scope,unique;not null;default:0" json:"regionId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes"
}
type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
AdminUserID uint `gorm:"index:idx_admin_temporary_payment_link_owners_user;not null" json:"adminUserId"`
RegionID int64 `gorm:"index:idx_admin_temporary_payment_link_owners_region;not null" json:"regionId"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (TemporaryPaymentLinkOwner) TableName() string {
return "admin_temporary_payment_link_owners"
}
type AdminJob struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:80;index;not null" json:"type"`

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
@ -71,6 +72,42 @@ func (h *Handler) GetUser(c *gin.Context) {
response.OK(c, shared.UserDTO(*user))
}
func (h *Handler) ListUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
scopes, err := h.service.ListUserMoneyScopes(id)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return
}
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) ReplaceUserMoneyScopes(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
return
}
var req moneyScopeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "财务范围参数不正确")
return
}
input := make([]MoneyScopeInput, 0, len(req.Scopes))
for _, item := range req.Scopes {
input = append(input, MoneyScopeInput{AppCode: item.AppCode, RegionID: item.RegionID})
}
scopes, err := h.service.ReplaceUserMoneyScopes(id, input)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "replace-user-money-scopes", "admin_user_money_scopes", "success", fmt.Sprintf("user_id=%d scopes=%d", id, len(scopes)))
response.OK(c, gin.H{"items": moneyScopeDTOs(scopes), "total": len(scopes)})
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, ok := shared.ParseID(c, "id")
if !ok {
@ -159,3 +196,15 @@ func (h *Handler) ExportUsers(c *gin.Context) {
c.Writer.WriteHeader(200)
_, _ = c.Writer.Write(export.Content)
}
func moneyScopeDTOs(scopes []model.UserMoneyScope) []gin.H {
out := make([]gin.H, 0, len(scopes))
for _, scope := range scopes {
out = append(out, gin.H{
"appCode": scope.AppCode,
"regionId": scope.RegionID,
"userId": scope.UserID,
})
}
return out
}

View File

@ -30,3 +30,12 @@ type batchStatusRequest struct {
IDs []uint `json:"ids" binding:"required"`
Status string `json:"status" binding:"required"`
}
type moneyScopeRequest struct {
Scopes []moneyScopeItemRequest `json:"scopes"`
}
type moneyScopeItemRequest struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
}

View File

@ -12,7 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/users/export", middleware.RequirePermission("user:export"), h.ExportUsers)
protected.POST("/users/batch/status", middleware.RequirePermission("user:status"), h.BatchUpdateUserStatus)
protected.GET("/users/:id", middleware.RequirePermission("user:view"), h.GetUser)
protected.GET("/users/:id/money-scopes", middleware.RequirePermission("user:view"), h.ListUserMoneyScopes)
protected.PATCH("/users/:id", middleware.RequirePermission("user:update"), h.UpdateUser)
protected.PUT("/users/:id/money-scopes", middleware.RequirePermission("user:update"), h.ReplaceUserMoneyScopes)
protected.PATCH("/users/:id/status", middleware.RequirePermission("user:status"), h.UpdateUserStatus)
protected.POST("/users/:id/reset-password", middleware.RequirePermission("user:reset-password"), h.ResetUserPassword)
}

View File

@ -58,6 +58,11 @@ type UserExport struct {
Count int
}
type MoneyScopeInput struct {
AppCode string
RegionID int64
}
func NewService(store *repository.Store, cfg config.Config) *AdminUserService {
return &AdminUserService{store: store, cfg: cfg}
}
@ -102,6 +107,24 @@ func (s *AdminUserService) GetUser(id uint) (*model.User, error) {
return s.store.FindUserByID(id)
}
func (s *AdminUserService) ListUserMoneyScopes(id uint) ([]model.UserMoneyScope, error) {
return s.store.ListUserMoneyScopes(id)
}
func (s *AdminUserService) ReplaceUserMoneyScopes(id uint, input []MoneyScopeInput) ([]model.UserMoneyScope, error) {
scopes := make([]model.UserMoneyScope, 0, len(input))
for _, item := range input {
scopes = append(scopes, model.UserMoneyScope{
AppCode: strings.TrimSpace(item.AppCode),
RegionID: item.RegionID,
})
}
if err := s.store.ReplaceUserMoneyScopes(id, scopes); err != nil {
return nil, err
}
return s.store.ListUserMoneyScopes(id)
}
func (s *AdminUserService) UpdateUser(id uint, input UpdateUserInput) (*model.User, error) {
updates := map[string]any{}
if input.Name != nil {

View File

@ -117,6 +117,10 @@ type thirdPartyPaymentMethodIssueDTO struct {
type temporaryPaymentLinkDTO struct {
OrderID string `json:"orderId"`
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
OwnerUserID uint `json:"ownerUserId,omitempty"`
OwnerUsername string `json:"ownerUsername,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
CommandID string `json:"commandId,omitempty"`
AudienceType string `json:"audienceType"`
ProductName string `json:"productName"`

View File

@ -13,6 +13,7 @@ import (
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -26,13 +27,15 @@ const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
userDB *sql.DB
walletDB *sql.DB
store *repository.Store
audit shared.OperationLogger
exchangeRates exchangeRateClient
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
@ -290,9 +293,18 @@ func (h *Handler) ListThirdPartyPaymentChannels(c *gin.Context) {
func (h *Handler) ListTemporaryPaymentLinks(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
resp, err := h.wallet.ListTemporaryRechargeOrders(c.Request.Context(), &walletv1.ListTemporaryRechargeOrdersRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AppCode: appCode,
Status: options.Status,
ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")),
Keyword: options.Keyword,
@ -307,6 +319,15 @@ func (h *Handler) ListTemporaryPaymentLinks(c *gin.Context) {
for _, item := range resp.GetOrders() {
items = append(items, temporaryPaymentLinkFromProto(item))
}
if h.store != nil {
items, err = h.enrichAndFilterTemporaryLinks(appCode, items, access, queryInt64(c, "region_id", "regionId"), queryUint(c, "operator_user_id", "operatorUserId"))
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: int64(len(items))})
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
@ -316,17 +337,39 @@ func (h *Handler) GetTemporaryPaymentLink(c *gin.Context) {
response.BadRequest(c, "支付链接订单号不能为空")
return
}
appCode := appctx.Normalize(firstNonEmptyString(firstQuery(c, "app_code", "appCode"), appctx.FromContext(c.Request.Context())))
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.store != nil && !moneyAccessAllowsApp(access, appCode) {
response.Forbidden(c, "没有该 App 的财务范围")
return
}
// 查询单条订单会进入 wallet-service 的支付状态刷新逻辑admin 页面只发起核验,不在浏览器里拼三方查询参数。
resp, err := h.wallet.GetTemporaryRechargeOrder(c.Request.Context(), &walletv1.GetTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AppCode: appCode,
OrderId: orderID,
})
if err != nil {
writeWalletError(c, err, "校验三方临时支付链接失败")
return
}
response.OK(c, temporaryPaymentLinkFromProto(resp.GetOrder()))
item := temporaryPaymentLinkFromProto(resp.GetOrder())
if h.store != nil {
items, err := h.enrichAndFilterTemporaryLinks(appCode, []temporaryPaymentLinkDTO{item}, access, 0, 0)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
if len(items) == 0 {
response.Forbidden(c, "没有该支付链接的财务范围")
return
}
item = items[0]
}
response.OK(c, item)
}
func (h *Handler) SetThirdPartyPaymentMethodStatus(c *gin.Context) {

View File

@ -11,10 +11,13 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/repository"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
@ -25,7 +28,7 @@ func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) {
defer db.Close()
wallet := &mockPaymentWallet{rechargeBillsResp: &walletv1.ListRechargeBillsResponse{}}
router := newPaymentHandlerTestRouter(New(wallet, db, nil))
router := newPaymentHandlerTestRouter(New(wallet, db, nil, nil, nil))
userID := int64(327702592329093120)
sellerUserID := int64(328453424662192128)
expectUserIdentityLookup(sqlMock, "111", userID)
@ -93,7 +96,7 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?status=active", nil)
recorder := httptest.NewRecorder()
@ -147,7 +150,7 @@ func TestListTemporaryPaymentLinksForwardsFilters(t *testing.T) {
UpdatedAtMs: 1700000000001,
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links?status=redirected&provider_code=mifapay&keyword=tmp&page=2&page_size=30", nil)
recorder := httptest.NewRecorder()
@ -185,7 +188,7 @@ func TestGetTemporaryPaymentLinkRefreshesSingleOrder(t *testing.T) {
PayUrl: "https://pay.example/tmp_1001",
Status: "paid",
}}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/temporary-links/tmp_1001", nil)
recorder := httptest.NewRecorder()
@ -208,9 +211,162 @@ func TestGetTemporaryPaymentLinkRefreshesSingleOrder(t *testing.T) {
}
}
func TestCreateTemporaryPaymentLinkRecordsOwner(t *testing.T) {
userDB, userSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
expectRegionCountry(userSQL, "lalu", 2, "SA", 1)
expectStoreRecordOwnerAndEnrich(storeSQL, "lalu", "tmp_created", 7, 2)
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810,"returnUrl":"https://h5.example/pay"}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateTemporary == nil ||
wallet.lastCreateTemporary.GetAppCode() != "lalu" ||
wallet.lastCreateTemporary.GetUsdMinorAmount() != 1299 ||
wallet.lastCreateTemporary.GetProviderCode() != "mifapay" ||
wallet.lastCreateTemporary.GetPaymentMethodId() != 810 ||
wallet.lastCreateTemporary.GetReturnUrl() != "https://h5.example/pay" ||
wallet.lastCreateTemporary.GetPayerAccount() != "tester" {
t.Fatalf("create temporary request mismatch: %+v", wallet.lastCreateTemporary)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 || response.Data["orderId"] != "tmp_created" || response.Data["regionId"].(float64) != 2 || response.Data["ownerUserId"].(float64) != 7 {
t.Fatalf("create payment link response mismatch: %+v", response)
}
if err := userSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestCreateTemporaryPaymentLinkRejectsOutOfScopeRegion(t *testing.T) {
userDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 3}})
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels != nil || wallet.lastCreateTemporary != nil {
t.Fatalf("out-of-scope request must fail before wallet calls: channels=%+v create=%+v", wallet.lastThirdPartyChannels, wallet.lastCreateTemporary)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestCreateTemporaryPaymentLinkRejectsCountryRegionMismatch(t *testing.T) {
userDB, userSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sql mock failed: %v", err)
}
defer userDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
expectRegionCountry(userSQL, "lalu", 2, "SA", 0)
wallet := &mockPaymentWallet{thirdPartyChannelsResp: temporaryMethodChannels("lalu", "mifapay", 810, "SA")}
router := newPaymentHandlerTestRouter(New(wallet, userDB, nil, store, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/temporary-links", bytes.NewBufferString(`{"appCode":"lalu","regionId":2,"usdMinorAmount":1299,"providerCode":"mifapay","paymentMethodId":810}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastCreateTemporary != nil {
t.Fatalf("country mismatch must fail before create order: %+v", wallet.lastCreateTemporary)
}
if err := userSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("user sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestGetMoneyPerformanceAggregatesPaidTemporaryOwners(t *testing.T) {
walletDB, walletSQL, err := sqlmock.New()
if err != nil {
t.Fatalf("create wallet sql mock failed: %v", err)
}
defer walletDB.Close()
store, storeSQL, closeStore := newPaymentStoreSQLMock(t)
defer closeStore()
expectStoreMoneyAccess(storeSQL, 7, []moneyScopeFixture{{appCode: "lalu", regionID: 2}})
walletSQL.ExpectQuery(`(?s)SELECT app_code, order_id, usd_minor_amount.*FROM external_recharge_orders`).
WithArgs("lalu", "temporary").
WillReturnRows(sqlmock.NewRows([]string{"app_code", "order_id", "usd_minor_amount", "status", "country_code", "provider_code", "payment_method_id", "pay_url", "created_at_ms", "updated_at_ms"}).
AddRow("lalu", "tmp_paid", int64(1299), "paid", "SA", "mifapay", int64(810), "https://pay.example/tmp_paid", int64(1700000000000), int64(1700000000100)).
AddRow("lalu", "tmp_credited", int64(2000), "credited", "SA", "mifapay", int64(810), "https://pay.example/tmp_credited", int64(1700000000000), int64(1700000000200)))
expectStoreOwners(storeSQL, "lalu", []ownerFixture{{orderID: "tmp_paid", adminUserID: 7, regionID: 2}, {orderID: "tmp_credited", adminUserID: 7, regionID: 2}})
expectStoreUsers(storeSQL, 7, "tester", "Tester")
router := newPaymentHandlerTestRouter(New(&mockPaymentWallet{}, nil, walletDB, store, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/money/performance", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
items := response.Data["items"].([]any)
item := items[0].(map[string]any)
summary := response.Data["summary"].(map[string]any)
if item["paidLinkCount"].(float64) != 2 || item["paidUsdMinor"].(float64) != 3299 || item["operatorName"] != "Tester" || summary["paidUsdMinor"].(float64) != 3299 {
t.Fatalf("performance response mismatch: %+v", response)
}
if err := walletSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("wallet sql expectations mismatch: %v", err)
}
if err := storeSQL.ExpectationsWereMet(); err != nil {
t.Fatalf("store sql expectations mismatch: %v", err)
}
}
func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-methods/810/status", bytes.NewBufferString(`{"enabled":false}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -230,7 +386,7 @@ func TestSetThirdPartyPaymentMethodStatusForwardsOperator(t *testing.T) {
func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPatch, "/admin/payment/third-party-rates/810", bytes.NewBufferString(`{"usdToCurrencyRate":" 3.75000000 "}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -263,7 +419,7 @@ func TestSyncThirdPartyPaymentMethodsDefaultsToV5Pay(t *testing.T) {
Message: "app is invalid",
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-methods/sync", bytes.NewBufferString(`{}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -312,7 +468,7 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
},
}},
}}
handler := New(wallet, nil, nil)
handler := New(wallet, nil, nil, nil, nil)
handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{
Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000"},
SourceNames: []string{"open.er-api.com"},
@ -354,7 +510,7 @@ func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.
func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":-1}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@ -371,7 +527,7 @@ func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
@ -409,7 +565,9 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
router.GET("/admin/payment/recharge-bills", handler.ListRechargeBills)
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks)
router.POST("/admin/payment/temporary-links", handler.CreateTemporaryPaymentLink)
router.GET("/admin/payment/temporary-links/:order_id", handler.GetTemporaryPaymentLink)
router.GET("/admin/money/performance", handler.GetMoneyPerformance)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.POST("/admin/payment/third-party-methods/sync", handler.SyncThirdPartyPaymentMethods)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
@ -442,6 +600,98 @@ func expectUserIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID in
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID))
}
type moneyScopeFixture struct {
appCode string
regionID int64
}
type ownerFixture struct {
orderID string
adminUserID uint
regionID int64
}
func newPaymentStoreSQLMock(t *testing.T) (*repository.Store, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create store sql mock failed: %v", err)
}
mock.MatchExpectationsInOrder(false)
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("create gorm db failed: %v", err)
}
return repository.New(gormDB), mock, func() {
_ = sqlDB.Close()
}
}
func expectStoreMoneyAccess(mock sqlmock.Sqlmock, userID uint, scopes []moneyScopeFixture) {
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "tester", "Tester", "active"))
mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?").
WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"}))
rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"})
for index, scope := range scopes {
rows.AddRow(index+1, userID, scope.appCode, scope.regionID, int64(1700000000000), int64(1700000000000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(rows)
}
func expectRegionCountry(mock sqlmock.Sqlmock, appCode string, regionID int64, countryCode string, count int) {
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*FROM region_countries.*WHERE app_code = \? AND region_id = \? AND country_code = \? AND status = 'active'`).
WithArgs(appCode, regionID, countryCode).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(count))
}
func expectStoreRecordOwnerAndEnrich(mock sqlmock.Sqlmock, appCode string, orderID string, adminUserID uint, regionID int64) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `admin_temporary_payment_link_owners`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
expectStoreOwners(mock, appCode, []ownerFixture{{orderID: orderID, adminUserID: adminUserID, regionID: regionID}})
expectStoreUsers(mock, adminUserID, "tester", "Tester")
}
func expectStoreOwners(mock sqlmock.Sqlmock, appCode string, owners []ownerFixture) {
rows := sqlmock.NewRows([]string{"app_code", "order_id", "admin_user_id", "region_id", "created_at_ms", "updated_at_ms"})
for _, owner := range owners {
rows.AddRow(appCode, owner.orderID, owner.adminUserID, owner.regionID, int64(1700000000000), int64(1700000000000))
}
mock.ExpectQuery("SELECT \\* FROM `admin_temporary_payment_link_owners` WHERE app_code = \\? AND order_id IN").
WillReturnRows(rows)
}
func expectStoreUsers(mock sqlmock.Sqlmock, userID uint, username string, name string) {
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE id IN").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, username, name, "active"))
}
func temporaryMethodChannels(appCode string, providerCode string, methodID int64, countryCode string) *walletv1.ListThirdPartyPaymentChannelsResponse {
return &walletv1.ListThirdPartyPaymentChannelsResponse{Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: appCode,
ProviderCode: providerCode,
ProviderName: providerCode,
Status: "active",
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: methodID,
AppCode: appCode,
ProviderCode: providerCode,
ProviderName: providerCode,
CountryCode: countryCode,
CountryName: countryCode,
CurrencyCode: countryCode,
PayWay: "Card",
PayType: "CARD",
MethodName: "Card",
Status: "active",
}},
}}}
}
type mockPaymentWallet struct {
walletclient.Client
@ -451,6 +701,8 @@ type mockPaymentWallet struct {
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastTemporaryOrders *walletv1.ListTemporaryRechargeOrdersRequest
temporaryOrdersResp *walletv1.ListTemporaryRechargeOrdersResponse
lastCreateTemporary *walletv1.CreateTemporaryRechargeOrderRequest
createTemporaryResp *walletv1.H5RechargeOrderResponse
lastTemporaryOrder *walletv1.GetTemporaryRechargeOrderRequest
temporaryOrderResp *walletv1.H5RechargeOrderResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
@ -485,6 +737,23 @@ func (m *mockPaymentWallet) ListTemporaryRechargeOrders(_ context.Context, req *
return &walletv1.ListTemporaryRechargeOrdersResponse{}, nil
}
func (m *mockPaymentWallet) CreateTemporaryRechargeOrder(_ context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
m.lastCreateTemporary = req
if m.createTemporaryResp != nil {
return m.createTemporaryResp, nil
}
return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{
OrderId: "tmp_created",
AppCode: req.GetAppCode(),
AudienceType: "temporary",
UsdMinorAmount: req.GetUsdMinorAmount(),
ProviderCode: req.GetProviderCode(),
PaymentMethodId: req.GetPaymentMethodId(),
PayUrl: "https://pay.example/tmp_created",
Status: "redirected",
}}, nil
}
func (m *mockPaymentWallet) GetTemporaryRechargeOrder(_ context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
m.lastTemporaryOrder = req
if m.temporaryOrderResp != nil {

View File

@ -0,0 +1,653 @@
package payment
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
const (
moneyPaymentLinkCreatePermission = "money:payment-link:create"
moneyViewPermission = "money:view"
temporaryRechargeAudience = "temporary"
)
type temporaryPaymentLinkCreateRequest struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
USDMinorAmount int64 `json:"usdMinorAmount"`
ProviderCode string `json:"providerCode"`
PaymentMethodID int64 `json:"paymentMethodId"`
ReturnURL string `json:"returnUrl"`
Language string `json:"language"`
}
type moneyScopeItemDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
}
type moneyAppDTO struct {
AppID int64 `json:"appId"`
AppCode string `json:"appCode"`
AppName string `json:"appName"`
PackageName string `json:"packageName"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type moneyRegionDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
RegionCode string `json:"regionCode"`
Name string `json:"name"`
Status string `json:"status"`
SortOrder int `json:"sortOrder"`
Countries []string `json:"countries"`
}
type moneyCountryDTO struct {
AppCode string `json:"appCode"`
CountryID int64 `json:"countryId"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
CountryDisplayName string `json:"countryDisplayName"`
Flag string `json:"flag"`
Enabled bool `json:"enabled"`
RegionID int64 `json:"regionId"`
SortOrder int `json:"sortOrder"`
}
type moneyPerformanceItemDTO struct {
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
OperatorUserID uint `json:"operatorUserId"`
OperatorName string `json:"operatorName"`
OperatorAccount string `json:"operatorAccount"`
PaidLinkCount int64 `json:"paidLinkCount"`
PaidUSDMinor int64 `json:"paidUsdMinor"`
LastPaidAtMS int64 `json:"lastPaidAtMs"`
}
type paidTemporaryOrder struct {
AppCode string
OrderID string
USDMinorAmount int64
Status string
CountryCode string
ProviderCode string
PaymentMethodID int64
PayURL string
CreatedAtMS int64
UpdatedAtMS int64
}
func (h *Handler) GetMoneyScope(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.userDB == nil {
response.ServerError(c, "user mysql is not configured")
return
}
apps, regions, countries, err := h.loadMoneyMasterData(c.Request.Context(), access)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return
}
response.OK(c, gin.H{
"all": access.All,
"scopes": moneyScopeDTOsFromModel(access.Scopes),
"apps": apps,
"regions": regions,
"countries": countries,
})
}
func (h *Handler) GetMoneyPerformance(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
if h.walletDB == nil || h.store == nil {
response.OK(c, gin.H{"items": []moneyPerformanceItemDTO{}, "summary": moneyPerformanceSummary(nil)})
return
}
appCodes, err := h.performanceAppCodes(c.Request.Context(), access, firstQuery(c, "app_code", "appCode"))
if err != nil {
response.ServerError(c, "获取绩效 App 失败")
return
}
regionID := queryInt64(c, "region_id", "regionId")
operatorUserID := queryUint(c, "operator_user_id", "operatorUserId")
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
aggregates := map[string]*moneyPerformanceItemDTO{}
userIDs := []uint{}
for _, appCode := range appCodes {
orders, err := h.listPaidTemporaryOrders(c.Request.Context(), appCode, startAtMS, endAtMS)
if err != nil {
response.ServerError(c, "获取支付绩效失败")
return
}
orderIDs := make([]string, 0, len(orders))
for _, order := range orders {
orderIDs = append(orderIDs, order.OrderID)
}
owners, err := h.store.TemporaryPaymentLinkOwners(appCode, orderIDs)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
for _, order := range orders {
owner, ok := owners[order.OrderID]
if !ok || !access.All && !access.Allows(owner.AppCode, owner.RegionID) {
continue
}
if regionID > 0 && owner.RegionID != regionID {
continue
}
if operatorUserID > 0 && owner.AdminUserID != operatorUserID {
continue
}
key := fmt.Sprintf("%s:%d:%d", owner.AppCode, owner.RegionID, owner.AdminUserID)
item := aggregates[key]
if item == nil {
item = &moneyPerformanceItemDTO{
AppCode: owner.AppCode,
RegionID: owner.RegionID,
OperatorUserID: owner.AdminUserID,
}
aggregates[key] = item
userIDs = append(userIDs, owner.AdminUserID)
}
item.PaidLinkCount++
item.PaidUSDMinor += order.USDMinorAmount
if order.UpdatedAtMS > item.LastPaidAtMS {
item.LastPaidAtMS = order.UpdatedAtMS
}
}
}
users, err := h.store.FindUsersByIDs(userIDs)
if err != nil {
response.ServerError(c, "获取运营用户失败")
return
}
items := make([]moneyPerformanceItemDTO, 0, len(aggregates))
for _, item := range aggregates {
if user, ok := users[item.OperatorUserID]; ok {
item.OperatorName = user.Name
item.OperatorAccount = user.Username
}
items = append(items, *item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].PaidUSDMinor == items[j].PaidUSDMinor {
return items[i].LastPaidAtMS > items[j].LastPaidAtMS
}
return items[i].PaidUSDMinor > items[j].PaidUSDMinor
})
response.OK(c, gin.H{"items": items, "summary": moneyPerformanceSummary(items)})
}
func (h *Handler) CreateTemporaryPaymentLink(c *gin.Context) {
if h.store == nil {
response.ServerError(c, "admin store is not configured")
return
}
var request temporaryPaymentLinkCreateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付链接参数不正确")
return
}
appCode := appctx.Normalize(request.AppCode)
if appCode == "" || request.RegionID <= 0 || request.USDMinorAmount <= 0 || request.PaymentMethodID <= 0 {
response.BadRequest(c, "支付链接参数不完整")
return
}
access, ok := h.moneyAccess(c)
if !ok {
return
}
if !access.Allows(appCode, request.RegionID) {
response.Forbidden(c, "没有该 App 区域的财务范围")
return
}
method, err := h.findTemporaryPaymentMethod(c.Request.Context(), appCode, request.ProviderCode, request.PaymentMethodID)
if err != nil {
if errors.Is(err, errTemporaryPaymentMethodInvalid) {
response.BadRequest(c, "支付方式不正确")
return
}
writeWalletError(c, err, "获取支付方式失败")
return
}
if ok, err := h.countryBelongsToRegion(c.Request.Context(), appCode, request.RegionID, method.GetCountryCode()); err != nil {
response.ServerError(c, "校验区域国家失败")
return
} else if !ok {
response.BadRequest(c, "支付方式国家不属于所选区域")
return
}
language := strings.TrimSpace(request.Language)
if language == "" {
language = "en"
}
resp, err := h.wallet.CreateTemporaryRechargeOrder(c.Request.Context(), &walletv1.CreateTemporaryRechargeOrderRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
CommandId: temporaryPaymentCommandID(shared.ActorFromContext(c).UserID, middleware.CurrentRequestID(c)),
CoinAmount: 0,
UsdMinorAmount: request.USDMinorAmount,
ProviderCode: method.GetProviderCode(),
PaymentMethodId: method.GetMethodId(),
ReturnUrl: strings.TrimSpace(request.ReturnURL),
ClientIp: c.ClientIP(),
Language: language,
PayerName: shared.ActorFromContext(c).Username,
PayerAccount: shared.ActorFromContext(c).Username,
})
if err != nil {
writeWalletError(c, err, "创建支付链接失败")
return
}
order := resp.GetOrder()
if order == nil || strings.TrimSpace(order.GetOrderId()) == "" {
response.ServerError(c, "创建支付链接失败")
return
}
if err := h.store.RecordTemporaryPaymentLinkOwner(model.TemporaryPaymentLinkOwner{
AppCode: appCode,
OrderID: order.GetOrderId(),
AdminUserID: shared.ActorFromContext(c).UserID,
RegionID: request.RegionID,
}); err != nil {
response.ServerError(c, "记录支付链接归属失败")
return
}
item := temporaryPaymentLinkFromProto(order)
items, err := h.enrichAndFilterTemporaryLinks(appCode, []temporaryPaymentLinkDTO{item}, access, 0, 0)
if err != nil {
response.ServerError(c, "获取支付链接归属失败")
return
}
if len(items) > 0 {
item = items[0]
}
shared.OperationLogWithResourceID(c, h.audit, "create-temporary-payment-link", "external_recharge_orders", order.GetOrderId(), "success", fmt.Sprintf("%s:%d", appCode, request.RegionID))
response.Created(c, item)
}
func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) {
if h.store == nil {
return repository.MoneyAccess{All: true}, true
}
access, err := h.store.MoneyAccessForUser(shared.ActorFromContext(c).UserID)
if err != nil {
response.ServerError(c, "获取财务范围失败")
return repository.MoneyAccess{}, false
}
return access, true
}
func moneyAccessAllowsApp(access repository.MoneyAccess, appCode string) bool {
if access.All {
return true
}
appCode = appctx.Normalize(appCode)
for _, scope := range access.Scopes {
if appctx.Normalize(scope.AppCode) == appCode {
return true
}
}
return false
}
func (h *Handler) enrichAndFilterTemporaryLinks(appCode string, items []temporaryPaymentLinkDTO, access repository.MoneyAccess, regionID int64, operatorUserID uint) ([]temporaryPaymentLinkDTO, error) {
orderIDs := make([]string, 0, len(items))
for _, item := range items {
orderIDs = append(orderIDs, item.OrderID)
}
owners, err := h.store.TemporaryPaymentLinkOwners(appCode, orderIDs)
if err != nil {
return nil, err
}
userIDs := []uint{}
for _, owner := range owners {
userIDs = append(userIDs, owner.AdminUserID)
}
users, err := h.store.FindUsersByIDs(userIDs)
if err != nil {
return nil, err
}
out := make([]temporaryPaymentLinkDTO, 0, len(items))
for _, item := range items {
owner, ok := owners[item.OrderID]
if !ok {
if access.All && regionID == 0 && operatorUserID == 0 {
out = append(out, item)
}
continue
}
if !access.All && !access.Allows(owner.AppCode, owner.RegionID) {
continue
}
if regionID > 0 && owner.RegionID != regionID {
continue
}
if operatorUserID > 0 && owner.AdminUserID != operatorUserID {
continue
}
item.RegionID = owner.RegionID
item.OwnerUserID = owner.AdminUserID
if user, ok := users[owner.AdminUserID]; ok {
item.OwnerUsername = user.Username
item.OwnerName = user.Name
}
out = append(out, item)
}
return out, nil
}
var errTemporaryPaymentMethodInvalid = errors.New("temporary payment method invalid")
func (h *Handler) findTemporaryPaymentMethod(ctx context.Context, appCode string, providerCode string, paymentMethodID int64) (*walletv1.ThirdPartyPaymentMethod, error) {
resp, err := h.wallet.ListThirdPartyPaymentChannels(ctx, &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: strconv.FormatInt(time.Now().UnixNano(), 10),
AppCode: appCode,
ProviderCode: strings.TrimSpace(providerCode),
Status: "active",
IncludeDisabledMethods: true,
})
if err != nil {
return nil, err
}
for _, channel := range resp.GetChannels() {
for _, method := range channel.GetMethods() {
if method.GetMethodId() != paymentMethodID {
continue
}
if strings.TrimSpace(providerCode) != "" && method.GetProviderCode() != strings.TrimSpace(providerCode) {
return nil, errTemporaryPaymentMethodInvalid
}
if method.GetStatus() != "active" || strings.TrimSpace(method.GetCountryCode()) == "" {
return nil, errTemporaryPaymentMethodInvalid
}
return method, nil
}
}
return nil, errTemporaryPaymentMethodInvalid
}
func (h *Handler) countryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, error) {
if h.userDB == nil {
return false, errors.New("user mysql is not configured")
}
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" || countryCode == "GLOBAL" {
return false, nil
}
var count int
err := h.userDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM region_countries
WHERE app_code = ? AND region_id = ? AND country_code = ? AND status = 'active'
`, appCode, regionID, countryCode).Scan(&count)
return count > 0, err
}
func (h *Handler) loadMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
apps, err := h.listMoneyApps(ctx, access)
if err != nil {
return nil, nil, nil, err
}
appCodes := make([]string, 0, len(apps))
for _, app := range apps {
appCodes = append(appCodes, app.AppCode)
}
regions, err := h.listMoneyRegions(ctx, access, appCodes)
if err != nil {
return nil, nil, nil, err
}
countries, err := h.listMoneyCountries(ctx, access, appCodes)
if err != nil {
return nil, nil, nil, err
}
return apps, regions, countries, nil
}
func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) {
where, args := scopedAppWhere(access)
rows, err := h.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status
FROM apps
WHERE status = 'active'`+where+`
ORDER BY app_name ASC, app_code ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyAppDTO{}
for rows.Next() {
var item moneyAppDTO
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) listMoneyRegions(ctx context.Context, access repository.MoneyAccess, appCodes []string) ([]moneyRegionDTO, error) {
if len(appCodes) == 0 {
return []moneyRegionDTO{}, nil
}
where, args := inClause("r.app_code", appCodes)
rows, err := h.userDB.QueryContext(ctx, `
SELECT r.app_code, r.region_id, r.region_code, r.name, r.status, r.sort_order,
COALESCE(GROUP_CONCAT(rc.country_code ORDER BY rc.country_code SEPARATOR ','), '')
FROM regions r
LEFT JOIN region_countries rc
ON rc.app_code = r.app_code AND rc.region_id = r.region_id AND rc.status = 'active'
WHERE r.status = 'active' AND `+where+`
GROUP BY r.app_code, r.region_id, r.region_code, r.name, r.status, r.sort_order
ORDER BY r.app_code ASC, r.sort_order ASC, r.name ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyRegionDTO{}
for rows.Next() {
var item moneyRegionDTO
var countries string
if err := rows.Scan(&item.AppCode, &item.RegionID, &item.RegionCode, &item.Name, &item.Status, &item.SortOrder, &countries); err != nil {
return nil, err
}
if !access.All && !access.Allows(item.AppCode, item.RegionID) {
continue
}
item.Countries = splitCodes(countries)
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) listMoneyCountries(ctx context.Context, access repository.MoneyAccess, appCodes []string) ([]moneyCountryDTO, error) {
if len(appCodes) == 0 {
return []moneyCountryDTO{}, nil
}
where, args := inClause("c.app_code", appCodes)
rows, err := h.userDB.QueryContext(ctx, `
SELECT c.app_code, c.country_id, c.country_code, c.country_name, c.country_display_name, c.flag,
c.enabled, COALESCE(rc.region_id, 0), c.sort_order
FROM countries c
LEFT JOIN region_countries rc
ON rc.app_code = c.app_code AND rc.country_code = c.country_code AND rc.status = 'active'
WHERE c.enabled = TRUE AND `+where+`
ORDER BY c.app_code ASC, c.sort_order ASC, c.country_name ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []moneyCountryDTO{}
for rows.Next() {
var item moneyCountryDTO
if err := rows.Scan(&item.AppCode, &item.CountryID, &item.CountryCode, &item.CountryName, &item.CountryDisplayName, &item.Flag, &item.Enabled, &item.RegionID, &item.SortOrder); err != nil {
return nil, err
}
if !access.All && !access.Allows(item.AppCode, item.RegionID) {
continue
}
items = append(items, item)
}
return items, rows.Err()
}
func (h *Handler) performanceAppCodes(ctx context.Context, access repository.MoneyAccess, queryAppCode string) ([]string, error) {
if appCode := appctx.Normalize(queryAppCode); strings.TrimSpace(queryAppCode) != "" {
if !moneyAccessAllowsApp(access, appCode) {
return nil, nil
}
return []string{appCode}, nil
}
if !access.All {
return access.AppCodes(), nil
}
rows, err := h.walletDB.QueryContext(ctx, `
SELECT DISTINCT app_code
FROM external_recharge_orders
WHERE audience_type = ?
ORDER BY app_code ASC`, temporaryRechargeAudience)
if err != nil {
return nil, err
}
defer rows.Close()
appCodes := []string{}
for rows.Next() {
var appCode string
if err := rows.Scan(&appCode); err != nil {
return nil, err
}
appCodes = append(appCodes, appctx.Normalize(appCode))
}
return appCodes, rows.Err()
}
func (h *Handler) listPaidTemporaryOrders(ctx context.Context, appCode string, startAtMS int64, endAtMS int64) ([]paidTemporaryOrder, error) {
where := `WHERE app_code = ? AND audience_type = ? AND status IN ('paid', 'credited')`
args := []any{appCode, temporaryRechargeAudience}
if startAtMS > 0 {
where += ` AND updated_at_ms >= ?`
args = append(args, startAtMS)
}
if endAtMS > 0 {
where += ` AND updated_at_ms <= ?`
args = append(args, endAtMS)
}
rows, err := h.walletDB.QueryContext(ctx, `
SELECT app_code, order_id, usd_minor_amount, status, country_code, provider_code, payment_method_id, pay_url, created_at_ms, updated_at_ms
FROM external_recharge_orders
`+where+`
ORDER BY updated_at_ms DESC, order_id DESC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
orders := []paidTemporaryOrder{}
for rows.Next() {
var order paidTemporaryOrder
if err := rows.Scan(&order.AppCode, &order.OrderID, &order.USDMinorAmount, &order.Status, &order.CountryCode, &order.ProviderCode, &order.PaymentMethodID, &order.PayURL, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
return nil, err
}
orders = append(orders, order)
}
return orders, rows.Err()
}
func moneyPerformanceSummary(items []moneyPerformanceItemDTO) gin.H {
var paidLinks int64
var paidUSDMinor int64
for _, item := range items {
paidLinks += item.PaidLinkCount
paidUSDMinor += item.PaidUSDMinor
}
return gin.H{
"paidLinkCount": paidLinks,
"paidUsdMinor": paidUSDMinor,
"operatorCount": len(items),
}
}
func moneyScopeDTOsFromModel(scopes []model.UserMoneyScope) []moneyScopeItemDTO {
out := make([]moneyScopeItemDTO, 0, len(scopes))
for _, scope := range scopes {
out = append(out, moneyScopeItemDTO{AppCode: scope.AppCode, RegionID: scope.RegionID})
}
return out
}
func scopedAppWhere(access repository.MoneyAccess) (string, []any) {
if access.All {
return "", nil
}
where, args := inClause("app_code", access.AppCodes())
return " AND " + where, args
}
func inClause(column string, values []string) (string, []any) {
if len(values) == 0 {
return "1 = 0", nil
}
placeholders := make([]string, 0, len(values))
args := make([]any, 0, len(values))
for _, value := range values {
placeholders = append(placeholders, "?")
args = append(args, appctx.Normalize(value))
}
return column + " IN (" + strings.Join(placeholders, ",") + ")", args
}
func splitCodes(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func queryUint(c *gin.Context, keys ...string) uint {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0
}
return uint(parsed)
}
func temporaryPaymentCommandID(userID uint, requestID string) string {
return fmt.Sprintf("admin-temporary:%d:%s", userID, strings.TrimSpace(requestID))
}

View File

@ -12,9 +12,12 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.GET("/admin/payment/third-party-channels", middleware.RequireAnyPermission("payment-third-party:view", moneyViewPermission), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/money/scope", middleware.RequirePermission(moneyViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/performance", middleware.RequirePermission(moneyViewPermission), h.GetMoneyPerformance)
protected.GET("/admin/payment/temporary-links", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.ListTemporaryPaymentLinks)
protected.POST("/admin/payment/temporary-links", middleware.RequirePermission(moneyPaymentLinkCreatePermission), h.CreateTemporaryPaymentLink)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequireAnyPermission("payment-temporary-link:view", moneyViewPermission), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)

View File

@ -0,0 +1,215 @@
package repository
import (
"errors"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MoneyAccess struct {
UserID uint
All bool
Scopes []model.UserMoneyScope
}
func (access MoneyAccess) Allows(appCode string, regionID int64) bool {
if access.All {
return true
}
appCode = appctx.Normalize(appCode)
for _, scope := range access.Scopes {
if appctx.Normalize(scope.AppCode) != appCode {
continue
}
if scope.RegionID == 0 || scope.RegionID == regionID {
return true
}
}
return false
}
func (access MoneyAccess) AppCodes() []string {
if access.All {
return nil
}
set := map[string]struct{}{}
for _, scope := range access.Scopes {
appCode := appctx.Normalize(scope.AppCode)
if appCode != "" {
set[appCode] = struct{}{}
}
}
out := make([]string, 0, len(set))
for appCode := range set {
out = append(out, appCode)
}
return out
}
func (s *Store) MoneyAccessForUser(userID uint) (MoneyAccess, error) {
if userID == 0 {
return MoneyAccess{All: true}, nil
}
var user model.User
if err := s.db.Preload("Roles").First(&user, userID).Error; err != nil {
return MoneyAccess{}, err
}
scopes, err := s.ListUserMoneyScopes(userID)
if err != nil {
return MoneyAccess{}, err
}
if len(scopes) == 0 && userHasRole(user, "platform-admin") {
return MoneyAccess{UserID: userID, All: true}, nil
}
return MoneyAccess{UserID: userID, Scopes: scopes}, nil
}
func (s *Store) ListUserMoneyScopes(userID uint) ([]model.UserMoneyScope, error) {
var scopes []model.UserMoneyScope
err := s.db.Where("user_id = ?", userID).Order("app_code ASC, region_id ASC").Find(&scopes).Error
return scopes, err
}
func (s *Store) ReplaceUserMoneyScopes(userID uint, scopes []model.UserMoneyScope) error {
if userID == 0 {
return errors.New("user id is required")
}
normalized, err := normalizeMoneyScopes(userID, scopes)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.First(&user, userID).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserMoneyScope{}).Error; err != nil {
return err
}
if len(normalized) == 0 {
return nil
}
return tx.Create(&normalized).Error
})
}
func (s *Store) RecordTemporaryPaymentLinkOwner(owner model.TemporaryPaymentLinkOwner) error {
owner.AppCode = appctx.Normalize(owner.AppCode)
owner.OrderID = strings.TrimSpace(owner.OrderID)
if owner.AppCode == "" || owner.OrderID == "" || owner.AdminUserID == 0 || owner.RegionID <= 0 {
return errors.New("temporary payment link owner is incomplete")
}
nowMS := time.Now().UTC().UnixMilli()
if owner.CreatedAtMS == 0 {
owner.CreatedAtMS = nowMS
}
owner.UpdatedAtMS = nowMS
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_code"}, {Name: "order_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"admin_user_id",
"region_id",
"updated_at_ms",
}),
}).Create(&owner).Error
}
func (s *Store) TemporaryPaymentLinkOwners(appCode string, orderIDs []string) (map[string]model.TemporaryPaymentLinkOwner, error) {
appCode = appctx.Normalize(appCode)
ids := uniqueStrings(orderIDs)
out := make(map[string]model.TemporaryPaymentLinkOwner, len(ids))
if appCode == "" || len(ids) == 0 {
return out, nil
}
var owners []model.TemporaryPaymentLinkOwner
if err := s.db.Where("app_code = ? AND order_id IN ?", appCode, ids).Find(&owners).Error; err != nil {
return nil, err
}
for _, owner := range owners {
out[owner.OrderID] = owner
}
return out, nil
}
func (s *Store) FindUsersByIDs(ids []uint) (map[uint]model.User, error) {
uniqueIDs := uniqueUints(ids)
out := make(map[uint]model.User, len(uniqueIDs))
if len(uniqueIDs) == 0 {
return out, nil
}
var users []model.User
if err := s.db.Where("id IN ?", uniqueIDs).Find(&users).Error; err != nil {
return nil, err
}
for _, user := range users {
out[user.ID] = user
}
return out, nil
}
func normalizeMoneyScopes(userID uint, scopes []model.UserMoneyScope) ([]model.UserMoneyScope, error) {
seen := map[string]struct{}{}
out := make([]model.UserMoneyScope, 0, len(scopes))
for _, scope := range scopes {
appCode := appctx.Normalize(scope.AppCode)
if appCode == "" || scope.RegionID < 0 {
return nil, errors.New("money scope app code and region id are required")
}
key := appCode + ":" + strconv.FormatInt(scope.RegionID, 10)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, model.UserMoneyScope{UserID: userID, AppCode: appCode, RegionID: scope.RegionID})
}
return out, nil
}
func userHasRole(user model.User, code string) bool {
for _, role := range user.Roles {
if role.Code == code {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
out = append(out, value)
}
return out
}
func uniqueUints(values []uint) []uint {
set := map[uint]struct{}{}
out := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
out = append(out, value)
}
return out
}

View File

@ -0,0 +1,77 @@
package repository
import (
"testing"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestReplaceAndListUserMoneyScopes(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectBegin()
mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?").
WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(7, "ops", "Ops", "active"))
mock.ExpectExec("DELETE FROM `admin_user_money_scopes` WHERE user_id = \\?").
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec("INSERT INTO `admin_user_money_scopes`").
WillReturnResult(sqlmock.NewResult(1, 2))
mock.ExpectCommit()
if err := store.ReplaceUserMoneyScopes(7, []model.UserMoneyScope{
{AppCode: " LALU ", RegionID: 2},
{AppCode: "lalu", RegionID: 2},
{AppCode: "yumi", RegionID: 0},
}); err != nil {
t.Fatalf("replace money scopes failed: %v", err)
}
mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}).
AddRow(1, 7, "lalu", 2, int64(1700000000000), int64(1700000000000)).
AddRow(2, 7, "yumi", 0, int64(1700000000000), int64(1700000000000)))
scopes, err := store.ListUserMoneyScopes(7)
if err != nil {
t.Fatalf("list money scopes failed: %v", err)
}
if len(scopes) != 2 || scopes[0].AppCode != "lalu" || scopes[0].RegionID != 2 || scopes[1].AppCode != "yumi" || scopes[1].RegionID != 0 {
t.Fatalf("money scopes mismatch: %+v", scopes)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestReplaceUserMoneyScopesRejectsInvalidScope(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
if err := store.ReplaceUserMoneyScopes(7, []model.UserMoneyScope{{RegionID: 2}}); err == nil {
t.Fatal("expected invalid scope error")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func newRepositorySQLMock(t *testing.T) (*Store, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("create gorm db failed: %v", err)
}
return New(gormDB), mock, func() {
_ = sqlDB.Close()
}
}

View File

@ -76,6 +76,8 @@ func (s *Store) AutoMigrate() error {
&model.LoginLog{},
&model.OperationLog{},
&model.DataScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.AdminJob{},
)
}

View File

@ -112,6 +112,8 @@ var defaultPermissions = []model.Permission{
{Name: "三方支付查看", Code: "payment-third-party:view", Kind: "menu"},
{Name: "三方支付更新", Code: "payment-third-party:update", Kind: "button"},
{Name: "三方临时支付链接查看", Code: "payment-temporary-link:view", Kind: "menu"},
{Name: "财务系统查看", Code: "money:view", Kind: "menu"},
{Name: "财务支付链接创建", Code: "money:payment-link:create", Kind: "button"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
{Name: "内购配置更新", Code: "payment-product:update", Kind: "button"},
@ -553,7 +555,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-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"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", "money:view", "money:payment-link:create", "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 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-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"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", "money:view", "money:payment-link:create", "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",

View File

@ -0,0 +1,44 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
CREATE TABLE IF NOT EXISTS admin_user_money_scopes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID',
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID0 表示整个 app',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_admin_user_money_scope (user_id, app_code, region_id),
KEY idx_admin_user_money_scopes_user (user_id),
KEY idx_admin_user_money_scopes_app_region (app_code, region_id),
CONSTRAINT fk_admin_user_money_scopes_user FOREIGN KEY (user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户财务系统 app/区域范围';
CREATE TABLE IF NOT EXISTS admin_temporary_payment_link_owners (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
order_id VARCHAR(96) NOT NULL COMMENT 'wallet external_recharge_orders.order_id',
admin_user_id BIGINT UNSIGNED NOT NULL COMMENT '创建支付链接的后台用户 ID',
region_id BIGINT NOT NULL COMMENT '支付链接归属区域 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, order_id),
KEY idx_admin_temporary_payment_link_owners_user (admin_user_id, created_at_ms),
KEY idx_admin_temporary_payment_link_owners_region (app_code, region_id, created_at_ms),
CONSTRAINT fk_admin_temporary_payment_link_owners_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='临时支付链接后台归属';
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('财务支付链接创建', 'money:payment-link:create', 'button', '允许在独立财务子系统创建临时支付链接', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
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 = 'platform-admin'
AND admin_permission.code IN ('money:view', 'money:payment-link:create');