From 151cf495e4a5ec9ecb60b1b71c8b36703d4edf2a Mon Sep 17 00:00:00 2001 From: local Date: Sun, 28 Jun 2026 23:24:57 +0800 Subject: [PATCH] =?UTF-8?q?bi=E6=8A=A5=E8=A1=A8=E5=92=8C=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/admin/cmd/server/main.go | 2 +- .../integration/walletclient/client.go | 5 + server/admin/internal/model/models.go | 26 + .../internal/modules/adminuser/handler.go | 49 ++ .../internal/modules/adminuser/request.go | 9 + .../internal/modules/adminuser/routes.go | 2 + .../internal/modules/adminuser/service.go | 23 + server/admin/internal/modules/payment/dto.go | 4 + .../admin/internal/modules/payment/handler.go | 53 +- .../internal/modules/payment/handler_test.go | 289 +++++++- .../admin/internal/modules/payment/money.go | 653 ++++++++++++++++++ .../admin/internal/modules/payment/routes.go | 9 +- .../repository/money_scope_repository.go | 215 ++++++ .../repository/money_scope_repository_test.go | 77 +++ .../admin/internal/repository/repository.go | 2 + server/admin/internal/repository/seed.go | 6 +- ...064_money_scope_and_payment_link_owner.sql | 44 ++ 17 files changed, 1447 insertions(+), 21 deletions(-) create mode 100644 server/admin/internal/modules/payment/money.go create mode 100644 server/admin/internal/repository/money_scope_repository.go create mode 100644 server/admin/internal/repository/money_scope_repository_test.go create mode 100644 server/admin/migrations/064_money_scope_and_payment_link_owner.sql diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 0c95f0ca..c7470c82 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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), diff --git a/server/admin/internal/integration/walletclient/client.go b/server/admin/internal/integration/walletclient/client.go index 734c121a..d392366e 100644 --- a/server/admin/internal/integration/walletclient/client.go +++ b/server/admin/internal/integration/walletclient/client.go @@ -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) } diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 33f46fc3..66973454 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -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"` diff --git a/server/admin/internal/modules/adminuser/handler.go b/server/admin/internal/modules/adminuser/handler.go index 2a58130b..dd6e4d0d 100644 --- a/server/admin/internal/modules/adminuser/handler.go +++ b/server/admin/internal/modules/adminuser/handler.go @@ -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 +} diff --git a/server/admin/internal/modules/adminuser/request.go b/server/admin/internal/modules/adminuser/request.go index 50a4b6e4..f290fe81 100644 --- a/server/admin/internal/modules/adminuser/request.go +++ b/server/admin/internal/modules/adminuser/request.go @@ -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"` +} diff --git a/server/admin/internal/modules/adminuser/routes.go b/server/admin/internal/modules/adminuser/routes.go index e39126f7..8708e619 100644 --- a/server/admin/internal/modules/adminuser/routes.go +++ b/server/admin/internal/modules/adminuser/routes.go @@ -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) } diff --git a/server/admin/internal/modules/adminuser/service.go b/server/admin/internal/modules/adminuser/service.go index 810f326e..1fd9a6f6 100644 --- a/server/admin/internal/modules/adminuser/service.go +++ b/server/admin/internal/modules/adminuser/service.go @@ -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 { diff --git a/server/admin/internal/modules/payment/dto.go b/server/admin/internal/modules/payment/dto.go index 4299ae4f..a53a1dc3 100644 --- a/server/admin/internal/modules/payment/dto.go +++ b/server/admin/internal/modules/payment/dto.go @@ -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"` diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index f8bf6ec6..61ddbc1e 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -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 组装支付后台 handler;wallet 负责账单和商品事实,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) { diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index 053f1fa1..3262dca2 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -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 { diff --git a/server/admin/internal/modules/payment/money.go b/server/admin/internal/modules/payment/money.go new file mode 100644 index 00000000..4048c19e --- /dev/null +++ b/server/admin/internal/modules/payment/money.go @@ -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)) +} diff --git a/server/admin/internal/modules/payment/routes.go b/server/admin/internal/modules/payment/routes.go index 687d4471..9768d995 100644 --- a/server/admin/internal/modules/payment/routes.go +++ b/server/admin/internal/modules/payment/routes.go @@ -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) diff --git a/server/admin/internal/repository/money_scope_repository.go b/server/admin/internal/repository/money_scope_repository.go new file mode 100644 index 00000000..20606d2b --- /dev/null +++ b/server/admin/internal/repository/money_scope_repository.go @@ -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 +} diff --git a/server/admin/internal/repository/money_scope_repository_test.go b/server/admin/internal/repository/money_scope_repository_test.go new file mode 100644 index 00000000..87c6d143 --- /dev/null +++ b/server/admin/internal/repository/money_scope_repository_test.go @@ -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() + } +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 575b649c..76cee373 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -76,6 +76,8 @@ func (s *Store) AutoMigrate() error { &model.LoginLog{}, &model.OperationLog{}, &model.DataScope{}, + &model.UserMoneyScope{}, + &model.TemporaryPaymentLinkOwner{}, &model.AdminJob{}, ) } diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 2ec025c2..75c65d8a 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -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", diff --git a/server/admin/migrations/064_money_scope_and_payment_link_owner.sql b/server/admin/migrations/064_money_scope_and_payment_link_owner.sql new file mode 100644 index 00000000..b5ebc7a8 --- /dev/null +++ b/server/admin/migrations/064_money_scope_and_payment_link_owner.sql @@ -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 '区域 ID,0 表示整个 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');