@rongyan/wxpost-server 0.1.0

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/src/token.js ADDED
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const https = require('https');
7
+
8
+ const STABLE_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
9
+ const CACHE_PATH = path.join(os.homedir(), '.@rongyan', 'tokens.json');
10
+ const CACHE_TMP = CACHE_PATH + '.tmp';
11
+
12
+ // 提前 5 分钟视为过期,避免边界问题
13
+ const EXPIRE_BUFFER_MS = 5 * 60 * 1000;
14
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
15
+
16
+ function readCache() {
17
+ try {
18
+ const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function writeCache(cache) {
26
+ // 原子写:先写临时文件再 rename,防止 crash 导致缓存损坏;权限 0600 保护 token
27
+ fs.writeFileSync(CACHE_TMP, JSON.stringify(cache, null, 2) + '\n', { mode: 0o600, encoding: 'utf-8' });
28
+ fs.renameSync(CACHE_TMP, CACHE_PATH);
29
+ }
30
+
31
+ function post(url, body) {
32
+ return new Promise((resolve, reject) => {
33
+ const data = JSON.stringify(body);
34
+ const req = https.request(url, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'Content-Length': Buffer.byteLength(data),
39
+ },
40
+ }, (res) => {
41
+ let raw = '';
42
+ res.on('data', (chunk) => { raw += chunk; });
43
+ res.on('end', () => {
44
+ try {
45
+ resolve(JSON.parse(raw));
46
+ } catch (e) {
47
+ reject(new Error(`响应解析失败: ${raw}`));
48
+ }
49
+ });
50
+ });
51
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('获取 access_token 超时')));
52
+ req.on('error', reject);
53
+ req.write(data);
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ /**
59
+ * 获取稳定版 access_token,自动读缓存、到期后刷新。
60
+ * @param {string} appId
61
+ * @param {string} appSecret
62
+ * @param {boolean} [forceRefresh=false]
63
+ */
64
+ async function getStableToken(appId, appSecret, forceRefresh = false) {
65
+ const cache = readCache();
66
+ const cached = cache[appId];
67
+ const now = Date.now();
68
+
69
+ if (!forceRefresh && cached && cached.expiresAt - EXPIRE_BUFFER_MS > now) {
70
+ return cached.accessToken;
71
+ }
72
+
73
+ const result = await post(STABLE_TOKEN_URL, {
74
+ grant_type: 'client_credential',
75
+ appid: appId,
76
+ secret: appSecret,
77
+ force_refresh: forceRefresh,
78
+ });
79
+
80
+ if (result.errcode) {
81
+ throw new Error(`获取 access_token 失败 [${result.errcode}]: ${result.errmsg}`);
82
+ }
83
+
84
+ cache[appId] = {
85
+ accessToken: result.access_token,
86
+ expiresAt: now + result.expires_in * 1000,
87
+ };
88
+ writeCache(cache);
89
+
90
+ return result.access_token;
91
+ }
92
+
93
+ module.exports = { getStableToken };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
6
+ const MAX_SIZE = 1 * 1024 * 1024; // 1MB,微信要求严格小于
7
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
8
+
9
+ function detectFormat(buffer) {
10
+ if (buffer.length < 4) return null;
11
+ if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return 'jpeg';
12
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'png';
13
+ return null;
14
+ }
15
+
16
+ function clientError(msg) {
17
+ const err = new Error(msg);
18
+ err.statusCode = 400;
19
+ return err;
20
+ }
21
+
22
+ function uploadImageToWx({ accessToken, buffer }) {
23
+ const format = detectFormat(buffer);
24
+ if (!format) {
25
+ return Promise.reject(clientError('不支持的图片格式,内容图仅支持 JPG/PNG'));
26
+ }
27
+ if (buffer.length >= MAX_SIZE) {
28
+ return Promise.reject(clientError(`图片大小 ${(buffer.length / 1024).toFixed(1)}KB 须小于 1MB`));
29
+ }
30
+
31
+ const mimetype = format === 'png' ? 'image/png' : 'image/jpeg';
32
+ const ext = format === 'png' ? '.png' : '.jpg';
33
+ const boundary = `----WxUploadBoundary${Date.now()}`;
34
+
35
+ const head = Buffer.from(
36
+ `--${boundary}\r\n` +
37
+ `Content-Disposition: form-data; name="media"; filename="upload${ext}"\r\n` +
38
+ `Content-Type: ${mimetype}\r\n\r\n`
39
+ );
40
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
41
+ const body = Buffer.concat([head, buffer, tail]);
42
+
43
+ return new Promise((resolve, reject) => {
44
+ const req = https.request(
45
+ `${UPLOAD_URL}?access_token=${accessToken}`,
46
+ {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
50
+ 'Content-Length': body.length,
51
+ },
52
+ },
53
+ (res) => {
54
+ let raw = '';
55
+ res.on('data', (chunk) => { raw += chunk; });
56
+ res.on('end', () => {
57
+ let result;
58
+ try {
59
+ result = JSON.parse(raw);
60
+ } catch {
61
+ return reject(new Error(`微信接口响应解析失败: ${raw}`));
62
+ }
63
+ if (result.errcode) return reject(new Error(`微信上传图片失败 [${result.errcode}]: ${result.errmsg}`));
64
+ resolve(result.url);
65
+ });
66
+ }
67
+ );
68
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('上传内容图超时')));
69
+ req.on('error', reject);
70
+ req.write(body);
71
+ req.end();
72
+ });
73
+ }
74
+
75
+ module.exports = { uploadImageToWx, MAX_SIZE };