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)) }