2026-06-28 23:24:57 +08:00

654 lines
20 KiB
Go

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