343 lines
11 KiB
Go
Raw 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"
"database/sql"
"errors"
"math"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const usdtMicroUnit int64 = 1_000_000
type Handler struct {
wallet walletclient.Client
userDB *sql.DB
audit shared.OperationLogger
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{wallet: wallet, userDB: userDB, audit: audit}
}
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: queryInt64(c, "user_id", "userId"),
SellerUserId: queryInt64(c, "seller_user_id", "sellerUserId"),
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
// 账单事实仍由 wallet-service 返回;这里只按本页出现的付款用户和币商用户批量补展示资料,避免列表渲染时逐行查用户。
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "获取账单用户资料失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
// fillRechargeBillUsers 将用户资料写回当前页账单 DTO缺失资料会在 DTO 层保留长 ID避免影响账单事实展示。
func (h *Handler) fillRechargeBillUsers(ctx context.Context, appCode string, items []rechargeBillDTO) error {
userIDs := collectRechargeBillUserIDs(items)
if len(userIDs) == 0 {
return nil
}
profiles, err := h.loadRechargeBillUsers(ctx, appCode, userIDs)
if err != nil {
return err
}
for index := range items {
items[index].User = rechargeBillUserOrFallback(profiles[items[index].UserID], items[index].UserID)
items[index].Seller = rechargeBillUserOrFallback(profiles[items[index].SellerUserID], items[index].SellerUserID)
}
return nil
}
// loadRechargeBillUsers 按 app_code 和用户 ID 批量读取展示资料,返回 map 便于用户列和币商列复用同一次查询结果。
func (h *Handler) loadRechargeBillUsers(ctx context.Context, appCode string, userIDs []int64) (map[int64]rechargeBillUserDTO, error) {
if h == nil || h.userDB == nil {
return nil, errors.New("user mysql is not configured")
}
// 本查询只读取 users 表里的展示字段,按照 app_code 和 user_id 精确命中;区域和账单筛选仍以 wallet-service 返回为准。
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`,
append([]any{appCode}, int64Args(userIDs)...)...,
)
if err != nil {
return nil, err
}
defer rows.Close()
profiles := make(map[int64]rechargeBillUserDTO, len(userIDs))
for rows.Next() {
var profile rechargeBillUserDTO
var userID int64
if err := rows.Scan(&userID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
return nil, err
}
profile.UserID = strconv.FormatInt(userID, 10)
profiles[userID] = profile
}
if err := rows.Err(); err != nil {
return nil, err
}
return profiles, nil
}
func (h *Handler) ListRechargeProducts(c *gin.Context) {
options := shared.ListOptions(c)
resp, err := h.wallet.ListAdminRechargeProducts(c.Request.Context(), &walletv1.ListAdminRechargeProductsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Status: strings.TrimSpace(firstQuery(c, "status")),
Platform: strings.TrimSpace(firstQuery(c, "platform")),
RegionId: queryInt64(c, "region_id", "regionId"),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
writeWalletError(c, err, "获取内购配置失败")
return
}
items := make([]rechargeProductDTO, 0, len(resp.GetProducts()))
for _, item := range resp.GetProducts() {
items = append(items, rechargeProductFromProto(item))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) CreateRechargeProduct(c *gin.Context) {
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), &walletv1.CreateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "创建内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "create-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.Created(c, item)
}
func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
var request rechargeProductRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "内购配置参数不正确")
return
}
amountMicro, err := parseUSDTMicro(request.AmountUSDT, request.AmountUSDTMicro)
if err != nil {
response.BadRequest(c, "金额USDT不正确")
return
}
resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), &walletv1.UpdateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
AmountMicro: amountMicro,
CoinAmount: request.CoinAmount,
ProductName: strings.TrimSpace(request.ProductName),
Description: strings.TrimSpace(request.Description),
Platform: strings.TrimSpace(request.Platform),
RegionIds: request.RegionIDs,
Enabled: request.Enabled,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "修改内购配置失败")
return
}
item := rechargeProductFromProto(resp.GetProduct())
shared.OperationLogWithResourceID(c, h.audit, "update-recharge-product", "wallet_recharge_products", strconv.FormatInt(item.ProductID, 10), "success", item.ProductCode)
response.OK(c, item)
}
func (h *Handler) DeleteRechargeProduct(c *gin.Context) {
productID, ok := productID(c)
if !ok {
return
}
_, err := h.wallet.DeleteRechargeProduct(c.Request.Context(), &walletv1.DeleteRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "删除内购配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "delete-recharge-product", "wallet_recharge_products", strconv.FormatInt(productID, 10), "success", "")
response.OK(c, gin.H{"deleted": true})
}
type rechargeProductRequest struct {
AmountUSDT string `json:"amountUsdt"`
AmountUSDTMicro int64 `json:"amountUsdtMicro"`
CoinAmount int64 `json:"coinAmount"`
ProductName string `json:"productName"`
Description string `json:"description"`
Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}
func productID(c *gin.Context) (int64, bool) {
value := strings.TrimSpace(c.Param("product_id"))
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
response.BadRequest(c, "内购配置 ID 不正确")
return 0, false
}
return parsed, true
}
func actorID(c *gin.Context) int64 {
return int64(shared.ActorFromContext(c).UserID)
}
func parseUSDTMicro(amountText string, amountMicro int64) (int64, error) {
if amountMicro > 0 {
return amountMicro, nil
}
raw := strings.TrimSpace(amountText)
if raw == "" {
return 0, errors.New("amount is required")
}
if strings.HasPrefix(raw, "+") {
raw = strings.TrimPrefix(raw, "+")
}
parts := strings.Split(raw, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, errors.New("amount format is invalid")
}
if !allDigits(parts[0]) {
return 0, errors.New("amount integer part is invalid")
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || whole > math.MaxInt64/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
var fraction int64
if len(parts) == 2 {
if parts[1] == "" || len(parts[1]) > 6 || !allDigits(parts[1]) {
return 0, errors.New("amount fraction part is invalid")
}
padded := parts[1] + strings.Repeat("0", 6-len(parts[1]))
fraction, err = strconv.ParseInt(padded, 10, 64)
if err != nil {
return 0, errors.New("amount fraction part is invalid")
}
}
if whole > (math.MaxInt64-fraction)/usdtMicroUnit {
return 0, errors.New("amount is too large")
}
value := whole*usdtMicroUnit + fraction
if value <= 0 {
return 0, errors.New("amount must be positive")
}
return value, nil
}
func allDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return value != ""
}
func writeWalletError(c *gin.Context, err error, fallback string) {
message := fallback
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
message = st.Message()
}
switch status.Code(err) {
case codes.InvalidArgument, codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted:
response.BadRequest(c, message)
case codes.NotFound:
response.NotFound(c, message)
default:
response.ServerError(c, fallback)
}
}