From 758760eb364f8a1acfd0598c51271096c29204fe Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Sun, 12 Jul 2026 15:30:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/README.md | 21 +++++++++ .../src/components/WechatLoginButton.tsx | 37 ++++++++++++++++ apps/mini-user/src/pages/login/index.tsx | 20 +++++---- apps/mini-user/src/styles/login.css | 44 ++++++++++++++++--- packages/shared-types/src/config.ts | 3 ++ server/dukang-api/.env.example | 4 ++ .../wechat/wechat.api.provider.ts | 26 ++++++++--- 7 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 apps/mini-user/src/components/WechatLoginButton.tsx diff --git a/apps/mini-user/README.md b/apps/mini-user/README.md index 75d1abc..1e86778 100644 --- a/apps/mini-user/README.md +++ b/apps/mini-user/README.md @@ -21,6 +21,27 @@ pnpm --filter @dukang/mini-user dev:weapp | **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 | | **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` | +### 微信登录 `invalid code` + +`wx.login` 的 code 只能由**与小程序 appid 匹配**的后端 `jscode2session` 兑换。 + +| 项 | 当前值 | +|----|--------| +| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` | +| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) | + +**本地联调(不接真实微信)**:API 指到本机且开启 Mock: + +```bash +# 终端 1 +pnpm dev:api # server/.env 保持 MOCK_WECHAT=true + +# 终端 2 +$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp +``` + +**连远程 API**:在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。 + ## 页面结构(18 页) **Tab**:首页 / 门店 / 好客权益 / 我的 diff --git a/apps/mini-user/src/components/WechatLoginButton.tsx b/apps/mini-user/src/components/WechatLoginButton.tsx new file mode 100644 index 0000000..f58530b --- /dev/null +++ b/apps/mini-user/src/components/WechatLoginButton.tsx @@ -0,0 +1,37 @@ +import { View, Text } from '@tarojs/components'; + +type WechatLoginButtonProps = { + loading?: boolean; + disabled?: boolean; + onClick: () => void; +}; + +function WechatIcon() { + return ( + + + + + ); +} + +/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */ +export default function WechatLoginButton({ + loading = false, + disabled = false, + onClick, +}: WechatLoginButtonProps) { + const inactive = loading || disabled; + + return ( + + + + {loading ? '授权中...' : '微信一键授权'} + + + ); +} diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 859d1d9..617ec8b 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -8,6 +8,7 @@ import { type WechatLoginResult, } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; +import WechatLoginButton from '../../components/WechatLoginButton'; import { finishLoginNavigate } from '../../lib/auth-nav'; import { request, saveAuth, toast, type SessionPayload } from '../../lib/api'; import { loginWithWechat } from '../../lib/wechat-auth'; @@ -154,7 +155,11 @@ export default function LoginPage() { const result = await loginWithWechat(); handleWechatLoginResult(result); } catch (e) { - setMsg(e instanceof Error ? e.message : '微信登录失败'); + const raw = e instanceof Error ? e.message : '微信登录失败'; + const hint = /invalid code/i.test(raw) + ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT' + : raw; + setMsg(hint); } finally { setWxLoading(false); } @@ -162,6 +167,8 @@ export default function LoginPage() { const displayMsg = msg || sentHint; const codeDisabled = cooldown > 0 || sending; + const showWechatLogin = + !bindMode && (process.env.TARO_ENV === 'weapp' || wxAuthorize); return ( @@ -233,22 +240,17 @@ export default function LoginPage() { - {!bindMode && wxAuthorize ? ( + {showWechatLogin ? ( <> 或者 - + /> ) : null} diff --git a/apps/mini-user/src/styles/login.css b/apps/mini-user/src/styles/login.css index e8d84fb..275bb20 100644 --- a/apps/mini-user/src/styles/login.css +++ b/apps/mini-user/src/styles/login.css @@ -221,20 +221,52 @@ font-weight: 600; line-height: 1; padding: 0; + box-sizing: border-box; box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1); } -.login-wechat-btn::after { - border: none; +.login-wechat-btn:active { + transform: scale(0.98); } -.login-wechat-btn[disabled] { +.login-wechat-btn--disabled { opacity: 0.7; + pointer-events: none; } -.login-wechat-icon { - font-size: 22px; - line-height: 1; +.login-wechat-btn__text { + color: #fff; + font-size: 18px; + font-weight: 600; + line-height: 26px; +} + +.wechat-login-icon { + position: relative; + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.wechat-login-icon__big, +.wechat-login-icon__small { + position: absolute; + border-radius: 50%; + background: #fff; +} + +.wechat-login-icon__big { + width: 16px; + height: 16px; + left: 0; + top: 5px; +} + +.wechat-login-icon__small { + width: 11px; + height: 11px; + right: 0; + bottom: 3px; } .login-footer { diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index 9af8701..9a576dc 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -11,6 +11,8 @@ export interface AppConfig { wechatAuthEnabled: boolean; wechatPayEnabled: boolean; wxAppId: string; + /** 小程序 AppID(code2session 与小程序支付;未配置时回退 wxAppId) */ + wxMiniAppId: string; wxMchId: string; /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */ ossEnabled: boolean; @@ -50,6 +52,7 @@ export function loadAppConfig(env?: Record): AppConf wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true', wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false', wxAppId: e.WX_APP_ID ?? '', + wxMiniAppId: e.WX_MINI_APP_ID ?? e.WX_APP_ID ?? '', wxMchId: e.WX_MCH_ID ?? '', ossEnabled: e.OSS_ENABLED === 'true', aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '', diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 0b25d94..93bac6f 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -41,6 +41,10 @@ TRUST_PROXY=true # 微信服务号「网页授权域名」「JS 接口安全域名」均配置 user.runxian.top(仅 1 个名额)。 WX_APP_ID= WX_APP_SECRET= +# 小程序(code2session / 小程序支付 appid;须与 apps/mini-user/project.config.json 的 appid 一致) +# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code +WX_MINI_APP_ID= +WX_MINI_APP_SECRET= WECHAT_AUTH_ENABLED=false WECHAT_PAY_ENABLED=false WX_MCH_ID= diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index cf7c05c..1b62994 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -23,6 +23,9 @@ export class WechatApiProvider implements IWechatProvider { private readonly config = loadAppConfig(); private readonly appId = process.env.WX_APP_ID ?? ''; private readonly appSecret = process.env.WX_APP_SECRET ?? ''; + /** 小程序独立凭证;未配置时回退公众号/H5 的 WX_APP_ID(须与开发者工具 appid 一致) */ + private readonly miniAppId = (process.env.WX_MINI_APP_ID ?? this.appId).trim(); + private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim(); private readonly mchId = process.env.WX_MCH_ID ?? ''; private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? ''; private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n'); @@ -70,14 +73,21 @@ export class WechatApiProvider implements IWechatProvider { } async code2Session(code: string, actorRef?: WechatActorRef): Promise { + const appId = this.miniAppId; + const appSecret = this.miniAppSecret; + if (!appId || !appSecret) { + throw new InternalServerErrorException( + '小程序微信登录未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET(或与 project.config.json appid 一致)', + ); + } const url = new URL('https://api.weixin.qq.com/sns/jscode2session'); - url.searchParams.set('appid', this.appId); + url.searchParams.set('appid', appId); url.searchParams.set('secret', '***'); url.searchParams.set('js_code', code); url.searchParams.set('grant_type', 'authorization_code'); const apiUrl = new URL('https://api.weixin.qq.com/sns/jscode2session'); - apiUrl.searchParams.set('appid', this.appId); - apiUrl.searchParams.set('secret', this.appSecret); + apiUrl.searchParams.set('appid', appId); + apiUrl.searchParams.set('secret', appSecret); apiUrl.searchParams.set('js_code', code); apiUrl.searchParams.set('grant_type', 'authorization_code'); const data = await this.fetchJson<{ @@ -91,7 +101,7 @@ export class WechatApiProvider implements IWechatProvider { await logWechatAuth(this.prisma, { scene: 'LOGIN', requestUrl: url.toString(), - requestBody: { grant_type: 'authorization_code', platform: 'mini' }, + requestBody: { grant_type: 'authorization_code', platform: 'mini', appId }, responseBody: ok ? { openid: data.openid, unionid: data.unionid } : { errcode: data.errcode, errmsg: data.errmsg }, @@ -101,7 +111,11 @@ export class WechatApiProvider implements IWechatProvider { actorRef, }); if (!data.openid) { - throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败'); + const invalidCode = data.errcode === 40029 || /invalid code/i.test(data.errmsg ?? ''); + const hint = invalidCode + ? `(后端 appid=${appId},请确认 WX_MINI_APP_ID/SECRET 与小程序 project.config.json 一致;本地可 MOCK_WECHAT=true)` + : ''; + throw new InternalServerErrorException((data.errmsg || '微信 code2session 失败') + hint); } return { openId: data.openid, @@ -284,7 +298,7 @@ export class WechatApiProvider implements IWechatProvider { throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL'); } const body = { - appid: this.appId, + appid: this.miniAppId || this.appId, mchid: this.mchId, description: params.description, out_trade_no: params.orderNo,