343 lines
11 KiB
Go
343 lines
11 KiB
Go
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 组装支付后台 handler;wallet 负责账单和商品事实,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)
|
||
}
|
||
}
|