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,