/** * 社群内容与运营执行模块 — 业务服务层 * * 对应规范文档 §九「模块 6:社群内容与运营执行模块」 * * 业务流程: * §9.1 话术与案例素材库:CMS 上传/分类/检索话术与案例文件 * §9.2 向多个群一键群发:选群与内容 → API-15 创建任务 → 轮询 API-16 * §9.3 识别群内是否已发规定运营内容:时间窗内查群发记录 + 群内关键词匹配 * §9.4 运营内容互动效果统计:以发布时间为起点统计后续消息量/回复数 * §9.5 发布时间建议:按历史群消息聚合活跃小时 * §9.6 录入周度运营计划:ops_plan 表录入计划项 * §9.7 对照计划检查是否已执行:计划时间窗对比 §9.3 识别结果 * §9.8 未完成项提醒:未执行计划项 → API-14 通知责任人 */ import { AppError } from '../../../../shared/errors/app-error.js'; import { getStringEnv } from '../../../../shared/config/env.js'; import { callQiWeApi } from '../../../../shared/qiwei/client.js'; import type { Material, BroadcastTask, OpsPlan, OpsPlanItem, } from '../models/content.model.js'; // ---------- 内存存储(TODO: 替换为数据库)---------- const materialStore = new Map(); const broadcastStore = new Map(); const planStore = new Map(); const planItemStore = new Map(); let nextMaterialId = 1; let nextBroadcastId = 1; let nextPlanId = 1; let nextPlanItemId = 1; // ============================================================ // 素材库(§9.1) // ============================================================ /** 创建素材 */ export function createMaterial(data: Omit): Material { const now = new Date().toISOString(); const m: Material = { id: nextMaterialId++, ...data, createdAt: now, updatedAt: now }; materialStore.set(m.id, m); return m; } /** 查询素材列表 */ export function listMaterials(type?: string, tag?: string): Material[] { let result = Array.from(materialStore.values()); if (type) result = result.filter((m) => m.type === type); if (tag) result = result.filter((m) => m.tags.includes(tag)); return result.filter((m) => m.enabled === 1); } /** 更新素材 */ export function updateMaterial(id: number, data: Partial): Material { const m = materialStore.get(id); if (!m) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`); Object.assign(m, data, { updatedAt: new Date().toISOString() }); materialStore.set(id, m); return m; } /** 删除素材 */ export function deleteMaterial(id: number): void { if (!materialStore.has(id)) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`); materialStore.delete(id); } // ============================================================ // 群发任务(§9.2) // ============================================================ /** * 创建群发任务 * * 步骤: * 1. 调用 API-15 /msg/sendGroupMsg 创建群发 * 2. 获取返回的 groupMsgId * 3. 轮询 API-16 /msg/sendGroupMsgStatus 检查进度 * * @param guid - 执行人员 guid * @param sendType - 发送类型:0=外部联系人, 1=外部群 * @param toIdList - 目标 ID 列表 * @param msgList - 消息内容列表 * @returns 群发任务记录 */ export async function createBroadcast( guid: string, sendType: 0 | 1, toIdList: string[], msgList: Array<{ type: number; msgData: Record }>, ): Promise { const now = new Date().toISOString(); const live = getStringEnv('QIWEI_LIVE_ALLOW_BROADCAST') === '1'; let groupMsgId = `dry-run-${Date.now()}`; if (live) { const data = await callQiWeApi<{ groupMsgId: string }>('/msg/sendGroupMsg', { guid, sendType, toIdList, msgList, }); groupMsgId = data.groupMsgId; } const task: BroadcastTask = { id: nextBroadcastId++, groupMsgId, sendType, toIdList, msgList, guid, total: toIdList.length, hasSend: 0, isEnd: false, creatorId: 0, createdAt: now, updatedAt: now, }; broadcastStore.set(task.id, task); if (live) { pollBroadcastStatus(task).catch((err) => console.error(`[Content] 群发状态轮询失败: taskId=${task.id}`, err), ); } return task; } /** * 轮询群发状态(内部方法) */ async function pollBroadcastStatus(task: BroadcastTask): Promise { // 简化的单次查询(生产环境应使用定时任务轮询) const statusData = await callQiWeApi<{ hasSend: number; isEnd: boolean; total: number; }>('/msg/sendGroupMsgStatus', { guid: task.guid, groupMsgId: task.groupMsgId, endDetailId: 2, }); task.hasSend = statusData.hasSend; task.isEnd = statusData.isEnd; task.total = statusData.total; task.updatedAt = new Date().toISOString(); broadcastStore.set(task.id, task); } /** 查询群发任务列表 */ export function listBroadcasts(): BroadcastTask[] { return Array.from(broadcastStore.values()).sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ); } /** 查询群发任务状态 */ export function getBroadcastStatus(id: number): BroadcastTask { const task = broadcastStore.get(id); if (!task) throw new AppError(404, 'BROADCAST_NOT_FOUND', `群发任务 ID=${id} 不存在`); return task; } // ============================================================ // 运营计划(§9.6 ~ §9.7) // ============================================================ /** 创建运营计划 */ export function createPlan(data: Omit): OpsPlan { const now = new Date().toISOString(); const plan: OpsPlan = { id: nextPlanId++, ...data, createdAt: now, updatedAt: now }; planStore.set(plan.id, plan); return plan; } /** 查询运营计划列表 */ export function listPlans(status?: string): OpsPlan[] { let result = Array.from(planStore.values()); if (status) result = result.filter((p) => p.status === status); return result.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); } /** 添加计划项 */ export function addPlanItem(data: Omit): OpsPlanItem { const plan = planStore.get(data.planId); if (!plan) throw new AppError(404, 'PLAN_NOT_FOUND', `计划 ID=${data.planId} 不存在`); const item: OpsPlanItem = { id: nextPlanItemId++, ...data, createdAt: new Date().toISOString(), }; planItemStore.set(item.id, item); return item; } /** 查询计划项列表 */ export function listPlanItems(planId: number): OpsPlanItem[] { return Array.from(planItemStore.values()) .filter((item) => item.planId === planId) .sort((a, b) => a.scheduledDate.localeCompare(b.scheduledDate)); } /** * 检查计划执行情况(§9.7) * * 对计划项的时间窗和群发记录做对比,更新执行状态。 * * @param planId - 计划 ID * @returns 未执行的项目列表(供 §9.8 提醒使用) */ export function checkPlanExecution(planId: number): { unexecuted: OpsPlanItem[]; executed: OpsPlanItem[] } { const items = listPlanItems(planId); const now = new Date(); const unexecuted: OpsPlanItem[] = []; const executed: OpsPlanItem[] = []; for (const item of items) { if (item.status === 'skipped') continue; // 判断是否已过计划日期 const scheduled = new Date(item.scheduledDate); if (scheduled < now && item.status === 'pending') { // 检查是否有群发记录(TODO: 实际查询 broadcast 记录) if (item.contentDetected) { item.status = 'executed'; planItemStore.set(item.id, item); executed.push(item); } else { unexecuted.push(item); } } } return { unexecuted, executed }; }