content.service.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. /**
  2. * 社群内容与运营执行模块 — 业务服务层
  3. *
  4. * 对应规范文档 §九「模块 6:社群内容与运营执行模块」
  5. *
  6. * 业务流程:
  7. * §9.1 话术与案例素材库:CMS 上传/分类/检索话术与案例文件
  8. * §9.2 向多个群一键群发:选群与内容 → API-15 创建任务 → 轮询 API-16
  9. * §9.3 识别群内是否已发规定运营内容:时间窗内查群发记录 + 群内关键词匹配
  10. * §9.4 运营内容互动效果统计:以发布时间为起点统计后续消息量/回复数
  11. * §9.5 发布时间建议:按历史群消息聚合活跃小时
  12. * §9.6 录入周度运营计划:ops_plan 表录入计划项
  13. * §9.7 对照计划检查是否已执行:计划时间窗对比 §9.3 识别结果
  14. * §9.8 未完成项提醒:未执行计划项 → API-14 通知责任人
  15. */
  16. import { AppError } from '../../../../shared/errors/app-error.js';
  17. import { getStringEnv } from '../../../../shared/config/env.js';
  18. import { callQiWeApi } from '../../../../shared/qiwei/client.js';
  19. import type {
  20. Material,
  21. BroadcastTask,
  22. OpsPlan,
  23. OpsPlanItem,
  24. } from '../models/content.model.js';
  25. // ---------- 内存存储(TODO: 替换为数据库)----------
  26. const materialStore = new Map<number, Material>();
  27. const broadcastStore = new Map<number, BroadcastTask>();
  28. const planStore = new Map<number, OpsPlan>();
  29. const planItemStore = new Map<number, OpsPlanItem>();
  30. let nextMaterialId = 1;
  31. let nextBroadcastId = 1;
  32. let nextPlanId = 1;
  33. let nextPlanItemId = 1;
  34. // ============================================================
  35. // 素材库(§9.1)
  36. // ============================================================
  37. /** 创建素材 */
  38. export function createMaterial(data: Omit<Material, 'id' | 'createdAt' | 'updatedAt'>): Material {
  39. const now = new Date().toISOString();
  40. const m: Material = { id: nextMaterialId++, ...data, createdAt: now, updatedAt: now };
  41. materialStore.set(m.id, m);
  42. return m;
  43. }
  44. /** 查询素材列表 */
  45. export function listMaterials(type?: string, tag?: string): Material[] {
  46. let result = Array.from(materialStore.values());
  47. if (type) result = result.filter((m) => m.type === type);
  48. if (tag) result = result.filter((m) => m.tags.includes(tag));
  49. return result.filter((m) => m.enabled === 1);
  50. }
  51. /** 更新素材 */
  52. export function updateMaterial(id: number, data: Partial<Material>): Material {
  53. const m = materialStore.get(id);
  54. if (!m) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`);
  55. Object.assign(m, data, { updatedAt: new Date().toISOString() });
  56. materialStore.set(id, m);
  57. return m;
  58. }
  59. /** 删除素材 */
  60. export function deleteMaterial(id: number): void {
  61. if (!materialStore.has(id)) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`);
  62. materialStore.delete(id);
  63. }
  64. // ============================================================
  65. // 群发任务(§9.2)
  66. // ============================================================
  67. /**
  68. * 创建群发任务
  69. *
  70. * 步骤:
  71. * 1. 调用 API-15 /msg/sendGroupMsg 创建群发
  72. * 2. 获取返回的 groupMsgId
  73. * 3. 轮询 API-16 /msg/sendGroupMsgStatus 检查进度
  74. *
  75. * @param guid - 执行人员 guid
  76. * @param sendType - 发送类型:0=外部联系人, 1=外部群
  77. * @param toIdList - 目标 ID 列表
  78. * @param msgList - 消息内容列表
  79. * @returns 群发任务记录
  80. */
  81. export async function createBroadcast(
  82. guid: string,
  83. sendType: 0 | 1,
  84. toIdList: string[],
  85. msgList: Array<{ type: number; msgData: Record<string, unknown> }>,
  86. ): Promise<BroadcastTask> {
  87. const now = new Date().toISOString();
  88. const live = getStringEnv('QIWEI_LIVE_ALLOW_BROADCAST') === '1';
  89. let groupMsgId = `dry-run-${Date.now()}`;
  90. if (live) {
  91. const data = await callQiWeApi<{ groupMsgId: string }>('/msg/sendGroupMsg', {
  92. guid,
  93. sendType,
  94. toIdList,
  95. msgList,
  96. });
  97. groupMsgId = data.groupMsgId;
  98. }
  99. const task: BroadcastTask = {
  100. id: nextBroadcastId++,
  101. groupMsgId,
  102. sendType,
  103. toIdList,
  104. msgList,
  105. guid,
  106. total: toIdList.length,
  107. hasSend: 0,
  108. isEnd: false,
  109. creatorId: 0,
  110. createdAt: now,
  111. updatedAt: now,
  112. };
  113. broadcastStore.set(task.id, task);
  114. if (live) {
  115. pollBroadcastStatus(task).catch((err) =>
  116. console.error(`[Content] 群发状态轮询失败: taskId=${task.id}`, err),
  117. );
  118. }
  119. return task;
  120. }
  121. /**
  122. * 轮询群发状态(内部方法)
  123. */
  124. async function pollBroadcastStatus(task: BroadcastTask): Promise<void> {
  125. // 简化的单次查询(生产环境应使用定时任务轮询)
  126. const statusData = await callQiWeApi<{
  127. hasSend: number;
  128. isEnd: boolean;
  129. total: number;
  130. }>('/msg/sendGroupMsgStatus', {
  131. guid: task.guid,
  132. groupMsgId: task.groupMsgId,
  133. endDetailId: 2,
  134. });
  135. task.hasSend = statusData.hasSend;
  136. task.isEnd = statusData.isEnd;
  137. task.total = statusData.total;
  138. task.updatedAt = new Date().toISOString();
  139. broadcastStore.set(task.id, task);
  140. }
  141. /** 查询群发任务列表 */
  142. export function listBroadcasts(): BroadcastTask[] {
  143. return Array.from(broadcastStore.values()).sort(
  144. (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
  145. );
  146. }
  147. /** 查询群发任务状态 */
  148. export function getBroadcastStatus(id: number): BroadcastTask {
  149. const task = broadcastStore.get(id);
  150. if (!task) throw new AppError(404, 'BROADCAST_NOT_FOUND', `群发任务 ID=${id} 不存在`);
  151. return task;
  152. }
  153. // ============================================================
  154. // 运营计划(§9.6 ~ §9.7)
  155. // ============================================================
  156. /** 创建运营计划 */
  157. export function createPlan(data: Omit<OpsPlan, 'id' | 'createdAt' | 'updatedAt'>): OpsPlan {
  158. const now = new Date().toISOString();
  159. const plan: OpsPlan = { id: nextPlanId++, ...data, createdAt: now, updatedAt: now };
  160. planStore.set(plan.id, plan);
  161. return plan;
  162. }
  163. /** 查询运营计划列表 */
  164. export function listPlans(status?: string): OpsPlan[] {
  165. let result = Array.from(planStore.values());
  166. if (status) result = result.filter((p) => p.status === status);
  167. return result.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
  168. }
  169. /** 添加计划项 */
  170. export function addPlanItem(data: Omit<OpsPlanItem, 'id' | 'createdAt'>): OpsPlanItem {
  171. const plan = planStore.get(data.planId);
  172. if (!plan) throw new AppError(404, 'PLAN_NOT_FOUND', `计划 ID=${data.planId} 不存在`);
  173. const item: OpsPlanItem = {
  174. id: nextPlanItemId++,
  175. ...data,
  176. createdAt: new Date().toISOString(),
  177. };
  178. planItemStore.set(item.id, item);
  179. return item;
  180. }
  181. /** 查询计划项列表 */
  182. export function listPlanItems(planId: number): OpsPlanItem[] {
  183. return Array.from(planItemStore.values())
  184. .filter((item) => item.planId === planId)
  185. .sort((a, b) => a.scheduledDate.localeCompare(b.scheduledDate));
  186. }
  187. /**
  188. * 检查计划执行情况(§9.7)
  189. *
  190. * 对计划项的时间窗和群发记录做对比,更新执行状态。
  191. *
  192. * @param planId - 计划 ID
  193. * @returns 未执行的项目列表(供 §9.8 提醒使用)
  194. */
  195. export function checkPlanExecution(planId: number): { unexecuted: OpsPlanItem[]; executed: OpsPlanItem[] } {
  196. const items = listPlanItems(planId);
  197. const now = new Date();
  198. const unexecuted: OpsPlanItem[] = [];
  199. const executed: OpsPlanItem[] = [];
  200. for (const item of items) {
  201. if (item.status === 'skipped') continue;
  202. // 判断是否已过计划日期
  203. const scheduled = new Date(item.scheduledDate);
  204. if (scheduled < now && item.status === 'pending') {
  205. // 检查是否有群发记录(TODO: 实际查询 broadcast 记录)
  206. if (item.contentDetected) {
  207. item.status = 'executed';
  208. planItemStore.set(item.id, item);
  209. executed.push(item);
  210. } else {
  211. unexecuted.push(item);
  212. }
  213. }
  214. }
  215. return { unexecuted, executed };
  216. }