feat: 购买下单透传终端类型,手机端收银台拉起支付宝 App(契约 v1.1.0)

根因:pay 判手机/PC 用下单请求 UA,而下单由 jiu 后端 Go client 发出,
恒被判 PC 扫码页——手机用户拿不到 wap 拉起页。

- backend:/license/purchase 请求体加可选 client_type(oneof=pc mobile),
  缺省按本请求 UA 兜底(官网浏览器购买自动受益);CreatePurchase 透传给 pay;
  单测断言透传 + 既有用例补参
- client:createPurchase 支持 clientType;PurchaseCard 按平台声明
  (iOS/Android=mobile,桌面=pc,Web 不传走 UA),kIsWeb 先于 dart:io;
  launchUrl 外部浏览器不变,wap 收银台自动拉起支付宝
- 配套:pay-contract v1.1.0(744725b)+ pay 侧 resolveIsMobile(dbdadd1)已各自提交

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-04 10:31:49 +08:00
parent aa3aad417c
commit f3cfdcadca
5 changed files with 64 additions and 15 deletions
+24 -1
View File
@@ -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):
+10 -3
View File
@@ -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)
+8 -6
View File
@@ -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)
// 多租户隔离:别家买过不影响本店
@@ -41,10 +41,16 @@ class LicenseRepository {
}
/// 在线购买/续费下单(仅管理员),返回 pay 收银台跳转信息。
Future<PurchaseOrder> createPurchase(String bizCode) async {
/// [clientType] 终端类型("pc"/"mobile",可选):显式声明收银台形态——
/// mobile 走支付宝手机网站支付(H5 拉起 App),pc 走电脑扫码页;
/// 不传时后端按请求 UA 判断(浏览器场景 UA 真实,App 场景须显式传)。
Future<PurchaseOrder> 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<String, dynamic>);
} on DioException catch (e) {
throw AppException(
+13 -2
View File
@@ -4,7 +4,9 @@
// GET /license/purchase/:otnwebhook 续期到账后自动刷新授权并展示新到期日。
// 价格仅展示,实付以 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<PurchaseCard> {
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<void> _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;