@ddn/egg-wechat 1.0.24 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,821 @@
1
+ 'use strict';
2
+
3
+ const Service = require('egg').Service;
4
+ const crypto = require('crypto');
5
+ const WXBizMsgCrypt = require('wechat-crypto');
6
+
7
+ function sha1(input) {
8
+ return crypto.createHash('sha1').update(String(input)).digest('hex');
9
+ }
10
+
11
+ function calcPlainSignature(token, timestamp, nonce) {
12
+ const list = [ String(token || ''), String(timestamp || ''), String(nonce || '') ].sort();
13
+ return sha1(list.join(''));
14
+ }
15
+
16
+ function getRawXmlBody(ctx) {
17
+ const body = ctx.request && ctx.request.body;
18
+ if (typeof body === 'string') return body;
19
+ if (Buffer.isBuffer(body)) return body.toString('utf8');
20
+
21
+ const rawBody = ctx.request && ctx.request.rawBody;
22
+ if (typeof rawBody === 'string') return rawBody;
23
+ if (Buffer.isBuffer(rawBody)) return rawBody.toString('utf8');
24
+
25
+ return '';
26
+ }
27
+
28
+ function safeSlice(input, n = 180) {
29
+ const v = String(input || '');
30
+ return v.length > n ? v.slice(0, n) : v;
31
+ }
32
+
33
+ class WechatPlatformHttpService extends Service {
34
+ get wechatConfig() {
35
+ return this.app.config.wechat || {};
36
+ }
37
+
38
+ // ===== 多租户参数名收敛(可配置) =====
39
+ // 背景:宿主不一定使用 daoId 作为租户标识参数名(可能叫 tenantId/orgId/spaceId 等)。
40
+ // 约定:
41
+ // - wechat.tenantIdParamName: 生成 redirect_uri/callbackUrl 时使用的参数名(默认 daoId)
42
+ // - wechat.tenantIdQueryKeys: 读取请求时允许的参数名列表(默认包含 tenantIdParamName + daoId + dao_id)
43
+ _getTenantIdParamName() {
44
+ const configured = this.wechatConfig.tenantIdParamName;
45
+ const v = String(configured || '').trim();
46
+ return v || 'daoId';
47
+ }
48
+
49
+ _getTenantIdQueryKeys() {
50
+ const configured = this.wechatConfig.tenantIdQueryKeys;
51
+ const keys = [];
52
+
53
+ const push = (k) => {
54
+ const v = String(k || '').trim();
55
+ if (!v) return;
56
+ if (!keys.includes(v)) keys.push(v);
57
+ };
58
+
59
+ if (Array.isArray(configured)) {
60
+ configured.forEach(push);
61
+ } else if (typeof configured === 'string') {
62
+ configured.split(',').map(s => s.trim()).forEach(push);
63
+ }
64
+
65
+ // 默认兼容:daoId/dao_id
66
+ push(this._getTenantIdParamName());
67
+ push('daoId');
68
+ push('dao_id');
69
+
70
+ return keys;
71
+ }
72
+
73
+ _resolveTenantIdFromRequest() {
74
+ const { ctx } = this;
75
+
76
+ // 1) 优先从 query 中按配置 key 读取
77
+ const query = ctx && ctx.query ? ctx.query : {};
78
+ const keys = this._getTenantIdQueryKeys();
79
+ for (const k of keys) {
80
+ const val = query && Object.prototype.hasOwnProperty.call(query, k) ? query[k] : undefined;
81
+ const v = String(val || '').trim();
82
+ if (v) return v;
83
+ }
84
+
85
+ // 2) 兜底:如果宿主有域名/多租户中间件,可能挂在 ctx.daoContext/ctx.dao
86
+ const ctxDaoId = ctx?.daoContext?.daoId || ctx?.dao?.id;
87
+ const ctxDaoIdStr = String(ctxDaoId || '').trim();
88
+ if (ctxDaoIdStr) return ctxDaoIdStr;
89
+
90
+ return '';
91
+ }
92
+
93
+ get mockMode() {
94
+ const v = this.wechatConfig.mockMode;
95
+ if (typeof v === 'string') return v === '1' || v.toLowerCase() === 'true';
96
+ return Boolean(v);
97
+ }
98
+
99
+ logStep(step, expected, actual, extra = {}) {
100
+ this.ctx.logger.debug('WECHAT_DEBUG_STEP', {
101
+ step,
102
+ expected,
103
+ actual,
104
+ ...extra,
105
+ });
106
+ }
107
+
108
+ async _ensureComponentCryptoConfig() {
109
+ const { app } = this;
110
+
111
+ const hasEnough = () => {
112
+ const c = app.config.wechat || {};
113
+ return Boolean(String(c.componentToken || '').trim())
114
+ && Boolean(String(c.componentEncodingAESKey || '').trim())
115
+ && Boolean(String(c.componentAppId || '').trim());
116
+ };
117
+
118
+ if (hasEnough()) return;
119
+
120
+ if (typeof app.wechatRefreshProviderConfig === 'function') {
121
+ await app.wechatRefreshProviderConfig();
122
+ if (hasEnough()) return;
123
+ }
124
+
125
+ if (typeof app.wechatRefreshUnifiedConfig === 'function') {
126
+ await app.wechatRefreshUnifiedConfig();
127
+ if (hasEnough()) return;
128
+ }
129
+
130
+ // 最后兜底:当宿主有 unifiedConfig service 时直接读一次
131
+ try {
132
+ const ctx = app.createAnonymousContext();
133
+ const unified = ctx?.service?.config?.unifiedConfig;
134
+ if (unified && typeof unified.get === 'function') {
135
+ const [ token, aesKey, appId ] = await Promise.all([
136
+ unified.get('wechat_platform.component.token'),
137
+ unified.get('wechat_platform.component.encoding_aes_key'),
138
+ unified.get('wechat_platform.component.app_id'),
139
+ ]);
140
+ app.config.wechat = Object.assign({}, app.config.wechat || {}, {
141
+ componentToken: typeof token === 'string' ? token.trim() : token,
142
+ componentEncodingAESKey: typeof aesKey === 'string' ? aesKey.trim() : aesKey,
143
+ componentAppId: typeof appId === 'string' ? appId.trim() : appId,
144
+ });
145
+ }
146
+ } catch (e) {
147
+ // ignore
148
+ }
149
+ }
150
+
151
+ _parseXml(xml) {
152
+ if (!xml || typeof xml !== 'string') return null;
153
+ try {
154
+ // 依赖插件扩展的 helper.xml2json
155
+ if (this.ctx.helper && typeof this.ctx.helper.xml2json === 'function') {
156
+ return this.ctx.helper.xml2json(xml);
157
+ }
158
+ } catch (e) {
159
+ // ignore
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ _getEncryptFromBody() {
166
+ const { ctx } = this;
167
+ const body = ctx.request && ctx.request.body;
168
+ if (body && typeof body === 'object') {
169
+ return body.Encrypt || body.encrypt || body?.xml?.Encrypt || body?.xml?.encrypt || null;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ async notify() {
175
+ const { ctx } = this;
176
+
177
+ await this._ensureComponentCryptoConfig();
178
+
179
+ const componentToken = String(this.wechatConfig.componentToken || '').trim();
180
+ const componentEncodingAESKey = String(this.wechatConfig.componentEncodingAESKey || '').trim();
181
+ const componentAppId = String(this.wechatConfig.componentAppId || '').trim();
182
+
183
+ try {
184
+ const { msg_signature, timestamp, nonce } = ctx.query;
185
+
186
+ this.logStep(
187
+ 'COMPONENT_NOTIFY_1_CONFIG',
188
+ { tokenPresent: true, encodingAesKeyPresent: true, componentAppIdPresent: true },
189
+ {
190
+ tokenPresent: Boolean(componentToken),
191
+ encodingAesKeyPresent: Boolean(componentEncodingAESKey),
192
+ componentAppIdPresent: Boolean(componentAppId),
193
+ componentAppId,
194
+ }
195
+ );
196
+
197
+ this.logStep(
198
+ 'COMPONENT_NOTIFY_2_QUERY',
199
+ { hasSignatureParams: true },
200
+ {
201
+ hasMsgSignature: Boolean(msg_signature),
202
+ hasTimestamp: Boolean(timestamp),
203
+ hasNonce: Boolean(nonce),
204
+ }
205
+ );
206
+
207
+ const xml = getRawXmlBody(ctx);
208
+ if (!xml && (!ctx.request.body || typeof ctx.request.body !== 'object')) {
209
+ this.logStep(
210
+ 'COMPONENT_NOTIFY_3_BODY',
211
+ { bodyNotEmpty: true },
212
+ { bodyNotEmpty: false, contentType: ctx.get('Content-Type') }
213
+ );
214
+ ctx.body = 'success';
215
+ return;
216
+ }
217
+
218
+ const cryptor = new WXBizMsgCrypt(componentToken, componentEncodingAESKey, componentAppId);
219
+
220
+ let encrypt = this._getEncryptFromBody();
221
+ if (!encrypt) {
222
+ const parsed = this._parseXml(xml);
223
+ encrypt = parsed?.Encrypt;
224
+ }
225
+
226
+ if (!encrypt) {
227
+ this.logStep(
228
+ 'COMPONENT_NOTIFY_4_ENCRYPT',
229
+ { encryptPresent: true },
230
+ { encryptPresent: false, bodyType: typeof ctx.request.body }
231
+ );
232
+ ctx.body = 'success';
233
+ return;
234
+ }
235
+
236
+ this.logStep(
237
+ 'COMPONENT_NOTIFY_4_ENCRYPT',
238
+ { encryptPresent: true },
239
+ { encryptPresent: true }
240
+ );
241
+
242
+ if (msg_signature && timestamp && nonce) {
243
+ const expected = cryptor.getSignature(timestamp, nonce, encrypt);
244
+ if (expected !== msg_signature) {
245
+ this.logStep(
246
+ 'COMPONENT_NOTIFY_5_SIGNATURE',
247
+ { signatureMatches: true },
248
+ { signatureMatches: false }
249
+ );
250
+ ctx.body = 'success';
251
+ return;
252
+ }
253
+ }
254
+
255
+ this.logStep(
256
+ 'COMPONENT_NOTIFY_5_SIGNATURE',
257
+ { signatureMatches: true },
258
+ { signatureMatches: true }
259
+ );
260
+
261
+ const decrypted = cryptor.decrypt(encrypt);
262
+ const message = decrypted && decrypted.message;
263
+ const info = this._parseXml(message);
264
+
265
+ this.logStep(
266
+ 'COMPONENT_NOTIFY_6_DECRYPTED',
267
+ { infoTypePresent: true },
268
+ {
269
+ infoTypePresent: Boolean(info?.InfoType),
270
+ infoType: info?.InfoType,
271
+ hasTicket: Boolean(info?.ComponentVerifyTicket),
272
+ ticketSample: info?.ComponentVerifyTicket ? String(info.ComponentVerifyTicket).slice(0, 12) + '***' : undefined,
273
+ }
274
+ );
275
+
276
+ if (info && info.InfoType === 'component_verify_ticket') {
277
+ this.logStep(
278
+ 'COMPONENT_NOTIFY_7_SAVE_TICKET',
279
+ { willSaveTicket: true },
280
+ { willSaveTicket: true, ticketPresent: Boolean(info.ComponentVerifyTicket) }
281
+ );
282
+
283
+ const ticket = info.ComponentVerifyTicket;
284
+
285
+ // 优先走宿主的业务 service(ddn-hub 会做更多记录/兼容)
286
+ if (ctx.service?.third?.wechatPlatform && typeof ctx.service.third.wechatPlatform.saveComponentVerifyTicket === 'function') {
287
+ await ctx.service.third.wechatPlatform.saveComponentVerifyTicket(ticket);
288
+ } else {
289
+ await ctx.service.wechat.component.saveComponentVerifyTicket(ticket);
290
+ }
291
+ }
292
+
293
+ ctx.body = 'success';
294
+ } catch (err) {
295
+ this.logStep(
296
+ 'COMPONENT_NOTIFY_999_ERROR',
297
+ { ok: true },
298
+ { ok: false, message: err?.message }
299
+ );
300
+ ctx.body = 'success';
301
+ }
302
+ }
303
+
304
+ async getAuthUrl() {
305
+ const { ctx } = this;
306
+ const tenantId = this._resolveTenantIdFromRequest();
307
+ const tenantKey = this._getTenantIdParamName();
308
+
309
+ if (!tenantId) {
310
+ ctx.throw(400, `Missing tenant id (${tenantKey})`);
311
+ }
312
+
313
+ this.logStep(
314
+ 'COMPONENT_AUTH_URL_1_INPUT',
315
+ { daoIdPresent: true },
316
+ { daoIdPresent: true, daoId: tenantId, tenantKey }
317
+ );
318
+
319
+ // 规则:授权入口页域名与回调页域名必须一致,并且与第三方平台配置的“登录授权的发起页域名”一致。
320
+ // mockMode 下优先使用配置域名,避免本地端口 origin 干扰。
321
+ const normalizePublicBaseUrl = (raw) => {
322
+ let v = String(raw || '').trim();
323
+ if (!v) return '';
324
+ if (!/^https?:\/\//i.test(v)) v = `https://${v}`;
325
+ try {
326
+ const u = new URL(v);
327
+ return u.origin;
328
+ } catch (e) {
329
+ return v.replace(/\/$/, '');
330
+ }
331
+ };
332
+
333
+ const publicBaseUrlConfig = normalizePublicBaseUrl(this.wechatConfig.publicBaseUrl);
334
+ const requestOrigin = normalizePublicBaseUrl(ctx.origin);
335
+
336
+ const publicBaseUrl = this.mockMode
337
+ ? (publicBaseUrlConfig || requestOrigin)
338
+ : (requestOrigin || publicBaseUrlConfig);
339
+
340
+ this.logStep(
341
+ 'COMPONENT_AUTH_URL_2_BASE_URL',
342
+ { publicBaseUrlResolved: true },
343
+ {
344
+ requestOrigin,
345
+ configPublicBaseUrl: publicBaseUrlConfig,
346
+ isMockMode: this.mockMode,
347
+ publicBaseUrl,
348
+ }
349
+ );
350
+
351
+ if (!publicBaseUrl) {
352
+ ctx.throw(500, 'Failed to determine publicBaseUrl (wechat.publicBaseUrl / request origin)');
353
+ }
354
+
355
+ const callbackUrl = `${publicBaseUrl}/api/v1/wechat/component/auth_callback?${encodeURIComponent(tenantKey)}=${encodeURIComponent(tenantId)}`;
356
+
357
+ this.logStep(
358
+ 'COMPONENT_AUTH_URL_3_CALLBACK',
359
+ { callbackUrlNonEmpty: true },
360
+ { callbackUrlSample: safeSlice(callbackUrl, 180) }
361
+ );
362
+
363
+ const urls = await ctx.service.wechat.component.getAuthUrl(callbackUrl, 3);
364
+
365
+ const pcUrl = typeof urls === 'string' ? urls : (urls?.pc || urls?.url);
366
+ const mobileUrl = typeof urls === 'string' ? undefined : (urls?.mobile || urls?.mobileUrl);
367
+ const finalUrl = pcUrl || mobileUrl;
368
+
369
+ this.logStep(
370
+ 'COMPONENT_AUTH_URL_4_RESULT',
371
+ { finalUrlNonEmpty: true, shouldContainOpenWeixin: true },
372
+ {
373
+ finalUrlNonEmpty: Boolean(finalUrl),
374
+ containsOpenWeixin: finalUrl ? String(finalUrl).includes('open.weixin.qq.com') : false,
375
+ finalUrlMasked: finalUrl ? String(finalUrl).replace(/([?&]pre_auth_code=)([^&]+)/, '$1***') : '',
376
+ urlsType: typeof urls,
377
+ }
378
+ );
379
+
380
+ if (!finalUrl) {
381
+ ctx.throw(500, 'Failed to generate auth url: empty url');
382
+ }
383
+
384
+ return { url: finalUrl, mobileUrl };
385
+ }
386
+
387
+ async authCallback() {
388
+ const { ctx } = this;
389
+ const { auth_code } = ctx.query;
390
+ const tenantId = this._resolveTenantIdFromRequest();
391
+ const tenantKey = this._getTenantIdParamName();
392
+
393
+ if (!auth_code || !tenantId) {
394
+ ctx.throw(400, `Missing auth_code or ${tenantKey}`);
395
+ }
396
+
397
+ this.logStep(
398
+ 'COMPONENT_AUTH_CALLBACK_1_INPUT',
399
+ { authCodePresent: true, daoIdPresent: true },
400
+ {
401
+ authCodePresent: Boolean(auth_code),
402
+ daoIdPresent: Boolean(tenantId),
403
+ daoId: tenantId,
404
+ tenantKey,
405
+ authCodeMasked: auth_code ? String(auth_code).slice(0, 8) + '***' : '',
406
+ }
407
+ );
408
+
409
+ try {
410
+ if (ctx.service?.third?.wechatPlatform && typeof ctx.service.third.wechatPlatform.handleAuthCallback === 'function') {
411
+ await ctx.service.third.wechatPlatform.handleAuthCallback(auth_code, tenantId);
412
+ } else {
413
+ ctx.throw(500, 'Missing host handler: ctx.service.third.wechatPlatform.handleAuthCallback');
414
+ }
415
+
416
+ this.logStep(
417
+ 'COMPONENT_AUTH_CALLBACK_2_HANDLE',
418
+ { handleSuccess: true },
419
+ { handleSuccess: true }
420
+ );
421
+
422
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
423
+ ctx.body = `<!doctype html>
424
+ <html lang="zh-CN">
425
+ <head>
426
+ <meta charset="utf-8" />
427
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
428
+ <title>微信授权成功</title>
429
+ <style>
430
+ body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;padding:24px;background:#f7f8fa;}
431
+ .card{max-width:520px;margin:48px auto;background:#fff;border-radius:12px;padding:24px;box-shadow:0 4px 14px rgba(0,0,0,.06);}
432
+ h1{font-size:18px;margin:0 0 12px;}
433
+ p{margin:0;color:#666;line-height:1.6;}
434
+ .ok{color:#1a7f37;font-weight:600;}
435
+ </style>
436
+ </head>
437
+ <body>
438
+ <div class="card">
439
+ <h1 class="ok">授权成功</h1>
440
+ <p>你可以关闭本页面,返回管理后台继续操作。</p>
441
+ </div>
442
+ <script>
443
+ try {
444
+ window.opener && window.opener.postMessage('wechat_auth_success', '*');
445
+ } catch (e) {}
446
+ try {
447
+ window.parent && window.parent.postMessage('wechat_auth_success', '*');
448
+ } catch (e) {}
449
+ setTimeout(function(){ try{ window.close(); } catch(e){} }, 200);
450
+ </script>
451
+ </body>
452
+ </html>`;
453
+ } catch (err) {
454
+ this.logStep(
455
+ 'COMPONENT_AUTH_CALLBACK_999_ERROR',
456
+ { ok: true },
457
+ { ok: false, message: err?.message }
458
+ );
459
+
460
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
461
+ const msg = String(err?.message || '授权失败');
462
+ ctx.body = `<!doctype html>
463
+ <html lang="zh-CN">
464
+ <head>
465
+ <meta charset="utf-8" />
466
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
467
+ <title>微信授权失败</title>
468
+ </head>
469
+ <body>
470
+ <p>授权失败:${msg.replace(/</g, '&lt;')}</p>
471
+ </body>
472
+ </html>`;
473
+ }
474
+ }
475
+
476
+ async mockAuthorize() {
477
+ const { ctx } = this;
478
+
479
+ if (!this.mockMode) {
480
+ ctx.throw(404);
481
+ }
482
+
483
+ const { redirect_uri, auth_type } = ctx.query;
484
+
485
+ this.logStep(
486
+ 'COMPONENT_MOCK_AUTHORIZE_1_INPUT',
487
+ { redirectUriPresent: true },
488
+ {
489
+ redirectUriPresent: Boolean(redirect_uri),
490
+ redirectUriSample: redirect_uri ? safeSlice(redirect_uri, 180) : undefined,
491
+ authType: auth_type,
492
+ }
493
+ );
494
+
495
+ if (!redirect_uri) {
496
+ ctx.throw(400, 'Missing redirect_uri');
497
+ }
498
+
499
+ let target = null;
500
+ try {
501
+ const u = new URL(String(redirect_uri));
502
+ const allowedHostsRaw = this.wechatConfig.mockAuthorizeAllowedHosts;
503
+ const allowedHosts = new Set(
504
+ String(allowedHostsRaw || '')
505
+ .split(',')
506
+ .map(s => s.trim().toLowerCase())
507
+ .filter(Boolean)
508
+ );
509
+
510
+ if (allowedHosts.size === 0) {
511
+ // 默认:仅允许当前 Host(更安全)
512
+ const host = String(ctx.host || '').trim().toLowerCase();
513
+ if (host) allowedHosts.add(host);
514
+ }
515
+
516
+ const redirectHost = String(u.hostname || '').trim().toLowerCase();
517
+ if (!allowedHosts.has(redirectHost)) {
518
+ ctx.throw(400, 'Invalid redirect_uri_host');
519
+ }
520
+
521
+ if (![ 'http:', 'https:' ].includes(String(u.protocol || '').toLowerCase())) {
522
+ ctx.throw(400, 'Invalid redirect_uri_protocol');
523
+ }
524
+
525
+ if (u.pathname !== '/api/v1/wechat/component/auth_callback') {
526
+ ctx.throw(400, 'Invalid redirect_uri path');
527
+ }
528
+
529
+ const authCode = `mock_auth_code_${Date.now()}`;
530
+ u.searchParams.set('auth_code', authCode);
531
+ u.searchParams.set('expires_in', '3600');
532
+ if (auth_type) u.searchParams.set('auth_type', auth_type);
533
+
534
+ // 使用相对路径重定向,避免 egg-security safe_redirect 拦截绝对 URL
535
+ target = `${u.pathname}?${u.searchParams.toString()}`;
536
+
537
+ this.logStep(
538
+ 'COMPONENT_MOCK_AUTHORIZE_2_REDIRECT',
539
+ { ok: true },
540
+ { ok: true, targetSample: safeSlice(target, 180), authCodeMasked: authCode.slice(0, 8) + '***' }
541
+ );
542
+ } catch (e) {
543
+ ctx.throw(400, 'Invalid redirect_uri');
544
+ }
545
+
546
+ ctx.redirect(target);
547
+ }
548
+
549
+ async callback() {
550
+ const { ctx } = this;
551
+
552
+ await this._ensureComponentCryptoConfig();
553
+
554
+ const componentToken = String(this.wechatConfig.componentToken || '').trim();
555
+ const componentEncodingAESKey = String(this.wechatConfig.componentEncodingAESKey || '').trim();
556
+ const componentAppId = String(this.wechatConfig.componentAppId || '').trim();
557
+
558
+ const { appid } = ctx.params;
559
+ const { msg_signature, signature, timestamp, nonce, echostr } = ctx.query;
560
+ let encryptType = String(ctx.query.encrypt_type || '').toLowerCase();
561
+
562
+ if (ctx.method === 'GET') {
563
+ try {
564
+ if (!echostr) {
565
+ ctx.body = '';
566
+ return;
567
+ }
568
+
569
+ const isEncrypted = encryptType === 'aes' || !!msg_signature;
570
+ if (isEncrypted) {
571
+ const cryptor = new WXBizMsgCrypt(componentToken, componentEncodingAESKey, componentAppId);
572
+ if (msg_signature && timestamp && nonce) {
573
+ const expected = cryptor.getSignature(timestamp, nonce, echostr);
574
+ if (expected !== msg_signature) {
575
+ ctx.logger.error('WeChat Callback VerifyURL signature mismatch (aes mode)', { appid, expected, msg_signature });
576
+ ctx.status = 403;
577
+ ctx.body = '';
578
+ return;
579
+ }
580
+ }
581
+
582
+ const decrypted = cryptor.decrypt(echostr);
583
+ ctx.body = decrypted.message;
584
+ return;
585
+ }
586
+
587
+ if (signature && timestamp && nonce) {
588
+ const expected = calcPlainSignature(componentToken, timestamp, nonce);
589
+ if (expected !== signature) {
590
+ ctx.logger.error('WeChat Callback VerifyURL signature mismatch (raw mode)', { appid, expected, signature });
591
+ ctx.status = 403;
592
+ ctx.body = '';
593
+ return;
594
+ }
595
+ }
596
+
597
+ ctx.body = echostr;
598
+ return;
599
+ } catch (err) {
600
+ ctx.logger.error(`WeChat Callback VerifyURL Error for ${appid}`, err);
601
+ ctx.status = 500;
602
+ ctx.body = '';
603
+ return;
604
+ }
605
+ }
606
+
607
+ // ===== POST:消息/事件回调 =====
608
+
609
+ const xml = getRawXmlBody(ctx);
610
+
611
+ try {
612
+ const cryptor = new WXBizMsgCrypt(componentToken, componentEncodingAESKey, componentAppId);
613
+
614
+ const sendReplyXml = replyXml => {
615
+ ctx.set('Content-Type', 'application/xml');
616
+ ctx.body = replyXml;
617
+ };
618
+
619
+ const sendEncryptedReply = replyXml => {
620
+ const ts = String(Math.floor(Date.now() / 1000));
621
+ const nonceToSend = crypto.randomBytes(8).toString('hex');
622
+ const encrypted = cryptor.encrypt(replyXml);
623
+ const signatureToSend = cryptor.getSignature(ts, nonceToSend, encrypted);
624
+
625
+ const wrappedXml = `<xml>
626
+ <Encrypt><![CDATA[${encrypted}]]></Encrypt>
627
+ <MsgSignature><![CDATA[${signatureToSend}]]></MsgSignature>
628
+ <TimeStamp>${ts}</TimeStamp>
629
+ <Nonce><![CDATA[${nonceToSend}]]></Nonce>
630
+ </xml>`;
631
+
632
+ sendReplyXml(wrappedXml.trim());
633
+ };
634
+
635
+ const parseIncomingPlainMessage = async () => {
636
+ if (ctx.request.body && typeof ctx.request.body === 'object') {
637
+ const maybeXml = ctx.request.body.xml || ctx.request.body;
638
+ if (maybeXml && (maybeXml.MsgType || maybeXml.ToUserName || maybeXml.FromUserName)) return maybeXml;
639
+ }
640
+ return this._parseXml(xml);
641
+ };
642
+
643
+ let encrypt = this._getEncryptFromBody();
644
+ if (!encrypt) {
645
+ const parsed = this._parseXml(xml);
646
+ encrypt = parsed?.Encrypt;
647
+ }
648
+
649
+ if (!encryptType && encrypt) encryptType = 'aes';
650
+ const isEncrypted = encryptType === 'aes';
651
+
652
+ let msg = null;
653
+ if (isEncrypted) {
654
+ if (!encrypt) {
655
+ ctx.logger.error('WeChat Callback missing Encrypt field (aes mode)', {
656
+ appid,
657
+ contentType: ctx.get('Content-Type'),
658
+ query: ctx.query,
659
+ bodyType: typeof ctx.request.body,
660
+ });
661
+ ctx.status = 400;
662
+ ctx.body = '';
663
+ return;
664
+ }
665
+
666
+ if (msg_signature && timestamp && nonce) {
667
+ const expected = cryptor.getSignature(timestamp, nonce, encrypt);
668
+ if (expected !== msg_signature) {
669
+ ctx.logger.error('WeChat Callback signature mismatch (aes mode)', { appid, expected, msg_signature });
670
+ ctx.status = 403;
671
+ ctx.body = '';
672
+ return;
673
+ }
674
+ }
675
+
676
+ const decrypted = cryptor.decrypt(encrypt);
677
+ msg = this._parseXml(decrypted.message);
678
+ } else {
679
+ if (signature && timestamp && nonce) {
680
+ const expected = calcPlainSignature(componentToken, timestamp, nonce);
681
+ if (expected !== signature) {
682
+ ctx.logger.error('WeChat Callback signature mismatch (raw mode)', { appid, expected, signature });
683
+ ctx.status = 403;
684
+ ctx.body = '';
685
+ return;
686
+ }
687
+ }
688
+
689
+ msg = await parseIncomingPlainMessage();
690
+ }
691
+
692
+ ctx.logger.info(`Received Callback for ${appid}:`, msg);
693
+
694
+ if (!msg) {
695
+ ctx.body = 'success';
696
+ return;
697
+ }
698
+
699
+ // 可选:宿主的媒体安全检测回调
700
+ if (
701
+ msg.MsgType === 'event' &&
702
+ String(msg.Event || '').toLowerCase() === 'wxa_media_check' &&
703
+ ctx.service?.ai?.review &&
704
+ typeof ctx.service.ai.review.handleWechatMediaCheckCallback === 'function'
705
+ ) {
706
+ await ctx.service.ai.review.handleWechatMediaCheckCallback(appid, msg);
707
+ ctx.body = 'success';
708
+ return;
709
+ }
710
+
711
+ const buildReplyXml = (reply, incoming) => {
712
+ if (!reply || !incoming) return null;
713
+
714
+ const toUser = incoming.FromUserName;
715
+ const fromUser = incoming.ToUserName;
716
+ const createTime = Math.floor(Date.now() / 1000);
717
+
718
+ if (reply.type === 'text') {
719
+ return `<xml>
720
+ <ToUserName><![CDATA[${toUser}]]></ToUserName>
721
+ <FromUserName><![CDATA[${fromUser}]]></FromUserName>
722
+ <CreateTime>${createTime}</CreateTime>
723
+ <MsgType><![CDATA[text]]></MsgType>
724
+ <Content><![CDATA[${reply.content || ''}]]></Content>
725
+ </xml>`;
726
+ }
727
+
728
+ if (reply.type === 'image') {
729
+ return `<xml>
730
+ <ToUserName><![CDATA[${toUser}]]></ToUserName>
731
+ <FromUserName><![CDATA[${fromUser}]]></FromUserName>
732
+ <CreateTime>${createTime}</CreateTime>
733
+ <MsgType><![CDATA[image]]></MsgType>
734
+ <Image><MediaId><![CDATA[${reply.mediaId || ''}]]></MediaId></Image>
735
+ </xml>`;
736
+ }
737
+
738
+ if (reply.type === 'news') {
739
+ const articles = Array.isArray(reply.content) ? reply.content : [];
740
+ const itemsXml = articles.map(a => `<item>
741
+ <Title><![CDATA[${a.title || ''}]]></Title>
742
+ <Description><![CDATA[${a.description || ''}]]></Description>
743
+ <PicUrl><![CDATA[${a.picUrl || ''}]]></PicUrl>
744
+ <Url><![CDATA[${a.url || ''}]]></Url>
745
+ </item>`).join('\n');
746
+
747
+ return `<xml>
748
+ <ToUserName><![CDATA[${toUser}]]></ToUserName>
749
+ <FromUserName><![CDATA[${fromUser}]]></FromUserName>
750
+ <CreateTime>${createTime}</CreateTime>
751
+ <MsgType><![CDATA[news]]></MsgType>
752
+ <ArticleCount>${articles.length}</ArticleCount>
753
+ <Articles>
754
+ ${itemsXml}
755
+ </Articles>
756
+ </xml>`;
757
+ }
758
+
759
+ return null;
760
+ };
761
+
762
+ // 全网发布自动化测试专用逻辑
763
+ if (msg.MsgType === 'text') {
764
+ if (msg.Content === 'TESTCOMPONENT_MSG_TYPE_TEXT') {
765
+ const replyContent = 'TESTCOMPONENT_MSG_TYPE_TEXT_callback';
766
+ const replyXml = `<xml>
767
+ <ToUserName><![CDATA[${msg.FromUserName}]]></ToUserName>
768
+ <FromUserName><![CDATA[${msg.ToUserName}]]></FromUserName>
769
+ <CreateTime>${Math.floor(Date.now() / 1000)}</CreateTime>
770
+ <MsgType><![CDATA[text]]></MsgType>
771
+ <Content><![CDATA[${replyContent}]]></Content>
772
+ </xml>`;
773
+
774
+ if (isEncrypted) {
775
+ sendEncryptedReply(replyXml);
776
+ } else {
777
+ sendReplyXml(replyXml);
778
+ }
779
+ return;
780
+ }
781
+
782
+ if (typeof msg.Content === 'string' && msg.Content.startsWith('QUERY_AUTH_CODE:')) {
783
+ const queryAuthCode = msg.Content.replace('QUERY_AUTH_CODE:', '');
784
+ ctx.body = '';
785
+
786
+ ctx.runInBackground(async () => {
787
+ try {
788
+ if (ctx.service?.third?.wechatPlatform && typeof ctx.service.third.wechatPlatform.handleTestAuthCode === 'function') {
789
+ await ctx.service.third.wechatPlatform.handleTestAuthCode(queryAuthCode, msg.FromUserName);
790
+ }
791
+ } catch (e) {
792
+ ctx.logger.error('Handle Test Auth Code Error:', e);
793
+ }
794
+ });
795
+ return;
796
+ }
797
+ }
798
+
799
+ // 非全网发布测试消息:交给宿主的消息处理 service(若不存在则直接 success)
800
+ if (ctx.service?.third?.wechatMessage && typeof ctx.service.third.wechatMessage.handleMessage === 'function') {
801
+ const reply = await ctx.service.third.wechatMessage.handleMessage(appid, msg);
802
+ const replyXml = buildReplyXml(reply, msg);
803
+ if (replyXml) {
804
+ if (isEncrypted) {
805
+ sendEncryptedReply(replyXml);
806
+ } else {
807
+ sendReplyXml(replyXml);
808
+ }
809
+ return;
810
+ }
811
+ }
812
+
813
+ ctx.body = 'success';
814
+ } catch (err) {
815
+ ctx.logger.error('Callback Error:', err);
816
+ ctx.body = 'success';
817
+ }
818
+ }
819
+ }
820
+
821
+ module.exports = WechatPlatformHttpService;