2026-07-02 10:53:44 +08:00

674 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
financeViewPermission = "finance:view"
legacyMoneyViewPermission = "money:view"
temporaryPaymentLinkCreatePermission = "payment-temporary-link:create"
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) {
appCode = appctx.Normalize(appCode)
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" || countryCode == "GLOBAL" {
return false, nil
}
for _, source := range h.moneyRegionSources {
// legacy App 的区域-国家关系和区域目录同源source 明确命中时直接返回,避免 Yumi/Aslan 回落到 hyapp_user 本地表。
ok, handled, err := source.CountryBelongsToRegion(ctx, appCode, regionID, countryCode)
if err != nil {
return false, err
}
if handled {
return ok, nil
}
}
if h.userDB == nil {
return false, errors.New("user mysql is not configured")
}
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
}
// Yumi/Aslan 这类 legacy App 的区域事实来自线上 likei Mongo这里合并目录数据但财务授权仍只保存 app_code + region_id。
sourceApps, sourceRegions, sourceCountries, err := h.listMoneyRegionSources(ctx, access)
if err != nil {
return nil, nil, nil, err
}
apps = appendUniqueMoneyApps(apps, sourceApps)
regions = appendUniqueMoneyRegions(regions, sourceRegions)
countries = appendUniqueMoneyCountries(countries, sourceCountries)
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))
}