ForcomeBot 2.2.4__py3-none-any.whl

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.
src/api/routes.py ADDED
@@ -0,0 +1,775 @@
1
+ """API路由模块 - RESTful API接口
2
+
3
+ 提供以下API:
4
+ - 配置管理:GET/POST /api/config, POST /api/config/validate
5
+ - 状态监控:GET /api/status, /api/status/langbot, /api/status/memory, /api/status/queue
6
+ - 日志:GET /api/logs
7
+ - 任务:GET /api/tasks, POST /api/tasks/{id}/run, GET /api/tasks/history
8
+ - 缓存:POST /api/cache/clear
9
+ - 列表:GET /api/chatrooms, /api/friends, /api/group_members/{id}, /api/robot_info
10
+ - 群发:POST /api/broadcast/*
11
+ """
12
+ import logging
13
+ from typing import Optional, Dict, Any, List, TYPE_CHECKING
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+ from pydantic import BaseModel
17
+
18
+ if TYPE_CHECKING:
19
+ from ..core.config_manager import ConfigManager
20
+ from ..core.state_store import StateStore
21
+ from ..core.log_collector import LogCollector
22
+ from ..core.message_queue import MessageQueue
23
+ from ..clients.qianxun import QianXunClient
24
+ from ..clients.langbot import LangBotClient
25
+ from ..handlers.scheduler import TaskScheduler
26
+
27
+ from ..core.message_queue import MessagePriority
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # 创建路由器
32
+ router = APIRouter(prefix="/api", tags=["admin"])
33
+
34
+ # 全局引用(由main.py设置)
35
+ _config_manager: Optional["ConfigManager"] = None
36
+ _state_store: Optional["StateStore"] = None
37
+ _log_collector: Optional["LogCollector"] = None
38
+ _message_queue: Optional["MessageQueue"] = None
39
+ _qianxun_client: Optional["QianXunClient"] = None
40
+ _langbot_client: Optional["LangBotClient"] = None
41
+ _scheduler: Optional["TaskScheduler"] = None
42
+
43
+
44
+ def set_dependencies(
45
+ config_manager: "ConfigManager",
46
+ state_store: "StateStore",
47
+ log_collector: "LogCollector",
48
+ qianxun_client: "QianXunClient",
49
+ langbot_client: "LangBotClient",
50
+ scheduler: "TaskScheduler",
51
+ message_queue: "MessageQueue" = None
52
+ ):
53
+ """设置API依赖的服务实例
54
+
55
+ Args:
56
+ config_manager: 配置管理器
57
+ state_store: 状态存储器
58
+ log_collector: 日志收集器
59
+ qianxun_client: 千寻客户端
60
+ langbot_client: LangBot客户端
61
+ scheduler: 任务调度器
62
+ message_queue: 消息队列
63
+ """
64
+ global _config_manager, _state_store, _log_collector
65
+ global _qianxun_client, _langbot_client, _scheduler, _message_queue
66
+
67
+ _config_manager = config_manager
68
+ _state_store = state_store
69
+ _log_collector = log_collector
70
+ _qianxun_client = qianxun_client
71
+ _langbot_client = langbot_client
72
+ _scheduler = scheduler
73
+ _message_queue = message_queue
74
+
75
+ logger.info("API路由依赖已设置")
76
+
77
+
78
+ # ============ 请求/响应模型 ============
79
+
80
+ class ConfigUpdate(BaseModel):
81
+ """配置更新请求"""
82
+ config: Dict[str, Any]
83
+
84
+
85
+ class CacheClearRequest(BaseModel):
86
+ """缓存清理请求"""
87
+ cache_type: str # all, image, msg_ids, group_mapping, message_mapping
88
+
89
+
90
+ # ============ 配置管理API ============
91
+
92
+ @router.get("/config")
93
+ async def get_config():
94
+ """获取当前配置"""
95
+ if not _config_manager:
96
+ raise HTTPException(status_code=503, detail="服务未初始化")
97
+
98
+ return _config_manager.config
99
+
100
+
101
+ @router.post("/config")
102
+ async def update_config(data: ConfigUpdate):
103
+ """更新配置"""
104
+ if not _config_manager:
105
+ raise HTTPException(status_code=503, detail="服务未初始化")
106
+
107
+ success, message = await _config_manager.save(data.config)
108
+
109
+ if not success:
110
+ raise HTTPException(status_code=400, detail=message)
111
+
112
+ # 重新加载调度器任务
113
+ if _scheduler:
114
+ _scheduler.reload_tasks()
115
+
116
+ return {"status": "ok", "message": message}
117
+
118
+
119
+ @router.post("/config/validate")
120
+ async def validate_config(data: ConfigUpdate):
121
+ """验证配置格式"""
122
+ if not _config_manager:
123
+ raise HTTPException(status_code=503, detail="服务未初始化")
124
+
125
+ valid, error = _config_manager.validate(data.config)
126
+
127
+ return {
128
+ "valid": valid,
129
+ "error": error if not valid else None
130
+ }
131
+
132
+
133
+ # ============ 状态监控API ============
134
+
135
+ @router.get("/status")
136
+ async def get_status():
137
+ """获取系统状态概览"""
138
+ robot_wxid = _config_manager.robot_wxid if _config_manager else None
139
+
140
+ # 获取调度任务信息
141
+ scheduled_jobs = []
142
+ if _scheduler and _scheduler.scheduler:
143
+ for job in _scheduler.scheduler.get_jobs():
144
+ next_run = None
145
+ try:
146
+ if hasattr(job, 'next_run_time') and job.next_run_time:
147
+ next_run = job.next_run_time.isoformat()
148
+ except:
149
+ pass
150
+ scheduled_jobs.append({
151
+ "id": job.id,
152
+ "next_run": next_run
153
+ })
154
+
155
+ # 获取内存使用情况
156
+ memory_usage = _state_store.get_stats() if _state_store else {}
157
+
158
+ return {
159
+ "robot_wxid": robot_wxid,
160
+ "langbot_connected": _langbot_client.is_connected if _langbot_client else False,
161
+ "langbot_reconnecting": _langbot_client.is_reconnecting if _langbot_client else False,
162
+ "scheduled_jobs": scheduled_jobs,
163
+ "memory_usage": memory_usage
164
+ }
165
+
166
+
167
+ @router.get("/status/langbot")
168
+ async def get_langbot_status():
169
+ """获取LangBot连接状态"""
170
+ if not _langbot_client:
171
+ return {
172
+ "connected": False,
173
+ "reconnecting": False,
174
+ "error": "LangBot客户端未初始化"
175
+ }
176
+
177
+ return {
178
+ "connected": _langbot_client.is_connected,
179
+ "reconnecting": _langbot_client.is_reconnecting,
180
+ "reconnect_delay": _langbot_client.get_reconnect_delay(),
181
+ "host": _langbot_client.host,
182
+ "port": _langbot_client.port
183
+ }
184
+
185
+
186
+ @router.get("/status/memory")
187
+ async def get_memory_status():
188
+ """获取内存缓存状态"""
189
+ if not _state_store:
190
+ return {"error": "状态存储器未初始化"}
191
+
192
+ stats = _state_store.get_stats()
193
+
194
+ return {
195
+ "stats": stats,
196
+ "limits": {
197
+ "max_msg_ids": _state_store.max_msg_ids,
198
+ "max_image_cache": _state_store.max_image_cache,
199
+ "max_group_mapping": _state_store.max_group_mapping,
200
+ "max_message_mapping": _state_store.max_message_mapping
201
+ },
202
+ "ttl": {
203
+ "msg_id_ttl": _state_store.msg_id_ttl,
204
+ "image_cache_ttl": _state_store.image_cache_ttl
205
+ }
206
+ }
207
+
208
+
209
+ @router.get("/status/queue")
210
+ async def get_queue_status():
211
+ """获取消息队列状态"""
212
+ if not _message_queue:
213
+ return {"error": "消息队列未初始化"}
214
+
215
+ return _message_queue.get_status()
216
+
217
+
218
+ # ============ 日志API ============
219
+
220
+ @router.get("/logs")
221
+ async def get_logs(type: Optional[str] = None, limit: int = 100):
222
+ """获取消息处理日志
223
+
224
+ Args:
225
+ type: 日志类型筛选(private, group, error, system)
226
+ limit: 返回条数限制
227
+ """
228
+ if not _log_collector:
229
+ return {"logs": [], "error": "日志收集器未初始化"}
230
+
231
+ logs = _log_collector.get_logs(log_type=type, limit=limit)
232
+
233
+ return {
234
+ "logs": logs,
235
+ "total": _log_collector.log_count,
236
+ "subscribers": _log_collector.subscriber_count
237
+ }
238
+
239
+
240
+ # ============ 任务API ============
241
+
242
+ @router.get("/tasks")
243
+ async def get_tasks():
244
+ """获取所有定时任务"""
245
+ if not _scheduler:
246
+ return {"tasks": [], "error": "调度器未初始化"}
247
+
248
+ tasks = _scheduler.get_tasks()
249
+
250
+ return {"tasks": tasks}
251
+
252
+
253
+ @router.post("/tasks/{task_id}/run")
254
+ async def run_task(task_id: str):
255
+ """手动触发任务执行"""
256
+ if not _scheduler:
257
+ raise HTTPException(status_code=503, detail="调度器未初始化")
258
+
259
+ result = await _scheduler.run_task_manually(task_id)
260
+
261
+ if result.get("status") == "error":
262
+ raise HTTPException(status_code=400, detail=result.get("message"))
263
+
264
+ return result
265
+
266
+
267
+ @router.get("/tasks/history")
268
+ async def get_task_history(limit: int = 50):
269
+ """获取任务执行历史"""
270
+ if not _scheduler:
271
+ return {"history": [], "error": "调度器未初始化"}
272
+
273
+ history = _scheduler.get_task_history(limit=limit)
274
+
275
+ return {"history": history}
276
+
277
+
278
+ # ============ 缓存API ============
279
+
280
+ @router.post("/cache/clear")
281
+ async def clear_cache(data: CacheClearRequest):
282
+ """清理缓存
283
+
284
+ Args:
285
+ cache_type: 缓存类型
286
+ - all: 清空所有缓存
287
+ - image: 清空图片缓存
288
+ """
289
+ if not _state_store:
290
+ raise HTTPException(status_code=503, detail="状态存储器未初始化")
291
+
292
+ cache_type = data.cache_type
293
+
294
+ if cache_type == "all":
295
+ _state_store.clear_all()
296
+ return {"status": "ok", "message": "所有缓存已清空"}
297
+ elif cache_type == "image":
298
+ _state_store.clear_image_cache()
299
+ return {"status": "ok", "message": "图片缓存已清空"}
300
+ else:
301
+ raise HTTPException(status_code=400, detail=f"未知的缓存类型: {cache_type}")
302
+
303
+
304
+ # ============ 列表API ============
305
+
306
+ @router.get("/chatrooms")
307
+ async def get_chatrooms():
308
+ """获取群聊列表"""
309
+ if not _qianxun_client or not _config_manager:
310
+ return {"chatrooms": [], "error": "服务未初始化"}
311
+
312
+ robot_wxid = _config_manager.robot_wxid
313
+ if not robot_wxid:
314
+ return {"chatrooms": [], "error": "机器人wxid未配置"}
315
+
316
+ try:
317
+ chatrooms = await _qianxun_client.get_chatroom_list(robot_wxid)
318
+ return {"chatrooms": chatrooms}
319
+ except Exception as e:
320
+ logger.error(f"获取群聊列表失败: {e}")
321
+ return {"chatrooms": [], "error": str(e)}
322
+
323
+
324
+ @router.get("/friends")
325
+ async def get_friends():
326
+ """获取好友列表"""
327
+ if not _qianxun_client or not _config_manager:
328
+ return {"friends": [], "error": "服务未初始化"}
329
+
330
+ robot_wxid = _config_manager.robot_wxid
331
+ if not robot_wxid:
332
+ return {"friends": [], "error": "机器人wxid未配置"}
333
+
334
+ try:
335
+ friends = await _qianxun_client.get_friend_list(robot_wxid)
336
+ return {"friends": friends}
337
+ except Exception as e:
338
+ logger.error(f"获取好友列表失败: {e}")
339
+ return {"friends": [], "error": str(e)}
340
+
341
+
342
+ @router.get("/group_members/{group_wxid}")
343
+ async def get_group_members(group_wxid: str):
344
+ """获取群成员列表"""
345
+ if not _qianxun_client or not _config_manager:
346
+ return {"members": [], "error": "服务未初始化"}
347
+
348
+ robot_wxid = _config_manager.robot_wxid
349
+ if not robot_wxid:
350
+ return {"members": [], "error": "机器人wxid未配置"}
351
+
352
+ try:
353
+ members = await _qianxun_client.get_group_member_list(
354
+ robot_wxid, group_wxid, get_nick=True
355
+ )
356
+ # 格式化返回数据
357
+ result = []
358
+ for m in members:
359
+ result.append({
360
+ "wxid": m.get("wxid", ""),
361
+ "nickname": m.get("groupNick", "") or m.get("nickname", "") or m.get("wxid", "")
362
+ })
363
+ return {"members": result}
364
+ except Exception as e:
365
+ logger.error(f"获取群成员列表失败: {e}")
366
+ return {"members": [], "error": str(e)}
367
+
368
+
369
+ @router.get("/robot_info")
370
+ async def get_robot_info():
371
+ """获取机器人自身信息"""
372
+ if not _qianxun_client or not _config_manager:
373
+ return {"wxid": "", "nickname": "", "error": "服务未初始化"}
374
+
375
+ robot_wxid = _config_manager.robot_wxid
376
+ if not robot_wxid:
377
+ return {"wxid": "", "nickname": "", "error": "机器人wxid未配置"}
378
+
379
+ try:
380
+ info = await _qianxun_client.get_self_info(robot_wxid)
381
+ if info:
382
+ # 尝试多个可能的字段名
383
+ nickname = (
384
+ info.get("nick") or
385
+ info.get("nickname") or
386
+ info.get("nickName") or
387
+ info.get("name") or
388
+ robot_wxid
389
+ )
390
+ return {"wxid": robot_wxid, "nickname": nickname}
391
+ return {"wxid": robot_wxid, "nickname": robot_wxid}
392
+ except Exception as e:
393
+ logger.error(f"获取机器人信息失败: {e}")
394
+ return {"wxid": robot_wxid, "nickname": robot_wxid, "error": str(e)}
395
+
396
+
397
+ # ============ 群发API ============
398
+
399
+ class BroadcastTextRequest(BaseModel):
400
+ """群发文本请求"""
401
+ targets: List[str] # 目标wxid列表
402
+ message: str # 消息内容
403
+
404
+
405
+ class BroadcastImageRequest(BaseModel):
406
+ """群发图片请求"""
407
+ targets: List[str] # 目标wxid列表
408
+ image_path: str # 图片路径(本地路径、网络直链或base64)
409
+ file_name: str = "" # 保存文件名(网络直链时必填)
410
+
411
+
412
+ class BroadcastFileRequest(BaseModel):
413
+ """群发文件请求"""
414
+ targets: List[str] # 目标wxid列表
415
+ file_path: str # 文件路径
416
+ file_name: str = "" # 保存文件名
417
+
418
+
419
+ class BroadcastShareUrlRequest(BaseModel):
420
+ """群发分享链接请求"""
421
+ targets: List[str] # 目标wxid列表
422
+ title: str # 标题
423
+ content: str # 内容描述
424
+ jump_url: str # 跳转地址
425
+ thumb_path: str = "" # 缩略图路径
426
+ app: str = "" # 小尾巴
427
+
428
+
429
+ class BroadcastAppletRequest(BaseModel):
430
+ """群发小程序请求"""
431
+ targets: List[str] # 目标wxid列表
432
+ title: str # 标题
433
+ content: str # 内容描述
434
+ jump_path: str # 跳转路径
435
+ gh: str # 小程序gh
436
+ thumb_path: str = "" # 缩略图路径
437
+
438
+
439
+ @router.post("/broadcast/text")
440
+ async def broadcast_text(data: BroadcastTextRequest):
441
+ """群发文本消息(通过消息队列)"""
442
+ if not _qianxun_client or not _config_manager:
443
+ raise HTTPException(status_code=503, detail="服务未初始化")
444
+
445
+ if not _message_queue:
446
+ raise HTTPException(status_code=503, detail="消息队列未初始化")
447
+
448
+ robot_wxid = _config_manager.robot_wxid
449
+ if not robot_wxid:
450
+ raise HTTPException(status_code=400, detail="机器人wxid未配置")
451
+
452
+ if not data.targets:
453
+ raise HTTPException(status_code=400, detail="请选择发送目标")
454
+
455
+ if not data.message.strip():
456
+ raise HTTPException(status_code=400, detail="消息内容不能为空")
457
+
458
+ # 将所有消息加入队列
459
+ message_ids = []
460
+ for target in data.targets:
461
+ msg_id = await _message_queue.enqueue_text(
462
+ _qianxun_client,
463
+ robot_wxid,
464
+ target,
465
+ data.message,
466
+ priority=MessagePriority.LOW # 群发使用低优先级
467
+ )
468
+ message_ids.append({"target": target, "message_id": msg_id})
469
+
470
+ # 记录群发日志
471
+ if _log_collector:
472
+ await _log_collector.add_log("system", {
473
+ "message": f"群发文本已加入队列: {len(data.targets)} 条消息",
474
+ "details": {"type": "text", "total": len(data.targets)}
475
+ })
476
+
477
+ return {
478
+ "status": "queued",
479
+ "total": len(data.targets),
480
+ "queue_size": _message_queue.queue_size,
481
+ "message_ids": message_ids,
482
+ "message": f"已将 {len(data.targets)} 条消息加入发送队列"
483
+ }
484
+
485
+
486
+ @router.post("/broadcast/image")
487
+ async def broadcast_image(data: BroadcastImageRequest):
488
+ """群发图片(通过消息队列)"""
489
+ if not _qianxun_client or not _config_manager:
490
+ raise HTTPException(status_code=503, detail="服务未初始化")
491
+
492
+ if not _message_queue:
493
+ raise HTTPException(status_code=503, detail="消息队列未初始化")
494
+
495
+ robot_wxid = _config_manager.robot_wxid
496
+ if not robot_wxid:
497
+ raise HTTPException(status_code=400, detail="机器人wxid未配置")
498
+
499
+ if not data.targets:
500
+ raise HTTPException(status_code=400, detail="请选择发送目标")
501
+
502
+ if not data.image_path.strip():
503
+ raise HTTPException(status_code=400, detail="图片路径不能为空")
504
+
505
+ # 将所有消息加入队列
506
+ message_ids = []
507
+ for target in data.targets:
508
+ msg_id = await _message_queue.enqueue_image(
509
+ _qianxun_client,
510
+ robot_wxid,
511
+ target,
512
+ data.image_path,
513
+ data.file_name,
514
+ priority=MessagePriority.LOW
515
+ )
516
+ message_ids.append({"target": target, "message_id": msg_id})
517
+
518
+ # 记录群发日志
519
+ if _log_collector:
520
+ await _log_collector.add_log("system", {
521
+ "message": f"群发图片已加入队列: {len(data.targets)} 条消息",
522
+ "details": {"type": "image", "total": len(data.targets), "path": data.image_path[:50]}
523
+ })
524
+
525
+ return {
526
+ "status": "queued",
527
+ "total": len(data.targets),
528
+ "queue_size": _message_queue.queue_size,
529
+ "message_ids": message_ids,
530
+ "message": f"已将 {len(data.targets)} 条消息加入发送队列"
531
+ }
532
+
533
+
534
+ @router.post("/broadcast/file")
535
+ async def broadcast_file(data: BroadcastFileRequest):
536
+ """群发文件(通过消息队列)"""
537
+ if not _qianxun_client or not _config_manager:
538
+ raise HTTPException(status_code=503, detail="服务未初始化")
539
+
540
+ if not _message_queue:
541
+ raise HTTPException(status_code=503, detail="消息队列未初始化")
542
+
543
+ robot_wxid = _config_manager.robot_wxid
544
+ if not robot_wxid:
545
+ raise HTTPException(status_code=400, detail="机器人wxid未配置")
546
+
547
+ if not data.targets:
548
+ raise HTTPException(status_code=400, detail="请选择发送目标")
549
+
550
+ if not data.file_path.strip():
551
+ raise HTTPException(status_code=400, detail="文件路径不能为空")
552
+
553
+ # 提前捕获文件路径和文件名,避免闭包问题
554
+ file_path = data.file_path
555
+ file_name = data.file_name
556
+
557
+ # 将所有消息加入队列
558
+ message_ids = []
559
+ for target in data.targets:
560
+ # 使用默认参数捕获当前值,避免闭包延迟绑定问题
561
+ async def send_file(t=target, fp=file_path, fn=file_name):
562
+ return await _qianxun_client.send_file(robot_wxid, t, fp, fn)
563
+
564
+ msg_id = await _message_queue.enqueue(
565
+ send_func=send_file,
566
+ priority=MessagePriority.LOW,
567
+ message_type="file",
568
+ target=target,
569
+ content_preview=file_path
570
+ )
571
+ message_ids.append({"target": target, "message_id": msg_id})
572
+
573
+ # 记录群发日志
574
+ if _log_collector:
575
+ await _log_collector.add_log("system", {
576
+ "message": f"群发文件已加入队列: {len(data.targets)} 条消息",
577
+ "details": {"type": "file", "total": len(data.targets), "path": data.file_path[:50]}
578
+ })
579
+
580
+ return {
581
+ "status": "queued",
582
+ "total": len(data.targets),
583
+ "queue_size": _message_queue.queue_size,
584
+ "message_ids": message_ids,
585
+ "message": f"已将 {len(data.targets)} 条消息加入发送队列"
586
+ }
587
+
588
+
589
+ @router.post("/broadcast/share_url")
590
+ async def broadcast_share_url(data: BroadcastShareUrlRequest):
591
+ """群发分享链接(通过消息队列)"""
592
+ if not _qianxun_client or not _config_manager:
593
+ raise HTTPException(status_code=503, detail="服务未初始化")
594
+
595
+ if not _message_queue:
596
+ raise HTTPException(status_code=503, detail="消息队列未初始化")
597
+
598
+ robot_wxid = _config_manager.robot_wxid
599
+ if not robot_wxid:
600
+ raise HTTPException(status_code=400, detail="机器人wxid未配置")
601
+
602
+ if not data.targets:
603
+ raise HTTPException(status_code=400, detail="请选择发送目标")
604
+
605
+ if not data.title.strip():
606
+ raise HTTPException(status_code=400, detail="标题不能为空")
607
+
608
+ if not data.jump_url.strip():
609
+ raise HTTPException(status_code=400, detail="跳转地址不能为空")
610
+
611
+ # 将所有消息加入队列
612
+ message_ids = []
613
+ for target in data.targets:
614
+ async def send_share(t=target):
615
+ return await _qianxun_client.send_share_url(
616
+ robot_wxid, t, data.title, data.content,
617
+ data.jump_url, data.thumb_path, data.app
618
+ )
619
+
620
+ msg_id = await _message_queue.enqueue(
621
+ send_func=send_share,
622
+ priority=MessagePriority.LOW,
623
+ message_type="share_url",
624
+ target=target,
625
+ content_preview=data.title
626
+ )
627
+ message_ids.append({"target": target, "message_id": msg_id})
628
+
629
+ # 记录群发日志
630
+ if _log_collector:
631
+ await _log_collector.add_log("system", {
632
+ "message": f"群发链接已加入队列: {len(data.targets)} 条消息",
633
+ "details": {"type": "share_url", "total": len(data.targets), "title": data.title}
634
+ })
635
+
636
+ return {
637
+ "status": "queued",
638
+ "total": len(data.targets),
639
+ "queue_size": _message_queue.queue_size,
640
+ "message_ids": message_ids,
641
+ "message": f"已将 {len(data.targets)} 条消息加入发送队列"
642
+ }
643
+
644
+
645
+ @router.post("/broadcast/applet")
646
+ async def broadcast_applet(data: BroadcastAppletRequest):
647
+ """群发小程序(通过消息队列)"""
648
+ if not _qianxun_client or not _config_manager:
649
+ raise HTTPException(status_code=503, detail="服务未初始化")
650
+
651
+ if not _message_queue:
652
+ raise HTTPException(status_code=503, detail="消息队列未初始化")
653
+
654
+ robot_wxid = _config_manager.robot_wxid
655
+ if not robot_wxid:
656
+ raise HTTPException(status_code=400, detail="机器人wxid未配置")
657
+
658
+ if not data.targets:
659
+ raise HTTPException(status_code=400, detail="请选择发送目标")
660
+
661
+ if not data.title.strip():
662
+ raise HTTPException(status_code=400, detail="标题不能为空")
663
+
664
+ if not data.gh.strip():
665
+ raise HTTPException(status_code=400, detail="小程序gh不能为空")
666
+
667
+ # 将所有消息加入队列
668
+ message_ids = []
669
+ for target in data.targets:
670
+ async def send_applet(t=target):
671
+ return await _qianxun_client.send_applet(
672
+ robot_wxid, t, data.title, data.content,
673
+ data.jump_path, data.gh, data.thumb_path
674
+ )
675
+
676
+ msg_id = await _message_queue.enqueue(
677
+ send_func=send_applet,
678
+ priority=MessagePriority.LOW,
679
+ message_type="applet",
680
+ target=target,
681
+ content_preview=data.title
682
+ )
683
+ message_ids.append({"target": target, "message_id": msg_id})
684
+
685
+ # 记录群发日志
686
+ if _log_collector:
687
+ await _log_collector.add_log("system", {
688
+ "message": f"群发小程序已加入队列: {len(data.targets)} 条消息",
689
+ "details": {"type": "applet", "total": len(data.targets), "title": data.title, "gh": data.gh}
690
+ })
691
+
692
+ return {
693
+ "status": "queued",
694
+ "total": len(data.targets),
695
+ "queue_size": _message_queue.queue_size,
696
+ "message_ids": message_ids,
697
+ "message": f"已将 {len(data.targets)} 条消息加入发送队列"
698
+ }
699
+
700
+
701
+ # ============ 节假日API ============
702
+
703
+ # 尝试导入中国节假日库
704
+ try:
705
+ import chinese_calendar
706
+ HAS_CHINESE_CALENDAR = True
707
+ except ImportError:
708
+ HAS_CHINESE_CALENDAR = False
709
+
710
+
711
+ @router.get("/holidays/{year}")
712
+ async def get_holidays(year: int):
713
+ """获取指定年份的中国法定节假日列表
714
+
715
+ Args:
716
+ year: 年份,如 2026
717
+
718
+ Returns:
719
+ {
720
+ "year": 2026,
721
+ "holidays": ["2026-01-01", "2026-01-28", ...], # 节假日列表
722
+ "workdays": ["2026-01-25", ...], # 调休补班日列表
723
+ "available": true # 是否有节假日数据
724
+ }
725
+ """
726
+ if not HAS_CHINESE_CALENDAR:
727
+ return {
728
+ "year": year,
729
+ "holidays": [],
730
+ "workdays": [],
731
+ "available": False,
732
+ "error": "chinese-calendar 库未安装"
733
+ }
734
+
735
+ try:
736
+ from datetime import date, timedelta
737
+
738
+ holidays: List[str] = []
739
+ workdays: List[str] = []
740
+
741
+ # 遍历全年每一天
742
+ start_date = date(year, 1, 1)
743
+ end_date = date(year, 12, 31)
744
+ current = start_date
745
+
746
+ while current <= end_date:
747
+ try:
748
+ is_holiday = chinese_calendar.is_holiday(current)
749
+ is_workday = chinese_calendar.is_workday(current)
750
+ weekday = current.weekday()
751
+
752
+ if is_holiday:
753
+ holidays.append(current.isoformat())
754
+ elif is_workday and weekday >= 5:
755
+ # 周末但是工作日 = 调休补班
756
+ workdays.append(current.isoformat())
757
+ except Exception:
758
+ pass
759
+ current += timedelta(days=1)
760
+
761
+ return {
762
+ "year": year,
763
+ "holidays": holidays,
764
+ "workdays": workdays,
765
+ "available": True
766
+ }
767
+ except Exception as e:
768
+ logger.error(f"获取节假日数据失败: {e}")
769
+ return {
770
+ "year": year,
771
+ "holidays": [],
772
+ "workdays": [],
773
+ "available": False,
774
+ "error": str(e)
775
+ }