bi报表和财务系统
This commit is contained in:
parent
1409529f10
commit
151cf495e4
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
653
server/admin/internal/modules/payment/money.go
Normal file
653
server/admin/internal/modules/payment/money.go
Normal 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))
|
||||
}
|
||||
@ -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)
|
||||
|
||||
215
server/admin/internal/repository/money_scope_repository.go
Normal file
215
server/admin/internal/repository/money_scope_repository.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -76,6 +76,8 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.LoginLog{},
|
||||
&model.OperationLog{},
|
||||
&model.DataScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.AdminJob{},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
Loading…
x
Reference in New Issue
Block a user