@ddn/egg-wechat 1.0.25 → 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.
- package/README.md +21 -0
- package/app/service/wechat/component.js +150 -3
- package/app/service/wechat/platform_http.js +821 -0
- package/app.js +198 -0
- package/config/config.default.js +8 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -63,6 +63,27 @@ exports.wechat = {
|
|
|
63
63
|
componentAppSecret: '',
|
|
64
64
|
componentToken: '',
|
|
65
65
|
componentEncodingAESKey: '',
|
|
66
|
+
|
|
67
|
+
// ===== 可选:动态注入/覆盖配置(覆盖所有模块) =====
|
|
68
|
+
// 场景:配置存储在数据库/远程配置中心,不希望只靠环境变量。
|
|
69
|
+
// init 支持同步/异步,返回的对象会合并进 wechat 配置。
|
|
70
|
+
init: async (app) => {
|
|
71
|
+
// 例:从你自己的配置系统拉取(伪代码)
|
|
72
|
+
// const ctx = app.createAnonymousContext();
|
|
73
|
+
// const componentAppId = await ctx.service.config.get('wechat.component.appid');
|
|
74
|
+
// const componentAppSecret = await ctx.service.config.get('wechat.component.secret');
|
|
75
|
+
// return { componentAppId, componentAppSecret };
|
|
76
|
+
return {};
|
|
77
|
+
},
|
|
78
|
+
// 或者用静态 override(无需异步)
|
|
79
|
+
// override: { componentAppId: 'wx...', componentAppSecret: '...' },
|
|
80
|
+
|
|
81
|
+
// ===== 可选:运行时从统一配置读取(无需把密钥写进 config.wechat) =====
|
|
82
|
+
// 若宿主项目存在 ctx.service.config.unifiedConfig.get(key)(例如 ddn-hub),
|
|
83
|
+
// 插件可在运行时读取 wechat_platform.* 并周期刷新到内存。
|
|
84
|
+
// 注意:这是“读取方式”的开关,不要求你在 config.wechat 里填 appid/secret。
|
|
85
|
+
useUnifiedConfig: true,
|
|
86
|
+
unifiedConfigRefreshIntervalMs: 30000,
|
|
66
87
|
};
|
|
67
88
|
```
|
|
68
89
|
|
|
@@ -21,6 +21,12 @@ class ComponentService extends Service {
|
|
|
21
21
|
return Boolean(this.wechatConfig.authUrlReturnObject);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
get mockMode() {
|
|
25
|
+
const v = this.wechatConfig.mockMode;
|
|
26
|
+
if (typeof v === 'string') return v === '1' || v.toLowerCase() === 'true';
|
|
27
|
+
return Boolean(v);
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
_assertComponentConfig() {
|
|
25
31
|
const { componentAppId, componentAppSecret } = this.wechatConfig;
|
|
26
32
|
if (!componentAppId || !componentAppSecret) {
|
|
@@ -53,13 +59,39 @@ class ComponentService extends Service {
|
|
|
53
59
|
const { componentAppId } = this.wechatConfig;
|
|
54
60
|
this._assertComponentConfig();
|
|
55
61
|
|
|
62
|
+
const logger = app.logger || app.coreLogger;
|
|
63
|
+
|
|
56
64
|
const primaryKey = this._getKeyComponentVerifyTicket(componentAppId);
|
|
57
65
|
const ticket = await app.redis.get(primaryKey);
|
|
58
|
-
if (ticket)
|
|
66
|
+
if (ticket) {
|
|
67
|
+
if (logger && logger.info) logger.info('[egg-wechat] component_verify_ticket hit', {
|
|
68
|
+
redisKeyStrategy: this.redisKeyStrategy,
|
|
69
|
+
componentAppId,
|
|
70
|
+
key: primaryKey,
|
|
71
|
+
ticketLength: String(ticket).length,
|
|
72
|
+
ticketPrefix: String(ticket).slice(0, 8) + '***',
|
|
73
|
+
});
|
|
74
|
+
return ticket;
|
|
75
|
+
}
|
|
59
76
|
|
|
60
77
|
// 兼容旧 key(历史项目可能写入该 key)
|
|
61
78
|
const legacyCompat = await app.redis.get('wechat:component:ticket');
|
|
62
|
-
if (legacyCompat)
|
|
79
|
+
if (legacyCompat) {
|
|
80
|
+
if (logger && logger.info) logger.info('[egg-wechat] component_verify_ticket hit (legacy compat)', {
|
|
81
|
+
redisKeyStrategy: this.redisKeyStrategy,
|
|
82
|
+
componentAppId,
|
|
83
|
+
key: 'wechat:component:ticket',
|
|
84
|
+
ticketLength: String(legacyCompat).length,
|
|
85
|
+
ticketPrefix: String(legacyCompat).slice(0, 8) + '***',
|
|
86
|
+
});
|
|
87
|
+
return legacyCompat;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (logger && logger.info) logger.info('[egg-wechat] component_verify_ticket miss', {
|
|
91
|
+
redisKeyStrategy: this.redisKeyStrategy,
|
|
92
|
+
componentAppId,
|
|
93
|
+
triedKeys: [ primaryKey, 'wechat:component:ticket' ],
|
|
94
|
+
});
|
|
63
95
|
|
|
64
96
|
throw new Error('未收到微信推送的 Component Verify Ticket,请等待微信服务器推送');
|
|
65
97
|
}
|
|
@@ -74,6 +106,8 @@ class ComponentService extends Service {
|
|
|
74
106
|
|
|
75
107
|
this._assertComponentConfig();
|
|
76
108
|
|
|
109
|
+
const logger = app.logger || app.coreLogger;
|
|
110
|
+
|
|
77
111
|
const cacheKey = this._getKeyComponentAccessToken(componentAppId);
|
|
78
112
|
let token = await app.redis.get(cacheKey);
|
|
79
113
|
|
|
@@ -89,8 +123,51 @@ class ComponentService extends Service {
|
|
|
89
123
|
return token;
|
|
90
124
|
}
|
|
91
125
|
|
|
126
|
+
// 纯本地伪联调:不请求真实微信接口,直接返回 mock token 并缓存
|
|
127
|
+
if (this.mockMode) {
|
|
128
|
+
token = `mock_component_access_token_${componentAppId}_${Date.now()}`;
|
|
129
|
+
const ttl = 7200;
|
|
130
|
+
await app.redis.set(cacheKey, token, 'EX', ttl);
|
|
131
|
+
if (this.redisKeyStrategy === 'legacy') {
|
|
132
|
+
await app.redis.set(`wechat:component_access_token:${componentAppId}`, token, 'EX', ttl);
|
|
133
|
+
} else {
|
|
134
|
+
await app.redis.set('wechat:component:access_token', token, 'EX', ttl);
|
|
135
|
+
}
|
|
136
|
+
if (logger && logger.info) {
|
|
137
|
+
logger.info('[egg-wechat] mockMode enabled: return mocked component_access_token', {
|
|
138
|
+
componentAppId,
|
|
139
|
+
redisKeyStrategy: this.redisKeyStrategy,
|
|
140
|
+
ttl,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return token;
|
|
144
|
+
}
|
|
145
|
+
|
|
92
146
|
const ticket = await this.getComponentVerifyTicket();
|
|
93
147
|
|
|
148
|
+
// 本地伪联调常用 mock ticket,但微信开放平台会直接判无效(errcode=61006)。
|
|
149
|
+
// 非 mockMode 时提前失败,提示如何拿到真实 ticket。
|
|
150
|
+
if (!this.mockMode && typeof ticket === 'string' && ticket.startsWith('mock_')) {
|
|
151
|
+
if (logger && logger.warn) {
|
|
152
|
+
logger.warn('[egg-wechat] component_verify_ticket looks like mock, abort calling wechat api', {
|
|
153
|
+
componentAppId,
|
|
154
|
+
ticketLength: ticket.length,
|
|
155
|
+
ticketPrefix: ticket.slice(0, 8) + '***',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
throw new Error(
|
|
159
|
+
'当前使用 mock component_verify_ticket,无法向微信开放平台换取 component_access_token。' +
|
|
160
|
+
'请配置开放平台“授权事件接收 URL”可被微信公网访问,并等待微信推送真实 ticket 后再试。'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (logger && logger.info) logger.info('[egg-wechat] component_access_token refreshing', {
|
|
165
|
+
redisKeyStrategy: this.redisKeyStrategy,
|
|
166
|
+
componentAppId,
|
|
167
|
+
ticketLength: String(ticket).length,
|
|
168
|
+
ticketPrefix: String(ticket).slice(0, 8) + '***',
|
|
169
|
+
});
|
|
170
|
+
|
|
94
171
|
// 请求微信接口获取 Token
|
|
95
172
|
const url = 'https://api.weixin.qq.com/cgi-bin/component/api_component_token';
|
|
96
173
|
const result = await ctx.curl(url, {
|
|
@@ -105,6 +182,11 @@ class ComponentService extends Service {
|
|
|
105
182
|
});
|
|
106
183
|
|
|
107
184
|
if (result.data.errcode) {
|
|
185
|
+
if (logger && logger.warn) logger.warn('[egg-wechat] api_component_token failed', {
|
|
186
|
+
componentAppId,
|
|
187
|
+
errcode: result.data.errcode,
|
|
188
|
+
errmsg: result.data.errmsg,
|
|
189
|
+
});
|
|
108
190
|
throw new Error(`获取 Component Access Token 失败: ${result.data.errmsg}`);
|
|
109
191
|
}
|
|
110
192
|
|
|
@@ -271,6 +353,25 @@ class ComponentService extends Service {
|
|
|
271
353
|
const { ctx, app } = this;
|
|
272
354
|
const { componentAppId } = app.config.wechat;
|
|
273
355
|
|
|
356
|
+
if (this.mockMode) {
|
|
357
|
+
const logger = app.logger || app.coreLogger;
|
|
358
|
+
const authorizationInfo = {
|
|
359
|
+
authorizer_appid: 'mock_authorizer_appid',
|
|
360
|
+
authorizer_access_token: `mock_authorizer_access_token_${Date.now()}`,
|
|
361
|
+
expires_in: 7200,
|
|
362
|
+
authorizer_refresh_token: `mock_authorizer_refresh_token_${Date.now()}`,
|
|
363
|
+
func_info: [],
|
|
364
|
+
};
|
|
365
|
+
if (logger && logger.info) {
|
|
366
|
+
logger.info('[egg-wechat] mockMode enabled: return mocked authorization_info', {
|
|
367
|
+
componentAppId,
|
|
368
|
+
authCodeMasked: authCode ? String(authCode).slice(0, 8) + '***' : '',
|
|
369
|
+
authorizerAppid: authorizationInfo.authorizer_appid,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return authorizationInfo;
|
|
373
|
+
}
|
|
374
|
+
|
|
274
375
|
const token = await this.getComponentAccessToken();
|
|
275
376
|
const url = `https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=${token}`;
|
|
276
377
|
|
|
@@ -299,6 +400,35 @@ class ComponentService extends Service {
|
|
|
299
400
|
const { ctx, app } = this;
|
|
300
401
|
const { componentAppId } = app.config.wechat;
|
|
301
402
|
|
|
403
|
+
if (this.mockMode) {
|
|
404
|
+
const logger = app.logger || app.coreLogger;
|
|
405
|
+
const data = {
|
|
406
|
+
authorizer_info: {
|
|
407
|
+
nick_name: 'Mock公众号',
|
|
408
|
+
head_img: '',
|
|
409
|
+
service_type_info: { id: 2 },
|
|
410
|
+
verify_type_info: { id: 0 },
|
|
411
|
+
user_name: 'gh_mock',
|
|
412
|
+
principal_name: 'Mock Principal',
|
|
413
|
+
alias: 'mock',
|
|
414
|
+
signature: 'mock signature',
|
|
415
|
+
business_info: {},
|
|
416
|
+
},
|
|
417
|
+
qrcode_url: '',
|
|
418
|
+
authorization_info: {
|
|
419
|
+
authorizer_appid: authorizerAppId,
|
|
420
|
+
func_info: [],
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
if (logger && logger.info) {
|
|
424
|
+
logger.info('[egg-wechat] mockMode enabled: return mocked authorizer_info', {
|
|
425
|
+
componentAppId,
|
|
426
|
+
authorizerAppId,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return data;
|
|
430
|
+
}
|
|
431
|
+
|
|
302
432
|
const token = await this.getComponentAccessToken();
|
|
303
433
|
const url = `https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?component_access_token=${token}`;
|
|
304
434
|
|
|
@@ -366,9 +496,26 @@ class ComponentService extends Service {
|
|
|
366
496
|
*/
|
|
367
497
|
async getAuthUrl(redirectUri, authType = 3) {
|
|
368
498
|
const { componentAppId } = this.wechatConfig;
|
|
369
|
-
const preAuthCode = await this.getPreAuthCode();
|
|
370
499
|
const encodedRedirectUri = encodeURIComponent(redirectUri);
|
|
371
500
|
|
|
501
|
+
// 纯本地伪联调:返回本地 mock 授权页,打开后会重定向回 redirectUri 并携带 auth_code
|
|
502
|
+
if (this.mockMode) {
|
|
503
|
+
let base = String(this.wechatConfig.publicBaseUrl || '').trim();
|
|
504
|
+
try {
|
|
505
|
+
if (!base) base = new URL(String(redirectUri)).origin;
|
|
506
|
+
} catch (e) {
|
|
507
|
+
// ignore
|
|
508
|
+
}
|
|
509
|
+
if (!base) base = 'http://localhost:7001';
|
|
510
|
+
if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
|
|
511
|
+
base = base.replace(/\/$/, '');
|
|
512
|
+
|
|
513
|
+
const url = `${base}/api/v1/wechat/component/mock_authorize?redirect_uri=${encodedRedirectUri}&auth_type=${encodeURIComponent(String(authType))}`;
|
|
514
|
+
if (this.authUrlReturnObject) return { pc: url, mobile: url };
|
|
515
|
+
return url;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const preAuthCode = await this.getPreAuthCode();
|
|
372
519
|
const url = `https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=${componentAppId}&pre_auth_code=${preAuthCode}&redirect_uri=${encodedRedirectUri}&auth_type=${authType}`;
|
|
373
520
|
if (this.authUrlReturnObject) {
|
|
374
521
|
return { pc: url, mobile: url };
|
|
@@ -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, '<')}</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;
|
package/app.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 插件初始化入口:用于在应用启动前,动态覆盖 wechat 配置。
|
|
5
|
+
*
|
|
6
|
+
* 用法:在宿主项目的 config/config.*.js 中提供:
|
|
7
|
+
*
|
|
8
|
+
* exports.wechat = {
|
|
9
|
+
* // ...原有配置
|
|
10
|
+
* // init 可同步或异步,返回要覆盖/补充的配置对象
|
|
11
|
+
* init: async (app) => ({ componentAppId: 'wx...', componentAppSecret: '...' }),
|
|
12
|
+
* };
|
|
13
|
+
*
|
|
14
|
+
* @param {import('egg').Application} app Egg Application
|
|
15
|
+
*/
|
|
16
|
+
module.exports = app => {
|
|
17
|
+
app.beforeStart(async () => {
|
|
18
|
+
const baseConfig = app.config.wechat || {};
|
|
19
|
+
|
|
20
|
+
const normalizeRuntimeCfg = (cfg) => {
|
|
21
|
+
if (!cfg || typeof cfg !== 'object') return null;
|
|
22
|
+
const next = Object.assign({}, cfg);
|
|
23
|
+
Object.keys(next).forEach(k => {
|
|
24
|
+
const v = next[k];
|
|
25
|
+
if (typeof v === 'string') next[k] = v.trim();
|
|
26
|
+
if (next[k] === undefined || next[k] === null || String(next[k]).trim() === '') {
|
|
27
|
+
delete next[k];
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return Object.keys(next).length > 0 ? next : null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const override = baseConfig.override && typeof baseConfig.override === 'object'
|
|
34
|
+
? baseConfig.override
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
const init = baseConfig.init;
|
|
38
|
+
|
|
39
|
+
let initResult = null;
|
|
40
|
+
if (typeof init === 'function') {
|
|
41
|
+
try {
|
|
42
|
+
initResult = await init(app);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
app.logger && app.logger.error && app.logger.error('[egg-wechat] wechat.init failed', err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const finalOverride = initResult && typeof initResult === 'object' ? initResult : override;
|
|
49
|
+
if (finalOverride) {
|
|
50
|
+
// 就地合并,保证所有 service 都能读到更新后的值
|
|
51
|
+
Object.assign(baseConfig, finalOverride);
|
|
52
|
+
app.config.wechat = baseConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const shouldEnableProvider = typeof baseConfig.getConfig === 'function';
|
|
56
|
+
|
|
57
|
+
const loadFromProvider = async () => {
|
|
58
|
+
if (!shouldEnableProvider) return null;
|
|
59
|
+
|
|
60
|
+
const ctx = app.createAnonymousContext();
|
|
61
|
+
try {
|
|
62
|
+
const cfg = await baseConfig.getConfig(ctx);
|
|
63
|
+
return normalizeRuntimeCfg(cfg);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
app.logger && app.logger.warn && app.logger.warn('[egg-wechat] wechat.getConfig(ctx) failed', err);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const shouldEnableUnifiedConfig = (() => {
|
|
71
|
+
if (baseConfig.useUnifiedConfig === true) return true;
|
|
72
|
+
if (String(baseConfig.configSource || '').toLowerCase() === 'unified') return true;
|
|
73
|
+
// 自动兜底:当 component 配置缺失时尝试从统一配置读取(避免宿主必须写死 secret)
|
|
74
|
+
const hasStaticComponent = Boolean(baseConfig.componentAppId) && Boolean(baseConfig.componentAppSecret);
|
|
75
|
+
return !hasStaticComponent;
|
|
76
|
+
})();
|
|
77
|
+
|
|
78
|
+
const refreshIntervalMsRaw = baseConfig.unifiedConfigRefreshIntervalMs;
|
|
79
|
+
const refreshIntervalMs = Number.isFinite(Number(refreshIntervalMsRaw))
|
|
80
|
+
? Math.max(Number(refreshIntervalMsRaw), 1000)
|
|
81
|
+
: 30 * 1000;
|
|
82
|
+
|
|
83
|
+
const loadFromUnifiedConfig = async () => {
|
|
84
|
+
// 在宿主没有 unifiedConfig service 时,静默跳过
|
|
85
|
+
const ctx = app.createAnonymousContext();
|
|
86
|
+
const unified = ctx?.service?.config?.unifiedConfig;
|
|
87
|
+
if (!unified || typeof unified.get !== 'function') return null;
|
|
88
|
+
|
|
89
|
+
// 统一配置键(以 ddn-hub 的 wechat_platform.* 为准)
|
|
90
|
+
const keys = {
|
|
91
|
+
appId: 'wechat_platform.app_id',
|
|
92
|
+
appSecret: 'wechat_platform.app_secret',
|
|
93
|
+
componentAppId: 'wechat_platform.component.app_id',
|
|
94
|
+
componentAppSecret: 'wechat_platform.component.app_secret',
|
|
95
|
+
componentToken: 'wechat_platform.component.token',
|
|
96
|
+
componentEncodingAESKey: 'wechat_platform.component.encoding_aes_key',
|
|
97
|
+
publicBaseUrl: 'wechat_platform.public_base_url',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const entries = await Promise.all(
|
|
101
|
+
Object.entries(keys).map(async ([k, key]) => {
|
|
102
|
+
try {
|
|
103
|
+
const v = await unified.get(key);
|
|
104
|
+
return [k, typeof v === 'string' ? v.trim() : v];
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return [k, undefined];
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const cfg = Object.fromEntries(entries);
|
|
112
|
+
|
|
113
|
+
return normalizeRuntimeCfg(cfg);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const applyRuntimeWechatConfig = (runtimeCfg) => {
|
|
117
|
+
if (!runtimeCfg || typeof runtimeCfg !== 'object') return;
|
|
118
|
+
|
|
119
|
+
// 仅在内存中合并:同时写到 app.wechatRuntimeConfig 与 app.config.wechat
|
|
120
|
+
// 目的:
|
|
121
|
+
// - service 读取 app.config.wechat 的代码无需大改
|
|
122
|
+
// - 运行时刷新能覆盖所有模块
|
|
123
|
+
app.wechatRuntimeConfig = Object.assign({}, app.wechatRuntimeConfig || {}, runtimeCfg);
|
|
124
|
+
app.config.wechat = Object.assign({}, app.config.wechat || {}, app.wechatRuntimeConfig);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 对宿主/测试暴露一个手动刷新入口,避免等待定时器
|
|
128
|
+
app.wechatRefreshProviderConfig = async () => {
|
|
129
|
+
if (!shouldEnableProvider) return { enabled: false };
|
|
130
|
+
const cfg = await loadFromProvider();
|
|
131
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
132
|
+
return { enabled: true, applied: Boolean(cfg) };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
app.wechatRefreshUnifiedConfig = async () => {
|
|
136
|
+
if (!shouldEnableUnifiedConfig) return { enabled: false };
|
|
137
|
+
const cfg = await loadFromUnifiedConfig();
|
|
138
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
139
|
+
return { enabled: true, applied: Boolean(cfg) };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (shouldEnableProvider) {
|
|
143
|
+
try {
|
|
144
|
+
const cfg = await loadFromProvider();
|
|
145
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
app.logger && app.logger.warn && app.logger.warn('[egg-wechat] provider initial load failed', err);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 周期刷新 provider:避免进程长期持有过期配置
|
|
151
|
+
setInterval(async () => {
|
|
152
|
+
try {
|
|
153
|
+
const cfg = await loadFromProvider();
|
|
154
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
app.logger && app.logger.warn && app.logger.warn('[egg-wechat] provider refresh failed', err);
|
|
157
|
+
}
|
|
158
|
+
}, refreshIntervalMs).unref();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (shouldEnableUnifiedConfig) {
|
|
162
|
+
try {
|
|
163
|
+
const cfg = await loadFromUnifiedConfig();
|
|
164
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
app.logger && app.logger.warn && app.logger.warn('[egg-wechat] unifiedConfig initial load failed', err);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 周期刷新:在不重启进程的情况下感知统一配置变更
|
|
170
|
+
// 若宿主不需要,可显式设置 useUnifiedConfig=false 并提供静态配置
|
|
171
|
+
setInterval(async () => {
|
|
172
|
+
try {
|
|
173
|
+
const cfg = await loadFromUnifiedConfig();
|
|
174
|
+
if (cfg) applyRuntimeWechatConfig(cfg);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
app.logger && app.logger.warn && app.logger.warn('[egg-wechat] unifiedConfig refresh failed', err);
|
|
177
|
+
}
|
|
178
|
+
}, refreshIntervalMs).unref();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 仅输出字段存在性,避免泄露敏感值
|
|
182
|
+
const finalConfig = app.config.wechat || {};
|
|
183
|
+
if (app.logger && app.logger.info) {
|
|
184
|
+
app.logger.info('[egg-wechat] wechat config initialized', {
|
|
185
|
+
providerEnabled: Boolean(shouldEnableProvider),
|
|
186
|
+
useUnifiedConfig: Boolean(shouldEnableUnifiedConfig),
|
|
187
|
+
unifiedConfigRefreshIntervalMs: refreshIntervalMs,
|
|
188
|
+
appIdPresent: Boolean(finalConfig.appId),
|
|
189
|
+
appSecretPresent: Boolean(finalConfig.appSecret),
|
|
190
|
+
componentAppIdPresent: Boolean(finalConfig.componentAppId),
|
|
191
|
+
componentAppSecretPresent: Boolean(finalConfig.componentAppSecret),
|
|
192
|
+
componentTokenPresent: Boolean(finalConfig.componentToken),
|
|
193
|
+
componentEncodingAESKeyPresent: Boolean(finalConfig.componentEncodingAESKey),
|
|
194
|
+
publicBaseUrlPresent: Boolean(finalConfig.publicBaseUrl),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
};
|
package/config/config.default.js
CHANGED
|
@@ -20,4 +20,12 @@ exports.wechat = {
|
|
|
20
20
|
componentAppSecret: '',
|
|
21
21
|
componentToken: '',
|
|
22
22
|
componentEncodingAESKey: '',
|
|
23
|
+
|
|
24
|
+
// 可选:动态注入/覆盖配置(覆盖所有模块)
|
|
25
|
+
// init: async (app) => ({ componentAppId: 'wx...', componentAppSecret: '...' }),
|
|
26
|
+
// override: { componentAppId: 'wx...', componentAppSecret: '...' },
|
|
27
|
+
|
|
28
|
+
// 可选:运行时从统一配置读取并周期刷新(需要宿主提供 ctx.service.config.unifiedConfig.get)
|
|
29
|
+
// useUnifiedConfig: true,
|
|
30
|
+
// unifiedConfigRefreshIntervalMs: 30000,
|
|
23
31
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddn/egg-wechat",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.28",
|
|
4
4
|
"description": "Eggjs plugin for wechat",
|
|
5
5
|
"types": "./index.d.ts",
|
|
6
6
|
"eggPlugin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"wxapp"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"wechat-crypto": "^0.0.2",
|
|
19
20
|
"xml-js": "^1.6.11"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"files": [
|
|
48
49
|
"app",
|
|
49
50
|
"config",
|
|
51
|
+
"app.js",
|
|
50
52
|
"index.d.ts"
|
|
51
53
|
],
|
|
52
54
|
"ci": {
|