@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/LICENSE +21 -0
- package/README.md +390 -0
- package/bin/wxpost-server.js +18 -0
- package/package.json +46 -0
- package/src/auth.js +47 -0
- package/src/config.js +100 -0
- package/src/draft-list.js +65 -0
- package/src/draft.js +151 -0
- package/src/index.js +297 -0
- package/src/logger.js +84 -0
- package/src/material.js +83 -0
- package/src/publish.js +54 -0
- package/src/token.js +93 -0
- package/src/upload-image.js +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rongyan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# @rongyan/wxpost-server
|
|
2
|
+
|
|
3
|
+
微信公众号内容发布服务,支持多账号,提供图片上传、草稿管理和发布等 HTTP 接口。
|
|
4
|
+
|
|
5
|
+
## 要求
|
|
6
|
+
|
|
7
|
+
- Node.js >= 18
|
|
8
|
+
- 微信公众号开发者账号(AppID + AppSecret)
|
|
9
|
+
- 服务器 IP 已加入微信公众平台 **IP 白名单**
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @rongyan/wxpost-server
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
或直接通过 npx 运行(无需安装):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @rongyan/wxpost-server
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
首次启动时,程序会自动在 `~/.@rongyan/env.json` 创建配置模板并退出。编辑该文件填入真实值后重启即可。
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"port": 3000,
|
|
30
|
+
"api_key": "your-api-key",
|
|
31
|
+
"upload_dir": "/Users/yourname/.@rongyan/upload_dir",
|
|
32
|
+
"defaultAccount": "wx_appid",
|
|
33
|
+
"accounts": {
|
|
34
|
+
"wx_appid": {
|
|
35
|
+
"appSecret": "your-app-secret"
|
|
36
|
+
},
|
|
37
|
+
"wx_another_appid": {
|
|
38
|
+
"appSecret": "another-app-secret"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| 字段 | 说明 |
|
|
45
|
+
|------|------|
|
|
46
|
+
| `port` | 配置文件中的默认监听端口,默认 `3000`;可被 `--port` 参数或 `PORT` 环境变量覆盖 |
|
|
47
|
+
| `api_key` | HTTP 接口鉴权密钥 |
|
|
48
|
+
| `upload_dir` | 图片上传临时目录,默认 `~/.@rongyan/upload_dir/` |
|
|
49
|
+
| `defaultAccount` | 不传 `appid` 参数时使用的默认账号(填 AppID) |
|
|
50
|
+
| `accounts` | 多账号配置,以 AppID 为 key,每个账号填写 `appSecret` |
|
|
51
|
+
|
|
52
|
+
## 启动
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# 使用配置文件中的端口
|
|
56
|
+
wxpost-server
|
|
57
|
+
|
|
58
|
+
# 指定端口(优先级最高)
|
|
59
|
+
wxpost-server --port 8080
|
|
60
|
+
|
|
61
|
+
# 通过环境变量指定端口
|
|
62
|
+
PORT=8080 wxpost-server
|
|
63
|
+
|
|
64
|
+
# 开发模式(文件变更自动重启)
|
|
65
|
+
npm run dev
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
端口优先级:`--port 参数` > `PORT 环境变量` > `配置文件 port` > `默认 3000`
|
|
69
|
+
|
|
70
|
+
## IP 白名单
|
|
71
|
+
|
|
72
|
+
微信接口要求服务器 IP 在白名单内。配置路径:
|
|
73
|
+
|
|
74
|
+
**微信公众平台** → 设置与开发 → 基本配置 → IP 白名单
|
|
75
|
+
|
|
76
|
+
## token 缓存
|
|
77
|
+
|
|
78
|
+
access_token 自动缓存至 `~/.@rongyan/tokens.json`,有效期 7200 秒,提前 5 分钟自动刷新,多进程共享同一缓存文件。
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
所有接口均需鉴权,支持以下两种方式(二选一):
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Authorization: Bearer <api_key>
|
|
88
|
+
X-Api-Key: <api_key>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
所有请求和响应均为 JSON(上传图片除外)。响应体统一包含 `ok` 字段,`true` 表示成功,`false` 表示失败(同时包含 `error` 字段)。
|
|
92
|
+
|
|
93
|
+
多账号场景可通过 `?appid=` 查询参数指定账号,不传则使用 `defaultAccount`。
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### POST /upload-image
|
|
98
|
+
|
|
99
|
+
上传图片到微信服务器,返回微信 CDN 图片地址。
|
|
100
|
+
|
|
101
|
+
**请求格式:** `multipart/form-data`
|
|
102
|
+
|
|
103
|
+
| 字段 | 类型 | 说明 |
|
|
104
|
+
|------|------|------|
|
|
105
|
+
| `media` | file | 图片文件,仅支持 JPG/PNG(按文件内容识别),大小须严格小于 1MB |
|
|
106
|
+
|
|
107
|
+
**查询参数:**
|
|
108
|
+
|
|
109
|
+
| 参数 | 说明 |
|
|
110
|
+
|------|------|
|
|
111
|
+
| `appid` | 可选,指定使用的公众号 AppID,不传使用默认账号 |
|
|
112
|
+
|
|
113
|
+
**请求示例:**
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
curl -X POST http://localhost:3000/upload-image \
|
|
117
|
+
-H "Authorization: Bearer your-api-key" \
|
|
118
|
+
-F "media=@/path/to/image.jpg"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**成功响应:**
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"ok": true,
|
|
126
|
+
"url": "https://mmbiz.qpic.cn/..."
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**错误响应:**
|
|
131
|
+
|
|
132
|
+
| 状态码 | 说明 |
|
|
133
|
+
|--------|------|
|
|
134
|
+
| 401 | 鉴权失败 |
|
|
135
|
+
| 400 | 格式不支持(非 JPG/PNG) |
|
|
136
|
+
| 413 | 图片超过 1MB,请压缩后重试 |
|
|
137
|
+
| 502 | 微信接口调用失败 |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### POST /upload-material
|
|
142
|
+
|
|
143
|
+
上传图片为**永久素材**,返回 `media_id` 和微信 CDN 地址。永久素材可用于草稿封面图(`thumb_media_id`)等需要持久化的场景。
|
|
144
|
+
|
|
145
|
+
**请求格式:** `multipart/form-data`
|
|
146
|
+
|
|
147
|
+
| 字段 | 类型 | 说明 |
|
|
148
|
+
|------|------|------|
|
|
149
|
+
| `media` | file | 图片文件,支持 JPG/PNG/GIF/BMP(按文件内容识别),大小须严格小于 10MB |
|
|
150
|
+
|
|
151
|
+
**查询参数:**
|
|
152
|
+
|
|
153
|
+
| 参数 | 说明 |
|
|
154
|
+
|------|------|
|
|
155
|
+
| `appid` | 可选,指定使用的公众号 AppID,不传使用默认账号 |
|
|
156
|
+
|
|
157
|
+
**请求示例:**
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
curl -X POST http://localhost:3000/upload-material \
|
|
161
|
+
-H "Authorization: Bearer your-api-key" \
|
|
162
|
+
-F "media=@/path/to/cover.jpg"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**成功响应:**
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"ok": true,
|
|
170
|
+
"media_id": "xxx",
|
|
171
|
+
"url": "https://mmbiz.qpic.cn/..."
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
> `url` 仅在微信/腾讯域名下有效,不可在外部网页直接引用。
|
|
176
|
+
|
|
177
|
+
**错误响应:**
|
|
178
|
+
|
|
179
|
+
| 状态码 | 说明 |
|
|
180
|
+
|--------|------|
|
|
181
|
+
| 401 | 鉴权失败 |
|
|
182
|
+
| 400 | 格式不支持(非 JPG/PNG/GIF/BMP) |
|
|
183
|
+
| 413 | 图片超过 10MB,请压缩后重试 |
|
|
184
|
+
| 502 | 微信接口调用失败 |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
### POST /draft/add
|
|
189
|
+
|
|
190
|
+
新增草稿,返回草稿的 `media_id`。
|
|
191
|
+
|
|
192
|
+
**请求格式:** `application/json`
|
|
193
|
+
|
|
194
|
+
**请求体:**
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"articles": [
|
|
199
|
+
{
|
|
200
|
+
"article_type": "news",
|
|
201
|
+
"title": "文章标题",
|
|
202
|
+
"author": "作者",
|
|
203
|
+
"digest": "摘要",
|
|
204
|
+
"content": "<p>正文 HTML</p>",
|
|
205
|
+
"content_source_url": "https://example.com",
|
|
206
|
+
"thumb_media_id": "封面图素材ID",
|
|
207
|
+
"need_open_comment": 0,
|
|
208
|
+
"only_fans_can_comment": 0,
|
|
209
|
+
"pic_crop_235_1": "0,0,1,0.4985",
|
|
210
|
+
"pic_crop_1_1": "0,0,1,1",
|
|
211
|
+
"cover_info": {},
|
|
212
|
+
"product_info": {}
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**articles 字段说明:**
|
|
219
|
+
|
|
220
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
221
|
+
|------|------|------|------|
|
|
222
|
+
| `article_type` | string | 否 | `"news"`(图文,默认)或 `"newspic"`(图片) |
|
|
223
|
+
| `title` | string | 是 | 超过 64 字自动截断 |
|
|
224
|
+
| `author` | string | 否 | 最多 16 字 |
|
|
225
|
+
| `digest` | string | 否 | 摘要,最多 128 字;不填则从正文自动截取 |
|
|
226
|
+
| `content` | string | 是 | 正文 HTML,最多 2 万字 |
|
|
227
|
+
| `content_source_url` | string | 否 | 点击"阅读原文"跳转的 URL |
|
|
228
|
+
| `thumb_media_id` | string | news 类型必填 | 封面图的永久素材 MediaID |
|
|
229
|
+
| `image_info` | object | newspic 类型必填 | 图片列表,`{ "list": [...] }`,最多 20 张 |
|
|
230
|
+
| `need_open_comment` | number | 否 | 是否开启评论:`0` 关闭,`1` 开启 |
|
|
231
|
+
| `only_fans_can_comment` | number | 否 | 仅粉丝可评论:`0` 所有人,`1` 仅粉丝 |
|
|
232
|
+
| `pic_crop_235_1` | string | 否 | 封面裁剪坐标(2.35:1) |
|
|
233
|
+
| `pic_crop_1_1` | string | 否 | 封面裁剪坐标(1:1) |
|
|
234
|
+
| `cover_info` | object | 否 | 封面裁剪信息对象 |
|
|
235
|
+
| `product_info` | object | 否 | 商品信息对象 |
|
|
236
|
+
|
|
237
|
+
**成功响应:**
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"ok": true,
|
|
242
|
+
"media_id": "草稿的media_id"
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**错误响应:**
|
|
247
|
+
|
|
248
|
+
| 状态码 | 说明 |
|
|
249
|
+
|--------|------|
|
|
250
|
+
| 401 | 鉴权失败 |
|
|
251
|
+
| 400 | 参数校验失败或微信接口返回错误 |
|
|
252
|
+
| 413 | 请求体超过 2MB |
|
|
253
|
+
| 502 | 获取 access_token 失败 |
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
### POST /draft/list
|
|
258
|
+
|
|
259
|
+
获取草稿列表(分页)。
|
|
260
|
+
|
|
261
|
+
**请求格式:** `application/json`(请求体可省略,使用默认值)
|
|
262
|
+
|
|
263
|
+
**请求体:**
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"offset": 0,
|
|
268
|
+
"count": 20,
|
|
269
|
+
"no_content": 0
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
274
|
+
|------|------|--------|------|
|
|
275
|
+
| `offset` | number | `0` | 起始偏移,从 0 开始 |
|
|
276
|
+
| `count` | number | `20` | 返回数量,1–20 |
|
|
277
|
+
| `no_content` | number | `0` | `1` 表示不返回正文内容,`0` 返回 |
|
|
278
|
+
|
|
279
|
+
**成功响应:**
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"ok": true,
|
|
284
|
+
"total_count": 100,
|
|
285
|
+
"item_count": 20,
|
|
286
|
+
"item": [
|
|
287
|
+
{
|
|
288
|
+
"media_id": "草稿ID",
|
|
289
|
+
"update_time": 1234567890,
|
|
290
|
+
"content": {
|
|
291
|
+
"news_item": [
|
|
292
|
+
{
|
|
293
|
+
"title": "文章标题",
|
|
294
|
+
"author": "作者",
|
|
295
|
+
"digest": "摘要",
|
|
296
|
+
"content": "<p>正文</p>",
|
|
297
|
+
"thumb_media_id": "封面素材ID",
|
|
298
|
+
"url": "草稿预览链接",
|
|
299
|
+
"article_type": "news"
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**错误响应:**
|
|
309
|
+
|
|
310
|
+
| 状态码 | 说明 |
|
|
311
|
+
|--------|------|
|
|
312
|
+
| 401 | 鉴权失败 |
|
|
313
|
+
| 400 | 参数错误或微信接口返回错误 |
|
|
314
|
+
| 502 | 获取 access_token 失败 |
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
### POST /draft/publish
|
|
319
|
+
|
|
320
|
+
发布草稿为正式图文,返回发布任务 ID。
|
|
321
|
+
|
|
322
|
+
**请求格式:** `application/json`
|
|
323
|
+
|
|
324
|
+
**请求体:**
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"media_id": "草稿的media_id"
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
333
|
+
|------|------|------|------|
|
|
334
|
+
| `media_id` | string | 是 | 要发布的草稿 ID |
|
|
335
|
+
|
|
336
|
+
**成功响应:**
|
|
337
|
+
|
|
338
|
+
```json
|
|
339
|
+
{
|
|
340
|
+
"ok": true,
|
|
341
|
+
"publish_id": "发布任务ID",
|
|
342
|
+
"msg_data_id": "消息数据ID"
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**错误响应:**
|
|
347
|
+
|
|
348
|
+
| 状态码 | 说明 |
|
|
349
|
+
|--------|------|
|
|
350
|
+
| 401 | 鉴权失败 |
|
|
351
|
+
| 400 | 缺少 `media_id`、草稿未通过检查或其他微信错误(含错误码) |
|
|
352
|
+
| 502 | 获取 access_token 失败 |
|
|
353
|
+
|
|
354
|
+
**微信错误码:**
|
|
355
|
+
|
|
356
|
+
| 错误码 | 说明 |
|
|
357
|
+
|--------|------|
|
|
358
|
+
| 48001 | 接口未授权,确认公众号已开通该能力 |
|
|
359
|
+
| 53503 | 草稿未通过发布检查,检查草稿内容 |
|
|
360
|
+
| 53504 | 需前往公众平台官网操作 |
|
|
361
|
+
| 53505 | 请手动保存成功后再发布 |
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 典型工作流
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
# 1. 上传文章封面图,获取永久素材 media_id
|
|
369
|
+
curl -X POST http://localhost:3000/upload-material \
|
|
370
|
+
-H "Authorization: Bearer your-api-key" \
|
|
371
|
+
-F "media=@cover.jpg"
|
|
372
|
+
|
|
373
|
+
# 2. 新增草稿
|
|
374
|
+
curl -X POST http://localhost:3000/draft/add \
|
|
375
|
+
-H "Authorization: Bearer your-api-key" \
|
|
376
|
+
-H "Content-Type: application/json" \
|
|
377
|
+
-d '{
|
|
378
|
+
"articles": [{
|
|
379
|
+
"title": "文章标题",
|
|
380
|
+
"content": "<p>正文内容</p>",
|
|
381
|
+
"thumb_media_id": "上一步返回的media_id"
|
|
382
|
+
}]
|
|
383
|
+
}'
|
|
384
|
+
|
|
385
|
+
# 3. 发布草稿
|
|
386
|
+
curl -X POST http://localhost:3000/draft/publish \
|
|
387
|
+
-H "Authorization: Bearer your-api-key" \
|
|
388
|
+
-H "Content-Type: application/json" \
|
|
389
|
+
-d '{ "media_id": "上一步返回的media_id" }'
|
|
390
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { startServer } = require('../src/index.js');
|
|
6
|
+
|
|
7
|
+
// 解析 --port <n> 或 --port=<n>
|
|
8
|
+
function parsePort(args) {
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
if (args[i] === '--port' && args[i + 1]) return parseInt(args[i + 1], 10);
|
|
11
|
+
const m = args[i].match(/^--port=(\d+)$/);
|
|
12
|
+
if (m) return parseInt(m[1], 10);
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cliPort = parsePort(process.argv.slice(2));
|
|
18
|
+
startServer({ port: cliPort });
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rongyan/wxpost-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "微信公众号内容发布服务,支持多账号,提供图片上传、草稿管理和发布等 HTTP 接口",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wxpost-server": "./bin/wxpost-server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node bin/wxpost-server.js",
|
|
16
|
+
"dev": "node --watch bin/wxpost-server.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"wechat",
|
|
20
|
+
"weixin",
|
|
21
|
+
"wxpost",
|
|
22
|
+
"mp",
|
|
23
|
+
"server",
|
|
24
|
+
"draft",
|
|
25
|
+
"publish"
|
|
26
|
+
],
|
|
27
|
+
"author": "rongyan",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/rongyan6/wepost-server.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/rongyan6/wepost-server#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/rongyan6/wepost-server/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"busboy": "^1.6.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 校验请求中的 API Key。
|
|
8
|
+
* 支持两种方式:
|
|
9
|
+
* Authorization: Bearer <api_key>
|
|
10
|
+
* X-Api-Key: <api_key>
|
|
11
|
+
*
|
|
12
|
+
* 使用 timingSafeEqual 防止时序攻击。
|
|
13
|
+
* @returns {string|null} 错误描述,null 表示通过
|
|
14
|
+
*/
|
|
15
|
+
function checkApiKey(req) {
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const expected = config.api_key;
|
|
18
|
+
|
|
19
|
+
if (!expected || expected === '__YOUR_API_KEY__') {
|
|
20
|
+
return 'api_key 未配置,请在 env.json 中设置 api_key';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const authHeader = req.headers['authorization'];
|
|
24
|
+
const xApiKey = req.headers['x-api-key'];
|
|
25
|
+
|
|
26
|
+
let provided = null;
|
|
27
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
28
|
+
provided = authHeader.slice(7).trim();
|
|
29
|
+
} else if (xApiKey) {
|
|
30
|
+
provided = xApiKey.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!provided) {
|
|
34
|
+
return '缺少 API Key(Authorization: Bearer <key> 或 X-Api-Key: <key>)';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const bufExpected = Buffer.from(expected);
|
|
38
|
+
const bufProvided = Buffer.from(provided);
|
|
39
|
+
if (bufProvided.length !== bufExpected.length ||
|
|
40
|
+
!crypto.timingSafeEqual(bufProvided, bufExpected)) {
|
|
41
|
+
return 'API Key 无效';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { checkApiKey };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.@rongyan');
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'env.json');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_UPLOAD_DIR = path.join(os.homedir(), '.@rongyan', 'upload_dir');
|
|
11
|
+
const DEFAULT_LOG_DIR = path.join(os.homedir(), '.@rongyan', 'log');
|
|
12
|
+
|
|
13
|
+
const TEMPLATE = {
|
|
14
|
+
port: 3000,
|
|
15
|
+
api_key: '__YOUR_API_KEY__',
|
|
16
|
+
upload_dir: DEFAULT_UPLOAD_DIR,
|
|
17
|
+
log_dir: DEFAULT_LOG_DIR,
|
|
18
|
+
defaultAccount: 'wx__YOUR_APP_ID__',
|
|
19
|
+
accounts: {
|
|
20
|
+
'wx__YOUR_APP_ID__': {
|
|
21
|
+
appSecret: '__YOUR_APP_SECRET__',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function ensureConfig() {
|
|
27
|
+
if (fs.existsSync(CONFIG_PATH)) return false;
|
|
28
|
+
|
|
29
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + '\n', { mode: 0o600, encoding: 'utf-8' });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let _cache = null;
|
|
35
|
+
|
|
36
|
+
function loadConfig() {
|
|
37
|
+
if (_cache) return _cache;
|
|
38
|
+
|
|
39
|
+
const created = ensureConfig();
|
|
40
|
+
if (created) {
|
|
41
|
+
console.error([
|
|
42
|
+
'',
|
|
43
|
+
' 配置文件已创建:' + CONFIG_PATH,
|
|
44
|
+
' 请编辑该文件,填入你的公众号 AppID 和 AppSecret,然后重新启动。',
|
|
45
|
+
'',
|
|
46
|
+
' 格式说明:',
|
|
47
|
+
' port — 监听端口(也可用 --port 参数或 PORT 环境变量覆盖)',
|
|
48
|
+
' api_key — HTTP 接口鉴权密钥',
|
|
49
|
+
' upload_dir — 本地图片存储目录(默认 ~/.@rongyan/upload_dir/)',
|
|
50
|
+
' log_dir — 日志目录(默认 ~/.@rongyan/log/)',
|
|
51
|
+
' defaultAccount — 默认使用的 AppID',
|
|
52
|
+
' accounts — 以 AppID 为 key,每个账号填写对应的 appSecret',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n'));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
59
|
+
try {
|
|
60
|
+
_cache = JSON.parse(raw);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
throw new Error(`配置文件 JSON 格式错误 (${CONFIG_PATH}): ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return _cache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getAccount(appId) {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const id = appId || config.defaultAccount;
|
|
71
|
+
if (!id) {
|
|
72
|
+
throw new Error('未指定 appId,且配置中没有 defaultAccount。');
|
|
73
|
+
}
|
|
74
|
+
const account = config.accounts?.[id];
|
|
75
|
+
if (!account) {
|
|
76
|
+
const available = Object.keys(config.accounts || {}).join(', ') || '(空)';
|
|
77
|
+
throw new Error(`账号 "${id}" 不存在。可用账号:${available}`);
|
|
78
|
+
}
|
|
79
|
+
if (!account.appSecret) {
|
|
80
|
+
throw new Error(`账号 "${id}" 缺少 appSecret。`);
|
|
81
|
+
}
|
|
82
|
+
return { appId: id, appSecret: account.appSecret };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function listAccounts() {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
return Object.keys(config.accounts || {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getUploadDir() {
|
|
91
|
+
const config = loadConfig();
|
|
92
|
+
return config.upload_dir || DEFAULT_UPLOAD_DIR;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getLogDir() {
|
|
96
|
+
const config = loadConfig();
|
|
97
|
+
return config.log_dir || DEFAULT_LOG_DIR;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { loadConfig, getAccount, listAccounts, getUploadDir, getLogDir, CONFIG_PATH };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
const DRAFT_BATCHGET_URL = 'https://api.weixin.qq.com/cgi-bin/draft/batchget';
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 获取草稿列表。
|
|
10
|
+
* @param {string} accessToken
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {number} opts.offset 起始位置,从 0 开始
|
|
13
|
+
* @param {number} opts.count 返回数量,1-20
|
|
14
|
+
* @param {number} [opts.no_content=0] 1=不返回 content 字段,0=返回
|
|
15
|
+
*/
|
|
16
|
+
function getDraftList(accessToken, { offset, count, no_content = 0 }) {
|
|
17
|
+
if (typeof offset !== 'number' || offset < 0 || !Number.isInteger(offset)) {
|
|
18
|
+
return Promise.reject(new Error('offset 须为非负整数'));
|
|
19
|
+
}
|
|
20
|
+
if (typeof count !== 'number' || count < 1 || count > 20 || !Number.isInteger(count)) {
|
|
21
|
+
return Promise.reject(new Error('count 须为 1-20 的整数'));
|
|
22
|
+
}
|
|
23
|
+
if (no_content !== 0 && no_content !== 1) {
|
|
24
|
+
return Promise.reject(new Error('no_content 须为 0 或 1'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const body = JSON.stringify({ offset, count, no_content });
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const req = https.request(
|
|
31
|
+
`${DRAFT_BATCHGET_URL}?access_token=${accessToken}`,
|
|
32
|
+
{
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
36
|
+
'Content-Length': Buffer.byteLength(body),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
(res) => {
|
|
40
|
+
let raw = '';
|
|
41
|
+
res.on('data', (chunk) => { raw += chunk; });
|
|
42
|
+
res.on('end', () => {
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return reject(new Error(`微信接口响应解析失败: ${raw}`));
|
|
48
|
+
}
|
|
49
|
+
if (result.errcode) {
|
|
50
|
+
return reject(new Error(`获取草稿列表失败 [${result.errcode}]: ${result.errmsg}`));
|
|
51
|
+
}
|
|
52
|
+
// 过滤微信内部字段,只返回业务数据
|
|
53
|
+
const { errcode, errmsg, ...data } = result;
|
|
54
|
+
resolve(data);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('获取草稿列表请求超时')));
|
|
59
|
+
req.on('error', reject);
|
|
60
|
+
req.write(body);
|
|
61
|
+
req.end();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { getDraftList };
|