| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- /**
- * 社群内容与运营执行模块 — 业务服务层
- *
- * 对应规范文档 §九「模块 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<number, Material>();
- const broadcastStore = new Map<number, BroadcastTask>();
- const planStore = new Map<number, OpsPlan>();
- const planItemStore = new Map<number, OpsPlanItem>();
- let nextMaterialId = 1;
- let nextBroadcastId = 1;
- let nextPlanId = 1;
- let nextPlanItemId = 1;
- // ============================================================
- // 素材库(§9.1)
- // ============================================================
- /** 创建素材 */
- export function createMaterial(data: Omit<Material, 'id' | 'createdAt' | 'updatedAt'>): 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>): 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<string, unknown> }>,
- ): Promise<BroadcastTask> {
- 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<void> {
- // 简化的单次查询(生产环境应使用定时任务轮询)
- 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, 'id' | 'createdAt' | 'updatedAt'>): 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, 'id' | 'createdAt'>): 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 };
- }
|