三方汇率支持

This commit is contained in:
zhx 2026-06-25 17:00:11 +08:00
parent e385704a7d
commit 72ec2bfcc9
7 changed files with 161 additions and 13 deletions

View File

@ -3,6 +3,7 @@ module hyapp-admin-server
go 1.26.3
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.2.1
@ -10,7 +11,6 @@ require (
github.com/tencentyun/cos-go-sdk-v5 v0.7.66
golang.org/x/crypto v0.32.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.35.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
@ -57,4 +57,5 @@ require (
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
)

View File

@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@ -59,6 +61,7 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=

View File

@ -357,6 +357,14 @@ func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
}
func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
var request thirdPartyPaymentRateSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
// 同步接口历史上允许空 body 直接触发;现在弹窗只新增上浮比例,空 body 仍按 0% 处理,避免旧后台或脚本调用被破坏。
if err := c.ShouldBindJSON(&request); err != nil || !validThirdPartyPaymentRateMarkup(request.MarkupPercent) {
response.BadRequest(c, "支付汇率同步参数不正确")
return
}
}
resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
@ -373,7 +381,7 @@ func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
response.OK(c, thirdPartyPaymentRateSyncDTO{UpdatedCount: 0})
return
}
// 汇率同步必须以服务端实时拉取结果为准:前端按钮只触发动作,不传任何汇率,避免浏览器篡改本币金额
// 汇率同步必须以服务端实时拉取结果为准:前端只传运营确认的上浮比例,原始汇率、失败切换和最终写库都留在服务端收敛
rateClient := h.exchangeRates
if rateClient == nil {
rateClient = newDefaultExchangeRateClient()
@ -385,10 +393,11 @@ func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
}
updated := 0
skipped := make(map[string]struct{})
writtenRates := make(map[string]string, len(rateResult.Rates))
for _, method := range methods {
currencyCode := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode()))
rate := rateResult.Rates[currencyCode]
if rate == "" {
rate, ok := applyThirdPartyPaymentRateMarkup(rateResult.Rates[currencyCode], request.MarkupPercent)
if !ok {
skipped[currencyCode] = struct{}{}
continue
}
@ -402,6 +411,7 @@ func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
writeWalletError(c, err, "写入支付汇率失败")
return
}
writtenRates[currencyCode] = rate
updated++
}
skippedCurrencies := sortedMapKeys(skipped)
@ -412,7 +422,7 @@ func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
MissingCurrencies: skippedCurrencies,
SourceNames: rateResult.SourceNames,
Sources: rateResult.Sources,
Rates: rateResult.Rates,
Rates: writtenRates,
})
}
@ -436,6 +446,10 @@ type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
type thirdPartyPaymentRateSyncRequest struct {
MarkupPercent float64 `json:"markupPercent"`
}
func queryInt64(c *gin.Context, keys ...string) int64 {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
@ -532,6 +546,27 @@ func allDigits(value string) bool {
return value != ""
}
func validThirdPartyPaymentRateMarkup(markupPercent float64) bool {
// 上浮比例来自后台运营弹窗,只允许 0-100 的有限数值;负数会压低支付汇率,过大数值会直接放大用户本币支付金额。
return !math.IsNaN(markupPercent) && !math.IsInf(markupPercent, 0) && markupPercent >= 0 && markupPercent <= 100
}
func applyThirdPartyPaymentRateMarkup(rateText string, markupPercent float64) (string, bool) {
rawRate, err := strconv.ParseFloat(strings.TrimSpace(rateText), 64)
if err != nil || rawRate <= 0 || !validThirdPartyPaymentRateMarkup(markupPercent) {
return "", false
}
// 汇率源返回的是实时 USD->本币原值;写库前统一叠加运营设置的上浮比例,再按 1 位小数向上取,保证 H5 下单看到的支付汇率不低于实时汇率。
markedRate := rawRate * (1 + markupPercent/100)
roundedRate := math.Ceil(markedRate*10-1e-9) / 10
return trimOneDecimalRate(strconv.FormatFloat(roundedRate, 'f', 1, 64)), true
}
func trimOneDecimalRate(rateText string) string {
// 产品要求“最多 1 位小数”,整数汇率不保留无意义的 .0,避免后台列表和人工核对时出现多余精度。
return strings.TrimSuffix(rateText, ".0")
}
func writeWalletError(c *gin.Context, err error, fallback string) {
message := fallback
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {

View File

@ -215,7 +215,7 @@ func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
}
}
func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *testing.T) {
func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
AppCode: "lalu",
@ -242,7 +242,8 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
SourceNames: []string{"open.er-api.com"},
}}
router := newPaymentHandlerTestRouter(handler)
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", nil)
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":3}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -263,7 +264,7 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
}
got[req.GetMethodId()] = req.GetUsdToCurrencyRate()
}
if got[810] != "3.75000000" || got[811] != "0.37600000" || got[2310] != "3.75000000" {
if got[810] != "3.9" || got[811] != "0.4" || got[2310] != "3.9" {
t.Fatalf("synced rates mismatch: %+v", got)
}
var response adminPaymentTestResponse
@ -275,6 +276,23 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
}
}
func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", bytes.NewBufferString(`{"markupPercent":-1}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if wallet.lastThirdPartyChannels != nil {
t.Fatalf("invalid markup must fail before reading payment methods: %+v", wallet.lastThirdPartyChannels)
}
}
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))

View File

@ -664,8 +664,17 @@ func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
func (h *Handler) ListResourceGrants(c *gin.Context) {
options := shared.ListOptions(c)
targetUserID, ok := optionalInt64Query(c, "target_user_id", "targetUserId")
if !ok {
targetUserID, _, badRequest, err := h.resolveResourceGrantListTargetUserID(
c.Request.Context(),
appctx.FromContext(c.Request.Context()),
firstQuery(c, "target_user_id", "targetUserId"),
)
if err != nil {
if badRequest {
response.BadRequest(c, err.Error())
return
}
response.ServerError(c, "查询赠送用户失败")
return
}
resp, err := h.wallet.ListResourceGrants(c.Request.Context(), &walletv1.ListResourceGrantsRequest{

View File

@ -14,6 +14,7 @@ import (
"hyapp-admin-server/internal/middleware"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
@ -151,6 +152,51 @@ func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) {
}
}
func TestListResourceGrantsResolvesDisplayIDBeforeWalletFilter(t *testing.T) {
db, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sql mock failed: %v", err)
}
defer db.Close()
wallet := &mockResourceWallet{}
router := newResourceHandlerTestRouter(New(wallet, nil, db, time.Second, nil))
resolvedUserID := int64(327702592329093120)
sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`).
WithArgs(
"lalu",
"111",
"111",
"111",
sqlmock.AnyArg(),
"111",
"%111%",
"111",
"111",
"111",
sqlmock.AnyArg(),
"111",
).
WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(resolvedUserID))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/admin/resource-grants?target_user_id=111", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list grants status mismatch: %d %s", recorder.Code, recorder.Body.String())
}
if len(wallet.listGrantRequests) != 1 {
t.Fatalf("expected one wallet list request, got %d", len(wallet.listGrantRequests))
}
if got := wallet.listGrantRequests[0].GetTargetUserId(); got != resolvedUserID {
t.Fatalf("target display id should be resolved before wallet filter: got %d want %d", got, resolvedUserID)
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
@ -188,9 +234,10 @@ func newResourceHandlerTestRouter(handler *Handler) *gin.Engine {
type mockResourceWallet struct {
walletclient.Client
resources map[int64]*walletv1.Resource
updates []*walletv1.UpdateResourceRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest
resources map[int64]*walletv1.Resource
updates []*walletv1.UpdateResourceRequest
deletedGifts []*walletv1.DeleteGiftConfigRequest
listGrantRequests []*walletv1.ListResourceGrantsRequest
}
func (m *mockResourceWallet) GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
@ -236,3 +283,10 @@ func (m *mockResourceWallet) DeleteGiftConfig(ctx context.Context, req *walletv1
Name: "Rose",
}}, nil
}
func (m *mockResourceWallet) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) {
// 列表过滤的修正点发生在 admin-server 调 wallet 之前;测试只需要捕获请求形状,
// 返回空页即可避免后续用户资料补全查询干扰断言。
m.listGrantRequests = append(m.listGrantRequests, req)
return &walletv1.ListResourceGrantsResponse{Grants: []*walletv1.ResourceGrant{}, Total: 0}, nil
}

View File

@ -2,7 +2,9 @@ package resource
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
@ -115,6 +117,32 @@ func applyGrantTargetUsers(grants []grantDTO, users map[int64]grantUserDTO) {
}
}
func (h *Handler) resolveResourceGrantListTargetUserID(ctx context.Context, appCode string, raw string) (int64, bool, bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false, false, nil
}
// 资源赠送记录的最终 owner 是 wallet-servicewallet 只接受内部 user_id 过滤;
// 后台列表输入框展示的是“用户信息”,运营会输入默认短号或靓号,所以这里先按 user-service 身份投影解析。
// 解析不到时保留旧版数字 target_user_id 行为,避免用户资料缺失时历史赠送记录完全查不到。
fallbackUserID, parseErr := strconv.ParseInt(raw, 10, 64)
if h != nil && h.userDB != nil {
resolvedUserID, filtered, err := h.resolveResourceShopPurchaseUserID(ctx, appCode, raw)
switch {
case err == nil && filtered:
return resolvedUserID, true, false, nil
case err == sql.ErrNoRows:
// 没有用户身份投影时继续尝试按内部 user_id 过滤,兼容历史孤儿赠送记录。
case err != nil:
return 0, true, false, err
}
}
if parseErr != nil || fallbackUserID < 0 {
return 0, true, true, fmt.Errorf("target_user_id 参数不正确")
}
return fallbackUserID, true, false, nil
}
func (h *Handler) enrichResourceShopPurchaseUsers(ctx context.Context, orders []resourceShopPurchaseOrderDTO) error {
ids := collectResourceShopPurchaseUserIDs(orders)
if len(ids) == 0 {