From eb961c3a74ce19a147e8b4ab58bdc4022270ac2a Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 22:07:47 +0800 Subject: [PATCH] feat(h5): unify WeChat OAuth under m.runxian.top path routing Serve user/shop/partner H5 under /user/, /shop/, /partner/ on a single authorized domain; update nginx, SSL script, and router base paths. Co-authored-by: Cursor --- .../src/contexts/PartnerSessionContext.tsx | 3 +- apps/h5-partner/src/lib/api.ts | 6 +- apps/h5-partner/src/main.tsx | 3 +- apps/h5-partner/vite.config.ts | 1 + apps/h5-shop/src/main.tsx | 3 +- apps/h5-shop/src/pages/HomePage.tsx | 2 +- apps/h5-shop/vite.config.ts | 1 + apps/h5-user/src/lib/pay-wechat.ts | 3 +- apps/h5-user/src/main.tsx | 3 +- apps/h5-user/vite.config.ts | 1 + deploy/enable-runxian-dukang-ssl.sh | 14 ++- deploy/nginx-runxian-dukang-ssl.conf | 99 +++++++++++++------ deploy/nginx-runxian-dukang.conf | 66 +++++++++---- packages/weixin-sdk/src/app-path.ts | 25 +++++ packages/weixin-sdk/src/index.ts | 1 + scripts/configure-oss-cors.mjs | 4 + server/dukang-api/.env.example | 6 +- server/dukang-api/.env.production.example | 3 + .../modules/ops/admin-promo-codes.service.ts | 2 +- 19 files changed, 178 insertions(+), 68 deletions(-) create mode 100644 packages/weixin-sdk/src/app-path.ts diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index 0a6af33..ceb2183 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; +import { toAppPath } from '@dukang/weixin-sdk'; import { clearAuth, isLoggedIn, request } from '../lib/api'; export type PartnerAccount = { @@ -43,7 +44,7 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) { const logout = useCallback(() => { clearAuth(); setAccount(null); - window.location.href = '/login'; + window.location.href = toAppPath('/login'); }, []); useEffect(() => { diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 06e737a..16df1e6 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -1,3 +1,5 @@ +import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; + export const apiBase = '/api/v1'; export async function request(clientApp: string, path: string, options: RequestInit = {}): Promise { @@ -12,8 +14,8 @@ export async function request(clientApp: string, path: string, options: Reque const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' })); if (res.status === 401 || json.code === 401) { clearAuth(); - if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { - window.location.href = '/login'; + if (typeof window !== 'undefined' && !isOnAppPath('/login')) { + window.location.href = toAppPath('/login'); } throw new Error(json.message || '登录已过期,请重新登录'); } diff --git a/apps/h5-partner/src/main.tsx b/apps/h5-partner/src/main.tsx index fef9f9a..9ad9251 100644 --- a/apps/h5-partner/src/main.tsx +++ b/apps/h5-partner/src/main.tsx @@ -1,13 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { getRouterBasename } from '@dukang/weixin-sdk'; import App from './App'; import { PartnerSessionProvider } from './contexts/PartnerSessionContext'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/apps/h5-partner/vite.config.ts b/apps/h5-partner/vite.config.ts index 3482978..ea673dc 100644 --- a/apps/h5-partner/vite.config.ts +++ b/apps/h5-partner/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ + base: '/partner/', plugins: [react()], resolve: { alias: { diff --git a/apps/h5-shop/src/main.tsx b/apps/h5-shop/src/main.tsx index 50cf420..29a6ad7 100644 --- a/apps/h5-shop/src/main.tsx +++ b/apps/h5-shop/src/main.tsx @@ -1,13 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { getRouterBasename } from '@dukang/weixin-sdk'; import { StoreSessionProvider } from './contexts/StoreSessionContext'; import App from './App'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 0fc1fc4..96e432f 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -24,7 +24,7 @@ function formatMoney(n: number) { function formatScanError(e: unknown): string { const msg = e instanceof Error ? e.message : '扫码失败,请重试'; if (/invalid signature/i.test(msg)) { - return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 shop.runxian.top,并刷新页面后重试'; + return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 m.runxian.top,并刷新页面后重试'; } return msg; } diff --git a/apps/h5-shop/vite.config.ts b/apps/h5-shop/vite.config.ts index c03c985..c33c06a 100644 --- a/apps/h5-shop/vite.config.ts +++ b/apps/h5-shop/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ + base: '/shop/', plugins: [react()], resolve: { alias: { diff --git a/apps/h5-user/src/lib/pay-wechat.ts b/apps/h5-user/src/lib/pay-wechat.ts index 8387bfb..85a5bba 100644 --- a/apps/h5-user/src/lib/pay-wechat.ts +++ b/apps/h5-user/src/lib/pay-wechat.ts @@ -1,5 +1,6 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types'; +import { toAppPath } from '@dukang/weixin-sdk'; import { isWechatEnv, weixinSdk } from './weixin'; import { request, saveSession, type UserProfile } from './api'; @@ -48,5 +49,5 @@ export async function authorizeWechatForPay(): Promise } export function buildLoginReturnUrl(pathname: string, search: string) { - return `/login?return=${encodeURIComponent(`${pathname}${search}`)}`; + return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`; } diff --git a/apps/h5-user/src/main.tsx b/apps/h5-user/src/main.tsx index a170668..fcde7ac 100644 --- a/apps/h5-user/src/main.tsx +++ b/apps/h5-user/src/main.tsx @@ -1,12 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { getRouterBasename } from '@dukang/weixin-sdk'; import App from './App'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + , diff --git a/apps/h5-user/vite.config.ts b/apps/h5-user/vite.config.ts index ff8d8a9..5daf8b8 100644 --- a/apps/h5-user/vite.config.ts +++ b/apps/h5-user/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ + base: '/user/', plugins: [react()], resolve: { alias: { diff --git a/deploy/enable-runxian-dukang-ssl.sh b/deploy/enable-runxian-dukang-ssl.sh index 4240266..04650e4 100644 --- a/deploy/enable-runxian-dukang-ssl.sh +++ b/deploy/enable-runxian-dukang-ssl.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# 为杜康 runxian.top 五域名申请 Let's Encrypt 并切换 HTTPS 配置 +# 为杜康 runxian.top 申请 Let's Encrypt 并切换 HTTPS 配置 +# 三端 H5 统一入口 m.runxian.top(微信网页授权单域名) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" DOMAINS=( + m.runxian.top user.runxian.top shop.runxian.top partner.runxian.top @@ -25,7 +27,8 @@ mkdir -p /var/www/certbot /var/log/nginx/dukang echo "==> 申请证书..." certbot certonly --webroot -w /var/www/certbot \ - --cert-name user.runxian.top \ + --cert-name m.runxian.top \ + -d m.runxian.top \ -d user.runxian.top \ -d shop.runxian.top \ -d partner.runxian.top \ @@ -43,13 +46,14 @@ systemctl reload nginx echo "==> 验证..." for url in \ + https://m.runxian.top/user/ \ + https://m.runxian.top/shop/ \ + https://m.runxian.top/partner/ \ https://user.runxian.top/ \ - https://shop.runxian.top/ \ - https://partner.runxian.top/ \ https://webadmin.runxian.top/ \ https://dkapi.runxian.top/api/v1/health; do code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)" echo " $url -> $code" done -echo "==> runxian.top 杜康域名 HTTPS 已启用" +echo "==> runxian.top 杜康域名 HTTPS 已启用(H5 统一入口 m.runxian.top)" diff --git a/deploy/nginx-runxian-dukang-ssl.conf b/deploy/nginx-runxian-dukang-ssl.conf index b240a5c..d659637 100644 --- a/deploy/nginx-runxian-dukang-ssl.conf +++ b/deploy/nginx-runxian-dukang-ssl.conf @@ -1,28 +1,16 @@ -# 杜康好客 — runxian.top HTTPS(证书申请后启用) +# 杜康好客 — runxian.top HTTPS # 执行: deploy/enable-runxian-dukang-ssl.sh - -map $host $dukang_runxian_port { - user.runxian.top 8091; - shop.runxian.top 8092; - partner.runxian.top 8093; - webadmin.runxian.top 8094; -} +# 三端 H5 统一入口 m.runxian.top/{user,shop,partner}/ server { listen 80; - server_name user.runxian.top shop.runxian.top partner.runxian.top; + server_name m.runxian.top user.runxian.top shop.runxian.top partner.runxian.top; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; default_type "text/plain"; } - location ~ ^/MP_verify_.*\.txt$ { - root /opt/dukang-haoke/apps/h5-user/dist; - default_type text/plain; - access_log off; - } - location / { return 301 https://$host$request_uri; } @@ -30,18 +18,24 @@ server { server { listen 443 ssl; - server_name user.runxian.top shop.runxian.top partner.runxian.top; + server_name m.runxian.top; - access_log /var/log/nginx/dukang/runxian-h5.access.log main; - error_log /var/log/nginx/dukang/runxian-h5.error.log warn; + access_log /var/log/nginx/dukang/runxian-m.access.log main; + error_log /var/log/nginx/dukang/runxian-m.error.log warn; - ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; client_max_body_size 20m; + location ~ ^/MP_verify_.*\.txt$ { + root /opt/dukang-haoke/apps/h5-user/dist; + default_type text/plain; + access_log off; + } + location /api/ { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; @@ -52,14 +46,57 @@ server { proxy_set_header Connection ""; } - location / { - proxy_pass http://127.0.0.1:$dukang_runxian_port; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + location = / { + return 302 /user/; } + + location /user/ { + alias /opt/dukang-haoke/apps/h5-user/dist/; + try_files $uri $uri/ index.html; + } + + location /shop/ { + alias /opt/dukang-haoke/apps/h5-shop/dist/; + try_files $uri $uri/ index.html; + } + + location /partner/ { + alias /opt/dukang-haoke/apps/h5-partner/dist/; + try_files $uri $uri/ index.html; + } +} + +server { + listen 443 ssl; + server_name user.runxian.top; + + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + return 301 https://m.runxian.top/user$request_uri; +} + +server { + listen 443 ssl; + server_name shop.runxian.top; + + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + return 301 https://m.runxian.top/shop$request_uri; +} + +server { + listen 443 ssl; + server_name partner.runxian.top; + + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + return 301 https://m.runxian.top/partner$request_uri; } server { @@ -83,8 +120,8 @@ server { access_log /var/log/nginx/dukang/runxian-api.access.log main; error_log /var/log/nginx/dukang/runxian-api.error.log warn; - ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; @@ -122,8 +159,8 @@ server { access_log /var/log/nginx/dukang/runxian-admin.access.log main; error_log /var/log/nginx/dukang/runxian-admin.error.log warn; - ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem; + ssl_certificate /etc/letsencrypt/live/m.runxian.top/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.runxian.top/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; diff --git a/deploy/nginx-runxian-dukang.conf b/deploy/nginx-runxian-dukang.conf index 7247625..6487cf3 100644 --- a/deploy/nginx-runxian-dukang.conf +++ b/deploy/nginx-runxian-dukang.conf @@ -1,21 +1,16 @@ -# 杜康好客 — runxian.top 别名域名(与 lingshivip.cn 同后端) -# user/shop/partner → 8091/8092/8093;webadmin → 8094;dkapi → 8090 -# 证书:certbot --cert-name user.runxian.top -d user.runxian.top -d shop.runxian.top ... +# 杜康好客 — runxian.top(HTTP) +# 三端 H5 统一入口 m.runxian.top/{user,shop,partner}/(微信网页授权单域名) +# 旧子域名 user/shop/partner 301 到统一入口;webadmin → 8094;dkapi → 8090 -map $host $dukang_runxian_port { - user.runxian.top 8091; - shop.runxian.top 8092; - partner.runxian.top 8093; - webadmin.runxian.top 8094; -} - -# --- HTTP: H5 三端(校验文件 + API;证书就绪后见 dukang-runxian-ssl.conf)--- +# --- 统一 H5 入口(证书就绪前 HTTP;HTTPS 见 dukang-runxian-ssl.conf)--- server { listen 80; - server_name user.runxian.top shop.runxian.top partner.runxian.top; + server_name m.runxian.top; - access_log /var/log/nginx/dukang/runxian-h5.access.log main; - error_log /var/log/nginx/dukang/runxian-h5.error.log warn; + access_log /var/log/nginx/dukang/runxian-m.access.log main; + error_log /var/log/nginx/dukang/runxian-m.error.log warn; + + client_max_body_size 20m; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; @@ -38,14 +33,43 @@ server { proxy_set_header Connection ""; } - location / { - proxy_pass http://127.0.0.1:$dukang_runxian_port; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + location = / { + return 302 /user/; } + + location /user/ { + alias /opt/dukang-haoke/apps/h5-user/dist/; + try_files $uri $uri/ index.html; + } + + location /shop/ { + alias /opt/dukang-haoke/apps/h5-shop/dist/; + try_files $uri $uri/ index.html; + } + + location /partner/ { + alias /opt/dukang-haoke/apps/h5-partner/dist/; + try_files $uri $uri/ index.html; + } +} + +# --- 旧 H5 子域名 → 统一入口(保留书签/推广链接)--- +server { + listen 80; + server_name user.runxian.top; + return 301 http://m.runxian.top/user$request_uri; +} + +server { + listen 80; + server_name shop.runxian.top; + return 301 http://m.runxian.top/shop$request_uri; +} + +server { + listen 80; + server_name partner.runxian.top; + return 301 http://m.runxian.top/partner$request_uri; } # --- HTTP: API --- diff --git a/packages/weixin-sdk/src/app-path.ts b/packages/weixin-sdk/src/app-path.ts new file mode 100644 index 0000000..010af01 --- /dev/null +++ b/packages/weixin-sdk/src/app-path.ts @@ -0,0 +1,25 @@ +/// + +function normalizeBase(base?: string): string { + if (!base || base === '/') return ''; + return base.replace(/\/$/, ''); +} + +/** React Router basename(来自 Vite `base`) */ +export function getRouterBasename(baseUrl?: string): string | undefined { + const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL); + return base || undefined; +} + +/** 拼接带 Vite base 前缀的应用内路径(用于 window.location 跳转) */ +export function toAppPath(path: string, baseUrl?: string): string { + const base = normalizeBase(baseUrl ?? import.meta.env?.BASE_URL); + const normalized = path.startsWith('/') ? path : `/${path}`; + return `${base}${normalized}`; +} + +export function isOnAppPath(path: string, baseUrl?: string): boolean { + if (typeof window === 'undefined') return false; + const target = toAppPath(path, baseUrl); + return window.location.pathname === target || window.location.pathname.startsWith(`${target}/`); +} diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts index d94f1be..abbedb6 100644 --- a/packages/weixin-sdk/src/index.ts +++ b/packages/weixin-sdk/src/index.ts @@ -1,4 +1,5 @@ export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env'; +export { getRouterBasename, toAppPath, isOnAppPath } from './app-path'; export { initWechatJssdk, ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, stripOAuthParamsFromLocation } from './jssdk'; export { getWechatLocation, diff --git a/scripts/configure-oss-cors.mjs b/scripts/configure-oss-cors.mjs index c760789..3212980 100644 --- a/scripts/configure-oss-cors.mjs +++ b/scripts/configure-oss-cors.mjs @@ -54,6 +54,10 @@ const defaultOrigins = [ 'http://127.0.0.1:5173', 'http://127.0.0.1:5174', 'http://127.0.0.1:5175', + 'http://localhost:5173/user', + 'http://localhost:5174/shop', + 'http://localhost:5175/partner', + 'https://m.runxian.top', ]; const allowedOrigin = (OSS_CORS_ORIGINS ?? defaultOrigins.join(',')) diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index fbe4485..b668735 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -26,13 +26,15 @@ MOCK_WECHAT=true WX_AUTHORIZE=false # C 端 H5 落地页(推广码二维码链接前缀) -USER_H5_URL=http://localhost:5173 +# 本地开发:http://localhost:5173/user 生产统一入口:https://m.runxian.top/user +USER_H5_URL=http://localhost:5173/user # 反向代理后提取真实客户端 IP(下单 IP 定位) TRUST_PROXY=true # 微信 SDK(生产:WECHAT_AUTH_ENABLED=true,配置 WX_APP_ID / WX_APP_SECRET) -# OAuth 授权页由 /common/wechat/oauth-url 生成;C/合伙人/总部 H5 均须在微信内置浏览器内授权。 +# OAuth 授权页由 /common/wechat/oauth-url 生成;三端统一入口 m.runxian.top/{user,shop,partner}。 +# 微信服务号「网页授权域名」「JS 接口安全域名」均配置 m.runxian.top(仅 1 个名额)。 WX_APP_ID= WX_APP_SECRET= WECHAT_AUTH_ENABLED=false diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index 9c5dedf..c211339 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -22,6 +22,9 @@ AUTO_APPROVE_STORE=false TRUST_PROXY=true +# C 端 H5 落地页(推广码二维码;生产统一入口) +USER_H5_URL=https://m.runxian.top/user + WECHAT_AUTH_ENABLED=true WECHAT_PAY_ENABLED=true # 登录后走微信 SDK OAuth 授权(生产/预发建议 true) diff --git a/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts b/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts index d17b9ce..a9addcf 100644 --- a/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts +++ b/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts @@ -9,7 +9,7 @@ import { CreatePromoCodeDto } from './dto/admin-mutate.dto'; import { AdminPromoCodesQueryDto } from './dto/admin-query.dto'; function userH5Base(): string { - return (process.env.USER_H5_URL || 'http://localhost:5173').replace(/\/$/, ''); + return (process.env.USER_H5_URL || 'http://localhost:5173/user').replace(/\/$/, ''); } function buildLandingUrl(code: string): string {