From 935ab0d1d47425378cf49327fd46ee285cb70311 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 21 Jul 2026 09:32:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E4=B8=8D=E9=80=9A=E8=BF=87=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/PhoneQuickLoginButton.tsx | 63 ++++ .../src/components/WechatLoginButton.tsx | 19 +- apps/mini-user/src/pages/login/index.tsx | 312 +++++++++++------- apps/mini-user/src/pages/mine/index.tsx | 2 +- .../src/pages/privacy-policy/index.tsx | 36 +- .../src/pages/user-agreement/index.config.ts | 2 +- .../src/pages/user-agreement/index.tsx | 36 +- apps/mini-user/src/styles/legal.css | 22 +- apps/mini-user/src/styles/login.css | 42 +-- packages/shared-types/src/legal.ts | 21 +- .../wechat/wechat.api.provider.ts | 34 +- .../src/modules/iam/auth.controller.ts | 18 + .../src/modules/iam/auth.service.ts | 85 +++-- .../src/modules/iam/dto/auth.dto.ts | 16 + 14 files changed, 466 insertions(+), 242 deletions(-) create mode 100644 apps/mini-user/src/components/PhoneQuickLoginButton.tsx diff --git a/apps/mini-user/src/components/PhoneQuickLoginButton.tsx b/apps/mini-user/src/components/PhoneQuickLoginButton.tsx new file mode 100644 index 0000000..b138e72 --- /dev/null +++ b/apps/mini-user/src/components/PhoneQuickLoginButton.tsx @@ -0,0 +1,63 @@ +import { Button, Text, View } from '@tarojs/components'; + +type PhoneQuickLoginButtonProps = { + loading?: boolean; + disabled?: boolean; + /** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */ + agreed: boolean; + onRequireAgree: () => void; + onGetPhoneNumber: (phoneCode: string) => void; + onFail?: (message: string) => void; +}; + +/** + * 小程序手机号快捷登录(open-type=getPhoneNumber)。 + * 文案不得使用「微信」字样或仿官方图标,以符合审核要求。 + */ +export default function PhoneQuickLoginButton({ + loading = false, + disabled = false, + agreed, + onRequireAgree, + onGetPhoneNumber, + onFail, +}: PhoneQuickLoginButtonProps) { + const inactive = loading || disabled; + const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`; + const label = loading ? '登录中...' : '手机号快捷登录'; + + if (!agreed) { + return ( + + {label} + + ); + } + + return ( + + ); +} diff --git a/apps/mini-user/src/components/WechatLoginButton.tsx b/apps/mini-user/src/components/WechatLoginButton.tsx index f58530b..379bba4 100644 --- a/apps/mini-user/src/components/WechatLoginButton.tsx +++ b/apps/mini-user/src/components/WechatLoginButton.tsx @@ -3,22 +3,16 @@ import { View, Text } from '@tarojs/components'; type WechatLoginButtonProps = { loading?: boolean; disabled?: boolean; + /** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */ + label?: string; onClick: () => void; }; -function WechatIcon() { - return ( - - - - - ); -} - -/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */ +/** 授权登录按钮(无微信品牌元素,满足小程序审核) */ export default function WechatLoginButton({ loading = false, disabled = false, + label = '授权登录', onClick, }: WechatLoginButtonProps) { const inactive = loading || disabled; @@ -28,10 +22,7 @@ export default function WechatLoginButton({ className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`} onClick={inactive ? undefined : onClick} > - - - {loading ? '授权中...' : '微信一键授权'} - + {loading ? '授权中...' : label} ); } diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 705622c..5a0418b 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -9,6 +9,7 @@ import { } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import WechatLoginButton from '../../components/WechatLoginButton'; +import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton'; import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav'; import { @@ -24,6 +25,8 @@ import { } from '../../lib/mini-wechat-profile'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; +const IS_WEAPP = process.env.TARO_ENV === 'weapp'; + function normalizePhone(value: string) { return value.replace(/\D/g, '').slice(0, 11); } @@ -32,6 +35,44 @@ function isValidPhone(phone: string) { return /^1[3-9]\d{9}$/.test(phone); } +function AgreementRow({ + agreed, + onToggle, +}: { + agreed: boolean; + onToggle: () => void; +}) { + return ( + + + {agreed ? : null} + + + 请阅读并勾选同意 + { + e.stopPropagation(); + Taro.navigateTo({ url: '/pages/user-agreement/index' }); + }} + > + 《用户服务协议》 + + 和 + { + e.stopPropagation(); + Taro.navigateTo({ url: '/pages/privacy-policy/index' }); + }} + > + 《隐私政策》 + + + + ); +} + export default function LoginPage() { const router = useRouter(); const returnTo = router.params.return || ''; @@ -44,8 +85,10 @@ export default function LoginPage() { const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false); + const [phoneQuickLoading, setPhoneQuickLoading] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); + /** 须用户主动勾选,禁止默认同意 */ const [agreed, setAgreed] = useState(false); const [msg, setMsg] = useState(''); const [sentHint, setSentHint] = useState(''); @@ -53,6 +96,7 @@ export default function LoginPage() { const [wxSessionKey, setWxSessionKey] = useState(initialWxSessionKey); const [wxAuthorize, setWxAuthorize] = useState(true); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); + const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP); useEffect(() => { request('/common/client-config') @@ -65,7 +109,6 @@ export default function LoginPage() { setCompleteMode(null); return; } - // 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态 if (!needPhone && !needWechat) { setCompleteMode(null); return; @@ -76,6 +119,7 @@ export default function LoginPage() { if (cancelled) return; if (needPhone && !me.phoneVerified) { setCompleteMode('phone'); + setShowSmsForm(true); return; } if (needWechat && !me.hasWechat) { @@ -100,7 +144,7 @@ export default function LoginPage() { function ensureAgreed() { if (!agreed) { - setMsg('请先勾选并同意用户协议'); + setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》'); return false; } return true; @@ -108,18 +152,18 @@ export default function LoginPage() { function applySessionAndLeave( data: SessionPayload | WechatLoginResult, - phone?: string, + phoneValue?: string, wxInfo?: MiniWechatProfile | null, successToast = '登录成功', ) { if (!data.accessToken) return; - if (phone) saveUserPhone(phone); + if (phoneValue) saveUserPhone(phoneValue); saveAuth({ accessToken: data.accessToken, refreshToken: data.refreshToken, }); void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile()); - if (!phone) { + if (!phoneValue) { void fetchUserProfile() .then((me) => resolveDefaultUserPhone(me)) .catch(() => {}); @@ -133,7 +177,6 @@ export default function LoginPage() { } function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) { - // 微信授权成功即登录;手机号改为下单页可选绑定 if (result.accessToken) { applySessionAndLeave(result, undefined, wxInfo); return; @@ -141,11 +184,49 @@ export default function LoginPage() { if (result.needBindPhone && result.wxSessionKey) { setBindMode(true); setWxSessionKey(result.wxSessionKey); - setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)'); + setShowSmsForm(true); + setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)'); setSentHint(''); return; } - setMsg('微信登录未完成,请重试或使用手机号登录'); + setMsg('登录未完成,请重试或使用手机号登录'); + } + + async function onPhoneQuickLogin(phoneCode: string) { + if (!ensureAgreed()) return; + setPhoneQuickLoading(true); + setMsg(''); + setSentHint(''); + try { + let loginCode: string | undefined; + try { + const loginRes = await Taro.login(); + loginCode = loginRes.code || undefined; + } catch { + /* openId 绑定失败不阻断手机号登录 */ + } + const data = await request('/auth/login/wechat-phone', { + method: 'POST', + data: { + phoneCode, + ...(loginCode ? { loginCode } : {}), + platform: 'mini', + }, + }); + if (!data?.accessToken) { + setMsg('登录成功但未返回令牌,请重试'); + return; + } + const profilePhone = + typeof data.user === 'object' && data.user && 'phone' in data.user + ? String((data.user as { phone?: string }).phone || '') + : ''; + applySessionAndLeave(data, profilePhone || undefined); + } catch (e) { + setMsg(e instanceof Error ? e.message : '手机号快捷登录失败'); + } finally { + setPhoneQuickLoading(false); + } } async function onSendCode() { @@ -200,7 +281,6 @@ export default function LoginPage() { return; } if (completeMode === 'phone' && isLoggedIn()) { - // bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开 const data = await request('/auth/phone/bind', { method: 'POST', data: { phone: normalized, code: code.trim() }, @@ -234,7 +314,6 @@ export default function LoginPage() { setSentHint(''); setWxLoading(true); try { - // 登录仅绑定 openId;资料展示走账号昵称/头像 const wxInfo = getCachedWxProfile(); if (completeMode === 'wechat' && isLoggedIn()) { @@ -246,12 +325,13 @@ export default function LoginPage() { setBindMode(true); setWxSessionKey(result.wxSessionKey); setCompleteMode('phone'); + setShowSmsForm(true); setMsg('请绑定手机号完成认证'); return; } if (result.ok) { if (wxInfo) await syncMiniWechatProfile(wxInfo); - toast('微信授权成功', 'success'); + toast('授权成功', 'success'); finishLoginNavigate(returnTo); return; } @@ -260,11 +340,11 @@ export default function LoginPage() { const result = await loginWithWechat(); if (result) handleWechatLoginResult(result, wxInfo); } catch (e) { - const raw = e instanceof Error ? e.message : '微信登录失败'; + const raw = e instanceof Error ? e.message : '授权登录失败'; const hint = /invalid code/i.test(raw) ? process.env.TARO_ENV === 'weapp' - ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT' - : '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确' + ? '授权失败:请确认后端小程序 AppID 配置正确' + : '授权失败:请确认公众号网页授权域名配置正确' : raw; setMsg(hint); } finally { @@ -274,18 +354,19 @@ export default function LoginPage() { const displayMsg = msg || sentHint; const codeDisabled = cooldown > 0 || sending; - const showWechatLogin = - (completeMode === 'wechat' || (!bindMode && !completeMode)) && - (process.env.TARO_ENV === 'weapp' || wxAuthorize); - const showSmsForm = completeMode !== 'wechat'; + const showAuthLogin = + (completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) && + (IS_WEAPP || wxAuthorize); + const showPhoneQuick = + IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone'; const cardTitle = completeMode === 'phone' ? '验证手机号' : bindMode ? '绑定手机号' : completeMode === 'wechat' - ? '微信授权' - : '手机验证码登录'; + ? '授权登录' + : '手机号快捷登录'; return ( @@ -301,7 +382,7 @@ export default function LoginPage() { {completeMode === 'phone' ? '建议绑定手机号' : completeMode === 'wechat' - ? '完成微信授权' + ? '完成授权登录' : '欢迎来到杜康好客'} @@ -317,77 +398,100 @@ export default function LoginPage() { {completeMode === 'wechat' ? ( - 微信一键授权 + 授权登录 - 使用微信支付前需授权微信账号 + 使用支付功能前需完成授权登录 - setAgreed((v) => !v)}> - - {agreed ? : null} - - - 我已阅读并同意 - { - e.stopPropagation(); - Taro.navigateTo({ url: '/pages/user-agreement/index' }); - }} - > - 《用户协议》 - - 和 - { - e.stopPropagation(); - Taro.navigateTo({ url: '/pages/privacy-policy/index' }); - }} - > - 《隐私政策》 - + setAgreed((v) => !v)} /> + {displayMsg ? ( + + {displayMsg} - - {showWechatLogin ? ( - void wechatLogin()} /> + ) : null} + {showAuthLogin ? ( + void wechatLogin()} /> ) : null} ) : ( {cardTitle} - - +86 - { - setPhone(normalizePhone(e.detail.value)); - setMsg(''); - setSentHint(''); - }} - /> - + setAgreed((v) => !v)} /> - - setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))} + {showPhoneQuick ? ( + ensureAgreed()} + onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)} + onFail={(message) => setMsg(message)} /> - void onSendCode()} - > - {sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'} - - + ) : null} + + {showPhoneQuick ? ( + + + setShowSmsForm((v) => !v)} + > + {showSmsForm ? '收起验证码登录' : '使用验证码登录'} + + + + ) : null} + + {(showSmsForm || !showPhoneQuick) && ( + <> + + +86 + { + setPhone(normalizePhone(e.detail.value)); + setMsg(''); + setSentHint(''); + }} + /> + + + + setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))} + /> + void onSendCode()} + > + {sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'} + + + + void login()} + > + + {loading + ? '处理中...' + : completeMode === 'phone' + ? '完成验证' + : bindMode + ? '绑定并登录' + : '验证码登录'} + + + + )} {displayMsg ? ( @@ -395,48 +499,6 @@ export default function LoginPage() { ) : null} - setAgreed((v) => !v)}> - - {agreed ? : null} - - - 我已阅读并同意 - { - e.stopPropagation(); - Taro.navigateTo({ url: '/pages/user-agreement/index' }); - }} - > - 《用户协议》 - - 和 - { - e.stopPropagation(); - Taro.navigateTo({ url: '/pages/privacy-policy/index' }); - }} - > - 《隐私政策》 - - - - - void login()} - > - - {loading - ? '处理中...' - : completeMode === 'phone' - ? '完成验证' - : bindMode - ? '绑定并登录' - : '登录'} - - {completeMode === 'phone' ? ( )} - {showWechatLogin && completeMode !== 'wechat' ? ( + {showAuthLogin && completeMode !== 'wechat' ? ( <> 或者 - void wechatLogin()} /> + void wechatLogin()} /> ) : null} diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index 8c5ba6d..47dbf86 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -213,7 +213,7 @@ export default function MinePage() { throw new Error('wx profile helpers missing'); } const nickname = display.nickname || '用户'; - const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '微信未授权' : '未授权微信'; + const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '未完成授权' : '未完成授权'; void needsWxProfileFill(display); return ( diff --git a/apps/mini-user/src/pages/privacy-policy/index.tsx b/apps/mini-user/src/pages/privacy-policy/index.tsx index 865712d..d6857bd 100644 --- a/apps/mini-user/src/pages/privacy-policy/index.tsx +++ b/apps/mini-user/src/pages/privacy-policy/index.tsx @@ -1,4 +1,4 @@ -import { View, Text, ScrollView } from '@tarojs/components'; +import { View, Text } from '@tarojs/components'; import { getLegalDocument } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; @@ -8,21 +8,25 @@ export default function PrivacyPolicyPage() { return ( - - - 更新日期:{doc.updatedAt} - {doc.intro} - {doc.sections.map((section) => ( - - {section.heading} - {section.paragraphs.map((p, i) => ( - - {p} - - ))} - - ))} - + + + 更新日期:{doc.updatedAt} + + + {doc.intro} + + {doc.sections.map((section) => ( + + + {section.heading} + + {section.paragraphs.map((p, i) => ( + + {p} + + ))} + + ))} ); diff --git a/apps/mini-user/src/pages/user-agreement/index.config.ts b/apps/mini-user/src/pages/user-agreement/index.config.ts index 37e4e8b..4677bdd 100644 --- a/apps/mini-user/src/pages/user-agreement/index.config.ts +++ b/apps/mini-user/src/pages/user-agreement/index.config.ts @@ -1,4 +1,4 @@ export default definePageConfig({ - navigationBarTitleText: '用户协议', + navigationBarTitleText: '用户服务协议', navigationStyle: 'custom', }); diff --git a/apps/mini-user/src/pages/user-agreement/index.tsx b/apps/mini-user/src/pages/user-agreement/index.tsx index 098e45c..f2d8c27 100644 --- a/apps/mini-user/src/pages/user-agreement/index.tsx +++ b/apps/mini-user/src/pages/user-agreement/index.tsx @@ -1,4 +1,4 @@ -import { View, Text, ScrollView } from '@tarojs/components'; +import { View, Text } from '@tarojs/components'; import { getLegalDocument } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; @@ -8,21 +8,25 @@ export default function UserAgreementPage() { return ( - - - 更新日期:{doc.updatedAt} - {doc.intro} - {doc.sections.map((section) => ( - - {section.heading} - {section.paragraphs.map((p, i) => ( - - {p} - - ))} - - ))} - + + + 更新日期:{doc.updatedAt} + + + {doc.intro} + + {doc.sections.map((section) => ( + + + {section.heading} + + {section.paragraphs.map((p, i) => ( + + {p} + + ))} + + ))} ); diff --git a/apps/mini-user/src/styles/legal.css b/apps/mini-user/src/styles/legal.css index 44eaa27..09b614e 100644 --- a/apps/mini-user/src/styles/legal.css +++ b/apps/mini-user/src/styles/legal.css @@ -1,21 +1,24 @@ -.legal-page .legal-scroll { - height: 100%; +.legal-page .legal-body { + min-height: calc(100vh - var(--nav-bar-height, 88px)); + padding-top: 8px; + padding-bottom: 40px; box-sizing: border-box; - padding-bottom: 32px; + background: var(--color-background, #f7f4ef); } .legal-updated { display: block; font-size: 12px; - color: var(--color-on-surface-variant, #8d706e); + color: #8d706e; margin-bottom: 12px; + line-height: 1.5; } .legal-intro { display: block; font-size: 14px; - line-height: 1.7; - color: var(--color-on-surface, #1f1a17); + line-height: 1.75; + color: #1f1a17; margin-bottom: 20px; } @@ -27,14 +30,15 @@ display: block; font-size: 15px; font-weight: 600; - color: var(--color-on-surface, #1f1a17); + color: #1f1a17; margin-bottom: 8px; + line-height: 1.5; } .legal-paragraph { display: block; font-size: 13px; - line-height: 1.7; - color: var(--color-on-surface-variant, #5c504c); + line-height: 1.75; + color: #3d3530; margin-bottom: 8px; } diff --git a/apps/mini-user/src/styles/login.css b/apps/mini-user/src/styles/login.css index f13b9f7..4b96cf5 100644 --- a/apps/mini-user/src/styles/login.css +++ b/apps/mini-user/src/styles/login.css @@ -234,7 +234,8 @@ flex-shrink: 0; } -.login-wechat-btn { +.login-wechat-btn, +.login-phone-quick-btn { width: 100%; height: 56px; border: none; @@ -250,52 +251,33 @@ font-weight: 600; line-height: 1; padding: 0; + margin: 0; box-sizing: border-box; box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1); } -.login-wechat-btn:active { +.login-wechat-btn:active, +.login-phone-quick-btn:active { transform: scale(0.98); } -.login-wechat-btn--disabled { +.login-wechat-btn--disabled, +.login-phone-quick-btn--disabled { opacity: 0.7; pointer-events: none; } -.login-wechat-btn__text { +.login-wechat-btn__text, +.login-phone-quick-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; +/* 重置小程序 Button 默认样式,避免绿边/微信绿 */ +.login-phone-quick-btn::after { + border: none; } .login-footer { diff --git a/packages/shared-types/src/legal.ts b/packages/shared-types/src/legal.ts index 50c4fac..efb7907 100644 --- a/packages/shared-types/src/legal.ts +++ b/packages/shared-types/src/legal.ts @@ -21,10 +21,10 @@ export type LegalDocument = { */ export const USER_AGREEMENT: LegalDocument = { id: 'user-agreement', - title: '用户协议', - updatedAt: '2026-07-15', + title: '用户服务协议', + updatedAt: '2026-07-21', intro: - '欢迎使用「杜康好客」平台(含微信小程序、微信内置浏览器 H5 及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您勾选同意并继续使用,即视为已阅读并接受本协议全部内容。', + '欢迎使用「杜康好客」平台(含小程序、移动网页及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您须主动勾选同意后继续使用,不得默认强制同意;勾选即视为已阅读并接受本协议全部内容。', sections: [ { heading: '一、服务说明', @@ -36,7 +36,7 @@ export const USER_AGREEMENT: LegalDocument = { { heading: '二、账号注册与安全', paragraphs: [ - '您可通过手机号验证码、微信授权等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。', + '您可通过手机号快捷登录、手机号验证码等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。', '您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。', '您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。', ], @@ -88,20 +88,21 @@ export const USER_AGREEMENT: LegalDocument = { export const PRIVACY_POLICY: LegalDocument = { id: 'privacy-policy', title: '隐私政策', - updatedAt: '2026-07-15', + updatedAt: '2026-07-21', intro: - '杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您勾选同意,即表示您已充分理解并同意我们按本政策处理相关个人信息。', + '杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您须主动勾选同意后,我们才会按本政策处理相关个人信息;我们不会默认勾选或强制同意。', sections: [ { heading: '一、我们如何收集与使用个人信息', paragraphs: [ - '为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能收集并使用下列信息:', - '1)账号信息:手机号码、验证码、微信 OpenID/UnionID、昵称与头像(若您授权微信);用于注册登录、账号绑定与安全保障。', + '为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能在取得您授权同意后收集并使用下列信息:', + '1)账号信息:手机号码、验证码、开放平台账号标识(OpenID/UnionID,若您授权)、昵称与头像(若您主动授权);用于注册登录、账号绑定与安全保障。', '2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。', '3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。', '4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。', '5)您主动提供的其他信息:如客服沟通内容、反馈建议等。', - '我们不会以默认勾选等方式强制您同意本政策;未征得同意前,我们不会超范围收集与实现业务功能无关的个人信息。', + '收集目的与方式:仅在实现上述业务功能所必需的范围内,通过您主动填写、授权组件或系统必要日志收集;未征得同意前,我们不会超范围收集与业务无关的个人信息。', + '我们不会以默认勾选等方式强制您同意本政策。', ], }, { @@ -109,7 +110,7 @@ export const PRIVACY_POLICY: LegalDocument = { paragraphs: [ '我们不会向第三方出售您的个人信息。仅在以下情形共享:', '1)获得您的明确同意;', - '2)为实现支付、短信、配送、地图/定位、微信登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;', + '2)为实现支付、短信、配送、地图/定位、账号登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;', '3)根据法律法规、行政或司法机关要求;', '4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。', ], 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 48be237..ee3fbec 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -16,6 +16,7 @@ type TokenCache = { accessToken: string; expiresAt: number }; type TicketCache = { ticket: string; expiresAt: number }; const ACCESS_TOKEN_KEY = 'wechat:access_token'; +const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token'; const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket'; @Injectable() @@ -255,7 +256,7 @@ export class WechatApiProvider implements IWechatProvider { if (platform === 'h5') { throw new InternalServerErrorException('H5 请使用短信绑定手机号'); } - const accessToken = await this.getAccessToken(); + const accessToken = await this.getMiniAccessToken(); const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`; const data = await this.fetchJson<{ errcode?: number; @@ -433,6 +434,37 @@ export class WechatApiProvider implements IWechatProvider { return data.access_token; } + /** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */ + private async getMiniAccessToken(): Promise { + const appId = this.miniAppId; + const appSecret = this.miniAppSecret; + if (!appId || !appSecret) { + throw new InternalServerErrorException( + '小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET', + ); + } + const cached = await this.redis.getJson(MINI_ACCESS_TOKEN_KEY); + if (cached && cached.expiresAt > Date.now()) return cached.accessToken; + + const url = new URL('https://api.weixin.qq.com/cgi-bin/token'); + url.searchParams.set('grant_type', 'client_credential'); + url.searchParams.set('appid', appId); + url.searchParams.set('secret', appSecret); + const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>( + url.toString(), + ); + if (!data.access_token) { + throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败'); + } + const ttl = Math.max((data.expires_in ?? 7200) - 300, 60); + await this.redis.setJson( + MINI_ACCESS_TOKEN_KEY, + { accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 }, + ttl, + ); + return data.access_token; + } + private async getJsapiTicket(): Promise { const cached = await this.redis.getJson(JSAPI_TICKET_KEY); if (cached && cached.expiresAt > Date.now()) return cached.ticket; diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index f3b4339..500600a 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -10,6 +10,7 @@ import { CheckPartnerPhoneDto, LoginSmsDto, LoginWechatDto, + LoginWechatPhoneDto, RefreshTokenDto, SendSmsDto, } from './dto/auth.dto'; @@ -74,6 +75,23 @@ export class UserAuthController { return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId); } + /** 小程序手机号快捷登录(getPhoneNumber) */ + @Post('auth/login/wechat-phone') + @UseGuards(OptionalJwtAuthGuard) + wechatPhoneLogin(@Req() req: Request, @Body() dto: LoginWechatPhoneDto) { + const guest = (req as Request & { user?: AuthUser }).user; + const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined; + const clientApp = resolveUserClientApp(req); + const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5'); + return this.authService.loginUserWechatPhone( + dto.phoneCode, + clientApp, + platform, + guestId, + dto.loginCode, + ); + } + @Post('auth/wechat/bind-phone') bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) { return this.authService.bindWechatPhone( diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 645bd8b..88f4c4c 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -751,19 +751,13 @@ export class AuthService { return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary)); } - async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { - const normalizedPhone = this.assertMobilePhone(phone); - const existingUser = await this.prisma.user.findUnique({ - where: { phone: normalizedPhone }, - select: { id: true }, - }); - await this.verifySmsForUser( - normalizedPhone, - code, - SmsScene.USER_LOGIN, - clientApp, - guestId ?? existingUser?.id, - ); + /** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */ + private async issueUserSessionByVerifiedPhone( + normalizedPhone: string, + clientApp: ClientApp, + guestId: bigint | undefined, + method: 'sms' | 'wechat_phone', + ) { let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone: normalizedPhone }, include: { avatar: true }, @@ -816,12 +810,12 @@ export class AuthService { if (guestId && guestId !== user.id) { user = await this.mergeUsers(guestId, user.id); this.analyticsService.trackOneSafe(user.id, clientApp, { - eventName: 'sms_login', - extraJson: { method: 'sms', accountMerged: true }, + eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', + extraJson: { method, accountMerged: true }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', - extraJson: { method: 'sms', accountMerged: true }, + extraJson: { method, accountMerged: true }, }); return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true }); } else { @@ -832,17 +826,70 @@ export class AuthService { if (!user) throw new BadRequestException('登录失败'); this.analyticsService.trackOneSafe(user.id, clientApp, { - eventName: 'sms_login', - extraJson: { method: 'sms' }, + eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', + extraJson: { method }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', - extraJson: { method: 'sms' }, + extraJson: { method }, }); return this.buildSessionResponse(user, clientApp, user.deviceKey); } + async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { + const normalizedPhone = this.assertMobilePhone(phone); + const existingUser = await this.prisma.user.findUnique({ + where: { phone: normalizedPhone }, + select: { id: true }, + }); + await this.verifySmsForUser( + normalizedPhone, + code, + SmsScene.USER_LOGIN, + clientApp, + guestId ?? existingUser?.id, + ); + return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms'); + } + + /** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */ + async loginUserWechatPhone( + phoneCode: string, + clientApp: ClientApp, + platform: 'h5' | 'mini' = 'mini', + guestId?: bigint, + loginCode?: string, + ) { + this.assertWechatEnabled(); + if (platform !== 'mini') { + throw new BadRequestException('仅小程序支持手机号快捷登录'); + } + const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform); + const normalizedPhone = this.assertMobilePhone(phone); + const session = await this.issueUserSessionByVerifiedPhone( + normalizedPhone, + clientApp, + guestId, + 'wechat_phone', + ); + + if (loginCode?.trim() && session.actorId) { + try { + await this.bindUserWechat( + BigInt(session.actorId), + { code: loginCode.trim() }, + clientApp, + 'mini', + ); + } catch { + /* 绑定 openId 失败不阻断已成功的手机号登录 */ + } + } + + return session; + } + async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId); diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index 15514d5..db55a3b 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -55,6 +55,22 @@ export class LoginWechatDto { platform?: 'h5' | 'mini'; } +/** 小程序 getPhoneNumber 返回的 phoneCode,可选附带 wx.login code 绑定 openId */ +export class LoginWechatPhoneDto { + @IsString() + @IsNotEmpty() + phoneCode: string; + + @IsString() + @IsOptional() + loginCode?: string; + + @IsString() + @IsIn(['h5', 'mini']) + @IsOptional() + platform?: 'h5' | 'mini'; +} + export class BindWechatPhoneDto { @IsString() @IsNotEmpty()