From cabc9e6ebbdcf4bcb70c20df4fa65b7bc107139e Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 15:44:46 +0800 Subject: [PATCH] feat(partner): require WeChat OAuth before store photo upload Co-authored-by: Cursor --- apps/h5-partner/src/App.tsx | 33 +++++++- .../src/components/OssUploadField.tsx | 75 ++++++++++++++++--- apps/h5-partner/src/lib/wechat-auth.ts | 38 ++++++++++ apps/h5-partner/src/lib/weixin.ts | 1 + apps/h5-partner/src/pages/StoreCreatePage.tsx | 26 +++++++ apps/h5-partner/src/styles.css | 8 ++ packages/weixin-sdk/src/auth.ts | 3 +- packages/weixin-sdk/src/types.ts | 2 + .../src/modules/iam/auth.controller.ts | 12 ++- .../src/modules/iam/auth.service.ts | 45 +++++++++++ .../settlement/settlement.controller.ts | 1 + 11 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 apps/h5-partner/src/lib/wechat-auth.ts diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index a549bad..9f4f4c7 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; import TabLayout from './layouts/TabLayout'; import LoginPage from './pages/LoginPage'; import HomePage from './pages/HomePage'; @@ -12,10 +13,37 @@ import BillsPage from './pages/BillsPage'; import ReshipPage from './pages/ReshipPage'; import WeeklyReportPage from './pages/WeeklyReportPage'; import LeaderboardPage from './pages/LeaderboardPage'; +import { handlePartnerWechatCallback, savePartnerWechatAuth } from './lib/wechat-auth'; +import { isWechatEnv } from './lib/weixin'; + +function WechatOAuthHandler() { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isWechatEnv() || !location.search.includes('code=')) return; + void handlePartnerWechatCallback() + .then((result) => { + if (!result || !savePartnerWechatAuth(result)) return; + const params = new URLSearchParams(location.search); + params.delete('code'); + params.delete('state'); + const qs = params.toString(); + navigate(`${location.pathname}${qs ? `?${qs}` : ''}`, { replace: true }); + }) + .catch(() => { + /* 页面内组件会提示 */ + }); + }, [location.pathname, location.search, navigate]); + + return null; +} export default function App() { return ( - + <> + + } /> }> } /> @@ -32,5 +60,6 @@ export default function App() { } /> } /> + ); } diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index 1d5c440..a872634 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -1,5 +1,11 @@ import { useEffect, useId, useRef, useState } from 'react'; import { uploadFileToOss, type OssMediaType } from '../lib/upload'; +import { + authorizePartnerWechat, + fetchPartnerProfile, + needsWechatAuth, + type PartnerProfile, +} from '../lib/wechat-auth'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; type OssUploadFieldProps = { @@ -11,6 +17,9 @@ type OssUploadFieldProps = { wide?: boolean; compact?: boolean; label?: string; + /** 父级已确认微信授权时可跳过检查 */ + wechatReady?: boolean; + onWechatReadyChange?: (ready: boolean) => void; }; const DEFAULT_MAX_MB = 10; @@ -24,22 +33,39 @@ export default function OssUploadField({ wide, compact, label, + wechatReady, + onWechatReadyChange, }: OssUploadFieldProps) { const inputId = useId(); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); + const [authorizing, setAuthorizing] = useState(false); const [error, setError] = useState(''); + const [profile, setProfile] = useState(null); const resolvedAccept = accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO'; + const needsAuth = useWechatPicker && needsWechatAuth(profile) && wechatReady !== true; useEffect(() => { if (!useWechatPicker) return; + void fetchPartnerProfile() + .then((me) => { + setProfile(me); + onWechatReadyChange?.(!!me.hasWechat); + }) + .catch(() => { + /* 未登录等场景由上传接口报错 */ + }); + }, [useWechatPicker, onWechatReadyChange]); + + useEffect(() => { + if (!useWechatPicker || needsAuth) return; void weixinSdk.init().catch(() => { - /* 点击上传时会再次初始化并提示 */ + /* 点击上传时会再次初始化 */ }); - }, [useWechatPicker]); + }, [useWechatPicker, needsAuth]); async function uploadSelectedFile(file: File) { if (file.size > DEFAULT_MAX_MB * 1024 * 1024) { @@ -59,9 +85,25 @@ export default function OssUploadField({ } } - async function pickFile() { - if (uploading) return; + async function startWechatAuth() { + setAuthorizing(true); setError(''); + try { + await authorizePartnerWechat(); + } catch (e) { + setError(e instanceof Error ? e.message : '微信授权失败'); + setAuthorizing(false); + } + } + + async function pickFile() { + if (uploading || authorizing) return; + setError(''); + + if (useWechatPicker && needsAuth) { + setError('请先完成微信授权后再上传照片'); + return; + } if (useWechatPicker) { try { @@ -90,22 +132,37 @@ export default function OssUploadField({ const isImage = mediaType === 'IMAGE' && value; const isFile = mediaType === 'FILE' && value; + const busy = uploading || authorizing; const triggerProps = { type: 'button' as const, - disabled: uploading, + disabled: busy || needsAuth, onClick: () => void pickFile(), }; return (
+ {needsAuth && ( +
+

上传照片需先完成微信授权

+ +
+ )} { const file = e.target.files?.[0]; if (file) void uploadSelectedFile(file); @@ -115,7 +172,7 @@ export default function OssUploadField({ @@ -135,10 +192,10 @@ export default function OssUploadField({ className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`} > - {uploading ? 'hourglass_top' : 'add_a_photo'} + {busy ? 'hourglass_top' : 'add_a_photo'} - {uploading ? '上传中…' : (label ?? (useWechatPicker ? '从相册选择' : '点击上传'))} + {uploading ? '上传中…' : needsAuth ? '请先微信授权' : (label ?? (useWechatPicker ? '从相册选择' : '点击上传'))} )} diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts new file mode 100644 index 0000000..d20ef6e --- /dev/null +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -0,0 +1,38 @@ +import type { WechatLoginResult } from '@dukang/shared-types'; +import { isWechatEnv, weixinSdk } from './weixin'; +import { request, saveAuth } from './api'; + +export type PartnerProfile = { + id: string; + name: string; + phone: string; + companyName: string; + hasWechat?: boolean; +}; + +export async function fetchPartnerProfile(): Promise { + return request('PARTNER_H5', '/partner/me'); +} + +/** 微信内上传照片前需完成公众号授权绑定 */ +export function needsWechatAuth(profile: PartnerProfile | null): boolean { + return isWechatEnv() && !!profile && !profile.hasWechat; +} + +export function savePartnerWechatAuth(result: WechatLoginResult): boolean { + if (!result.accessToken) return false; + saveAuth({ accessToken: result.accessToken }); + return true; +} + +export async function authorizePartnerWechat(): Promise { + if (!isWechatEnv()) { + throw new Error('请在微信内打开以完成授权'); + } + return weixinSdk.login(); +} + +export async function handlePartnerWechatCallback(): Promise { + if (!isWechatEnv()) return null; + return weixinSdk.handleOAuthCallback(); +} diff --git a/apps/h5-partner/src/lib/weixin.ts b/apps/h5-partner/src/lib/weixin.ts index 4af1596..60dff5a 100644 --- a/apps/h5-partner/src/lib/weixin.ts +++ b/apps/h5-partner/src/lib/weixin.ts @@ -4,6 +4,7 @@ export const weixinSdk = createWeixinSdk({ apiBase: '/api/v1', clientApp: 'PARTNER_H5', getAccessToken: () => localStorage.getItem('accessToken'), + wechatLoginPath: '/partner/auth/login/wechat', }); export { isWechatEnv }; diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 2603296..d84685b 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -6,6 +6,11 @@ import OssUploadField from '../components/OssUploadField'; import { request } from '../lib/api'; import { resolveRegionBinding } from '../lib/china-region'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; +import { + fetchPartnerProfile, + handlePartnerWechatCallback, + savePartnerWechatAuth, +} from '../lib/wechat-auth'; import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { clearStoreDraft, @@ -28,6 +33,7 @@ export default function StoreCreatePage() { const [submitting, setSubmitting] = useState(false); const [cities, setCities] = useState([]); const [citiesError, setCitiesError] = useState(''); + const [wechatReady, setWechatReady] = useState(false); const stepFromUrl = Number(params.get('step') || 0); const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1); @@ -44,11 +50,25 @@ export default function StoreCreatePage() { useEffect(() => { if (step !== 2 || !isWechatEnv()) return; + void fetchPartnerProfile() + .then((me) => setWechatReady(!!me.hasWechat)) + .catch(() => setWechatReady(false)); void weixinSdk.init().catch(() => { /* OssUploadField 点击时会再次初始化 */ }); }, [step]); + useEffect(() => { + if (!isWechatEnv() || !params.get('code')) return; + void handlePartnerWechatCallback() + .then((result) => { + if (result && savePartnerWechatAuth(result)) { + setWechatReady(true); + } + }) + .catch((e) => setError(e instanceof Error ? e.message : '微信授权失败')); + }, [params]); + useEffect(() => { void fetchPartnerCities() .then((items) => { @@ -259,6 +279,8 @@ export default function StoreCreatePage() { bizType="STORE_TITLE" mediaType="IMAGE" value={form.coverUrl} + wechatReady={wechatReady} + onWechatReadyChange={setWechatReady} onChange={(coverUrl) => patchForm({ coverUrl })} label="点击或拖拽上传" /> @@ -275,6 +297,8 @@ export default function StoreCreatePage() { bizType="STORE_ENV" mediaType="IMAGE" value={url} + wechatReady={wechatReady} + onWechatReadyChange={setWechatReady} label="添加照片" onChange={(nextUrl) => { const envPhotoUrls = [...form.envPhotoUrls]; @@ -294,6 +318,8 @@ export default function StoreCreatePage() { mediaType="FILE" accept="image/*,.pdf" value={form.contractUrl} + wechatReady={wechatReady} + onWechatReadyChange={setWechatReady} onChange={(contractUrl) => patchForm({ contractUrl })} label="上传合同副本" /> diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index aadbbc5..daad8cf 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -932,6 +932,14 @@ html, body, #root { aspect-ratio: 1; } +.partner-wechat-auth-hint { + margin-bottom: 12px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(166, 29, 36, 0.08); + border: 1px solid rgba(166, 29, 36, 0.15); +} + .partner-oss-upload-input { position: absolute; width: 1px; diff --git a/packages/weixin-sdk/src/auth.ts b/packages/weixin-sdk/src/auth.ts index 468951a..45cb6a1 100644 --- a/packages/weixin-sdk/src/auth.ts +++ b/packages/weixin-sdk/src/auth.ts @@ -71,7 +71,8 @@ export async function loginWithWechatCode( platform?: WechatLoginPlatform, ): Promise { const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5'); - return apiRequest(config, '/auth/login/wechat', { + const loginPath = config.wechatLoginPath ?? '/auth/login/wechat'; + return apiRequest(config, loginPath, { method: 'POST', body: JSON.stringify({ code, platform: resolvedPlatform }), }); diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts index 00450d0..e92270c 100644 --- a/packages/weixin-sdk/src/types.ts +++ b/packages/weixin-sdk/src/types.ts @@ -7,6 +7,8 @@ export type WeixinSdkConfig = { clientApp: string; /** 获取 access token(可选,登录后自动带) */ getAccessToken?: () => string | null; + /** 微信 code 登录接口,默认 /auth/login/wechat */ + wechatLoginPath?: string; }; export type WxInvokeResult = { diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 4ff3d71..008983c 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -114,7 +114,17 @@ export class PartnerAuthController { } @Post('login/wechat') - wechatLogin(@Body() dto: LoginWechatDto) { + @UseGuards(OptionalJwtAuthGuard) + wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) { + const user = (req as Request & { user?: AuthUser }).user; + if (user?.actorType === 'PARTNER') { + return this.authService.bindPartnerWechat( + user.actorId, + dto.code, + ClientApp.PARTNER_H5, + dto.platform ?? 'h5', + ); + } return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5'); } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index be076eb..bae5713 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -498,6 +498,51 @@ export class AuthService { }); } + async bindPartnerWechat( + partnerAccountId: bigint, + code: string, + clientApp: ClientApp, + platform: 'h5' | 'mini' = 'h5', + ) { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + const account = await this.prisma.partnerAccount.findUnique({ + where: { id: partnerAccountId }, + include: { partner: true }, + }); + if (!account) throw new BadRequestException('合伙人账号不存在'); + + const conflict = await this.prisma.partnerAccount.findFirst({ + where: { wxOpenId: session.openId, id: { not: partnerAccountId } }, + }); + if (conflict) { + throw new BadRequestException('该微信已绑定其他合伙人账号'); + } + + const updated = await this.prisma.partnerAccount.update({ + where: { id: partnerAccountId }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? account.wxUnionId, + lastLoginAt: new Date(), + }, + include: { partner: true }, + }); + + return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, { + id: updated.id.toString(), + partnerId: updated.partnerId.toString(), + name: updated.name, + phone: updated.phone, + isPrimary: updated.isPrimary === 1, + companyName: updated.partner.companyName, + }); + } + async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { this.assertWechatEnabled(); const session = diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index cdf1ab6..01293e8 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -32,6 +32,7 @@ export class PartnerMeController { phone: account.phone, isPrimary: account.isPrimary === 1, companyName: account.partner.companyName, + hasWechat: !!account.wxOpenId, }; } }