三方汇率支持
This commit is contained in:
parent
e385704a7d
commit
72ec2bfcc9
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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()) != "" {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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-service,wallet 只接受内部 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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user