diff --git a/packages/weixin-sdk/src/chooseImage.ts b/packages/weixin-sdk/src/chooseImage.ts index 013d663..bfcced3 100644 --- a/packages/weixin-sdk/src/chooseImage.ts +++ b/packages/weixin-sdk/src/chooseImage.ts @@ -7,6 +7,39 @@ export type ChooseWechatImageOptions = { sourceType?: Array<'album' | 'camera'>; }; +async function reportChooseImageEvent( + config: WeixinSdkConfig, + payload: { + status: 'fail'; + errMsg: string; + sourceType?: string; + stage: 'jssdk' | 'choose' | 'read' | 'empty'; + }, +) { + if (/cancel/i.test(payload.errMsg)) return; + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': config.clientApp, + }; + const token = config.getAccessToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + await fetch(`${config.apiBase ?? '/api/v1'}/common/wechat/choose-image`, { + method: 'POST', + headers, + body: JSON.stringify({ + status: 'fail', + errMsg: payload.errMsg, + sourceType: payload.sourceType, + stage: payload.stage, + pageUrl: typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined, + }), + }); + } catch { + /* 上报失败不影响主流程 */ + } +} + function base64ToFile(base64: string, fileName: string): File { const normalized = base64.startsWith('data:') ? base64 @@ -52,30 +85,58 @@ export async function chooseWechatImages( if (platform === 'wechat-h5') { const pageUrl = typeof window !== 'undefined' ? window.location.href.split('#')[0] : ''; - await ensureJssdkReady({ - apiBase: config.apiBase ?? '/api/v1', - clientApp: config.clientApp, - getAccessToken: config.getAccessToken, - url: pageUrl, - jsApiList: ['chooseImage', 'getLocalImgData'], - }); + const sourceTypeKey = sourceType.join(','); + try { + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + url: pageUrl, + jsApiList: ['chooseImage', 'getLocalImgData'], + }); + } catch (e) { + const errMsg = e instanceof Error ? e.message : 'JSSDK 初始化失败'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' }); + throw e; + } if (!window.wx?.chooseImage) { - throw new Error('微信选图接口不可用,请刷新页面后重试'); + const errMsg = '微信选图接口不可用,请刷新页面后重试'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'jssdk' }); + throw new Error(errMsg); } - const localIds = await new Promise((resolve, reject) => { - window.wx!.chooseImage!({ - count, - sizeType: ['compressed'], - sourceType, - success: (res) => resolve(res.localIds ?? []), - fail: (err) => reject(new Error(err.errMsg || '无法打开相册')), + let localIds: string[]; + try { + localIds = await new Promise((resolve, reject) => { + window.wx!.chooseImage!({ + count, + sizeType: ['compressed'], + sourceType, + success: (res) => resolve(res.localIds ?? []), + fail: (err) => reject(new Error(err.errMsg || '无法打开相册')), + }); }); - }); + } catch (e) { + const errMsg = e instanceof Error ? e.message : '无法打开相册'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'choose' }); + throw e; + } + + if (!localIds.length) { + const errMsg = '未选择图片'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'empty' }); + return []; + } const files: File[] = []; for (const localId of localIds) { - files.push(await localIdToFile(localId)); + try { + files.push(await localIdToFile(localId)); + } catch (e) { + const errMsg = e instanceof Error ? e.message : '读取图片失败'; + void reportChooseImageEvent(config, { status: 'fail', errMsg, sourceType: sourceTypeKey, stage: 'read' }); + throw e; + } } return files; } diff --git a/server/dukang-api/src/integrations/wechat/wechat-log.util.ts b/server/dukang-api/src/integrations/wechat/wechat-log.util.ts index 06959f6..9cd9132 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-log.util.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-log.util.ts @@ -5,6 +5,11 @@ export type WechatActorRef = { refId: bigint; }; +export function wechatActorRefFromAuth(actorType?: string, actorId?: bigint): WechatActorRef | undefined { + if (!actorType || actorId == null) return undefined; + return { refType: actorType, refId: actorId }; +} + type LogWechatAuthInput = { scene: string; requestUrl?: string; diff --git a/server/dukang-api/src/modules/common/wechat-location.service.ts b/server/dukang-api/src/modules/common/wechat-location.service.ts index 2ef7068..95fd68f 100644 --- a/server/dukang-api/src/modules/common/wechat-location.service.ts +++ b/server/dukang-api/src/modules/common/wechat-location.service.ts @@ -128,4 +128,29 @@ export class WechatLocationService { return result; } + + async reportChooseImage(input: { + status: 'success' | 'fail'; + errMsg?: string; + sourceType?: string; + stage?: string; + pageUrl?: string; + actorRef?: WechatActorRef; + }) { + const logId = await logWechatAuth(this.prisma, { + scene: 'CHOOSE_IMAGE', + requestUrl: input.pageUrl?.split('#')[0]?.slice(0, 512), + requestBody: { + status: input.status, + ...(input.sourceType ? { sourceType: input.sourceType } : {}), + ...(input.stage ? { stage: input.stage } : {}), + ...(input.errMsg ? { errMsg: input.errMsg.slice(0, 200) } : {}), + }, + responseBody: { reported: true }, + status: input.status === 'success' ? 'SUCCESS' : 'FAILED', + errorMessage: input.status === 'fail' ? input.errMsg?.slice(0, 512) : undefined, + actorRef: input.actorRef, + }); + return { ok: true, logId: logId.toString() }; + } } diff --git a/server/dukang-api/src/modules/common/wechat.controller.ts b/server/dukang-api/src/modules/common/wechat.controller.ts index 66ca90c..9eeaa53 100644 --- a/server/dukang-api/src/modules/common/wechat.controller.ts +++ b/server/dukang-api/src/modules/common/wechat.controller.ts @@ -6,6 +6,7 @@ import { WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import type { AuthUser } from '../../common/guards/jwt-auth.guard'; +import { wechatActorRefFromAuth } from '../../integrations/wechat/wechat-log.util'; import { WechatLocationService } from './wechat-location.service'; class PhoneNumberDto { @@ -41,6 +42,29 @@ class WechatLocationDto { errMsg?: string; } +class WechatChooseImageDto { + @IsString() + @IsIn(['success', 'fail']) + status: 'success' | 'fail'; + + @IsString() + @IsOptional() + errMsg?: string; + + @IsString() + @IsOptional() + sourceType?: string; + + @IsString() + @IsIn(['jssdk', 'choose', 'read', 'empty']) + @IsOptional() + stage?: 'jssdk' | 'choose' | 'read' | 'empty'; + + @IsString() + @IsOptional() + pageUrl?: string; +} + @Controller('common/wechat') export class WechatController { constructor( @@ -91,4 +115,18 @@ export class WechatController { userId, }); } + + @Post('choose-image') + @UseGuards(OptionalJwtAuthGuard) + reportChooseImage(@Req() req: Request, @Body() dto: WechatChooseImageDto) { + const user = (req as Request & { user?: AuthUser }).user; + return this.locationService.reportChooseImage({ + status: dto.status, + errMsg: dto.errMsg, + sourceType: dto.sourceType, + stage: dto.stage, + pageUrl: dto.pageUrl, + actorRef: wechatActorRefFromAuth(user?.actorType, user?.actorId), + }); + } }