diff --git a/backend/internal/handler/pay.go b/backend/internal/handler/pay.go index f585dd1..5579461 100644 --- a/backend/internal/handler/pay.go +++ b/backend/internal/handler/pay.go @@ -4,6 +4,7 @@ import ( "errors" "log" "net/http" + "strings" "github.com/gin-gonic/gin" @@ -12,6 +13,17 @@ import ( "github.com/wangjia/jiu/backend/internal/util" ) +// isMobileUA 判断是否手机浏览器(词表与 pay 侧 handler/order.go 保持一致)。 +func isMobileUA(ua string) bool { + ua = strings.ToLower(ua) + for _, kw := range []string{"android", "iphone", "ipod", "mobile", "harmony", "windows phone"} { + if strings.Contains(ua, kw) { + return true + } + } + return false +} + type PayHandler struct { svc *service.PayService } @@ -30,13 +42,24 @@ func (h *PayHandler) Purchase(c *gin.Context) { } var req struct { BizCode string `json:"biz_code" binding:"required"` + // 终端类型(可选,"pc"/"mobile"):App 显式声明优先;缺省按本请求 UA 判断 + // (官网浏览器购买的 UA 是真实终端 UA)。透传给 pay 决定收银台形态。 + ClientType string `json:"client_type" binding:"omitempty,oneof=pc mobile"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + clientType := req.ClientType + if clientType == "" { + if isMobileUA(c.Request.UserAgent()) { + clientType = "mobile" + } else { + clientType = "pc" + } + } - res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode) + res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode, clientType) if err != nil { switch { case errors.Is(err, service.ErrPayNotConfigured): diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go index 078be9d..38a866d 100644 --- a/backend/internal/service/pay.go +++ b/backend/internal/service/pay.go @@ -90,7 +90,10 @@ type PurchaseResult struct { } // CreatePurchase 建购买记录并调 pay 下单,返回收银台跳转 URL。 -func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*PurchaseResult, error) { +// clientType(契约 v1.1.0,"pc"/"mobile"/""):显式端型透传给 pay 决定收银台形态 +// (mobile=手机网站支付拉起支付宝 App / pc=电脑扫码页);服务端间调用下单请求 UA +// 是本后端的,不传则 pay 会按 Go client UA 误判为 PC。 +func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode, clientType string) (*PurchaseResult, error) { if !s.Configured() { return nil, ErrPayNotConfigured } @@ -117,12 +120,16 @@ func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*Pur return nil, err } - reqBody, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "product_id": productID, "biz_system": "jiu", "biz_ref": strconv.FormatUint(p.ID, 10), "return_url": s.retURL, - }) + } + if clientType != "" { + payload["client_type"] = clientType + } + reqBody, _ := json.Marshal(payload) respBody, err := s.signedPost("/api/v1/orders", reqBody) if err != nil { return nil, fmt.Errorf("pay 下单失败: %w", err) diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go index 53ac1ee..87f5555 100644 --- a/backend/internal/service/pay_test.go +++ b/backend/internal/service/pay_test.go @@ -234,7 +234,7 @@ func TestCreatePurchase_HappyPath(t *testing.T) { mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`) }) - var gotBizRef string + var gotBizRef, gotClientType string mux.HandleFunc("POST /api/v1/orders", func(w http.ResponseWriter, r *http.Request) { body := make([]byte, r.ContentLength) _, _ = r.Body.Read(body) @@ -246,13 +246,14 @@ func TestCreatePurchase_HappyPath(t *testing.T) { var req map[string]any _ = json.Unmarshal(body, &req) gotBizRef, _ = req["biz_ref"].(string) + gotClientType, _ = req["client_type"].(string) fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`) }) payServer := httptest.NewServer(mux) defer payServer.Close() svc := newTestPaySvc(db, payServer.URL) - res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard") + res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard", "mobile") require.NoError(t, err) assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL) assert.Equal(t, "yanmei-new-1", res.OutTradeNo) @@ -262,16 +263,17 @@ func TestCreatePurchase_HappyPath(t *testing.T) { assert.Equal(t, "pending", p.Status) assert.Equal(t, "2999.00", p.Amount) assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id") + assert.Equal(t, "mobile", gotClientType, "client_type 应透传给 pay(契约 v1.1.0)") } func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) { db := testutil.SetupTestDB() svc := newTestPaySvc(db, "http://pay.invalid") - _, err := svc.CreatePurchase(1, 1, "no_such_plan") + _, err := svc.CreatePurchase(1, 1, "no_such_plan", "pc") assert.ErrorIs(t, err, ErrUnknownPlan) unconfigured := NewPayService(db, "http://pay.invalid", "", "") - _, err = unconfigured.CreatePurchase(1, 1, "annual_standard") + _, err = unconfigured.CreatePurchase(1, 1, "annual_standard", "pc") assert.ErrorIs(t, err, ErrPayNotConfigured) } @@ -287,7 +289,7 @@ func TestCreatePurchase_PromoOncePerShop(t *testing.T) { used, err := svc.PromoUsed(shop.ID) require.NoError(t, err) assert.False(t, used) - _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode) + _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode, "pc") assert.NotErrorIs(t, err, ErrPromoUsed) // pending 单不算已享用(可能弃单),仍可重新下单 @@ -302,7 +304,7 @@ func TestCreatePurchase_PromoOncePerShop(t *testing.T) { used, err = svc.PromoUsed(shop.ID) require.NoError(t, err) assert.True(t, used) - _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode) + _, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode, "pc") assert.ErrorIs(t, err, ErrPromoUsed) // 多租户隔离:别家买过不影响本店 diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart index c8b1b28..c98fb31 100644 --- a/client/lib/repositories/license_repository.dart +++ b/client/lib/repositories/license_repository.dart @@ -41,10 +41,16 @@ class LicenseRepository { } /// 在线购买/续费下单(仅管理员),返回 pay 收银台跳转信息。 - Future createPurchase(String bizCode) async { + /// [clientType] 终端类型("pc"/"mobile",可选):显式声明收银台形态—— + /// mobile 走支付宝手机网站支付(H5 拉起 App),pc 走电脑扫码页; + /// 不传时后端按请求 UA 判断(浏览器场景 UA 真实,App 场景须显式传)。 + Future createPurchase(String bizCode, + {String? clientType}) async { try { - final resp = - await _client.post('/license/purchase', data: {'biz_code': bizCode}); + final resp = await _client.post('/license/purchase', data: { + 'biz_code': bizCode, + if (clientType != null) 'client_type': clientType, + }); return PurchaseOrder.fromJson(resp.data['data'] as Map); } on DioException catch (e) { throw AppException( diff --git a/client/lib/screens/settings/purchase_card.dart b/client/lib/screens/settings/purchase_card.dart index c3beb07..c1884cc 100644 --- a/client/lib/screens/settings/purchase_card.dart +++ b/client/lib/screens/settings/purchase_card.dart @@ -4,7 +4,9 @@ // GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。 // 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。 import 'dart:async'; +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -73,13 +75,22 @@ class _PurchaseCardState extends ConsumerState { super.dispose(); } + /// 终端类型(契约 v1.1.0):手机 App 显式声明 mobile 让 pay 出「手机网站支付」 + /// 收银台(H5 拉起支付宝 App);桌面 App 声明 pc(扫码页);Web 端不传, + /// 后端按浏览器真实 UA 判断。kIsWeb 判断先于 dart:io(项目规则)。 + String? get _clientType { + if (kIsWeb) return null; + return (Platform.isIOS || Platform.isAndroid) ? 'mobile' : 'pc'; + } + Future _submit() async { setState(() => _submitting = true); try { final order = await ref .read(licenseRepositoryProvider) - .createPurchase(_plan.bizCode); - // 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器拉起支付宝 + .createPurchase(_plan.bizCode, clientType: _clientType); + // 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器打开 + // wap 收银台自动拉起支付宝 App await launchUrl(Uri.parse(order.payUrl), mode: LaunchMode.externalApplication); if (!mounted) return;