diff --git a/server/admin/go.mod b/server/admin/go.mod index 24abda44..fad30ce1 100644 --- a/server/admin/go.mod +++ b/server/admin/go.mod @@ -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 ) diff --git a/server/admin/go.sum b/server/admin/go.sum index fd81cfe0..c37078ee 100644 --- a/server/admin/go.sum +++ b/server/admin/go.sum @@ -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= diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index 2c6afb98..7b09f6b2 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -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()) != "" { diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index 173c221c..f812e3f4 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -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)) diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index baadfac6..b20fd73f 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -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{ diff --git a/server/admin/internal/modules/resource/handler_test.go b/server/admin/internal/modules/resource/handler_test.go index 290e89ed..4e67cb8f 100644 --- a/server/admin/internal/modules/resource/handler_test.go +++ b/server/admin/internal/modules/resource/handler_test.go @@ -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 +} diff --git a/server/admin/internal/modules/resource/operator.go b/server/admin/internal/modules/resource/operator.go index 48a8cc3e..0d0d0c94 100644 --- a/server/admin/internal/modules/resource/operator.go +++ b/server/admin/internal/modules/resource/operator.go @@ -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 {