From a007e110acec584045d9067a37f401757247d644 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Thu, 27 Aug 2026 20:27:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20=20Skill=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=9A=E6=97=B6=E6=97=A5=E6=8A=A5=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/skill/tracememo-reader/SKILL.md | 79 ++- src/main/http-server.ts | 152 +++++- .../services/scheduled-report-api-service.ts | 451 ++++++++++++++++++ src/renderer/src/styles/reports-core.scss | 30 ++ src/shared/local-api-test.ts | 10 + src/shared/scheduled-report-api.ts | 67 +++ tests/integration/local-api-auth.test.ts | 2 + .../integration/scheduled-report-api.test.ts | 254 ++++++++++ .../unit/scheduled-report-api-service.test.ts | 204 ++++++++ 9 files changed, 1233 insertions(+), 16 deletions(-) create mode 100644 src/main/services/scheduled-report-api-service.ts create mode 100644 src/shared/scheduled-report-api.ts create mode 100644 tests/integration/scheduled-report-api.test.ts create mode 100644 tests/unit/scheduled-report-api-service.test.ts diff --git a/docs/skill/tracememo-reader/SKILL.md b/docs/skill/tracememo-reader/SKILL.md index b538d34..6b5fffd 100644 --- a/docs/skill/tracememo-reader/SKILL.md +++ b/docs/skill/tracememo-reader/SKILL.md @@ -27,21 +27,70 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 ## 端点速查 -| 方法 | 路径 | 用途 | -| ---- | --------------------- | ------------------------------------------------- | -| GET | `/health` | 健康和数据库状态 | -| GET | `/current_time` | 本机时间与时区 | -| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` | -| GET | `/chatroom` | 群聊列表;可传 `keyword` | -| GET | `/recent_chat` | 最近会话;可传 `limit` | -| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | -| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 | -| GET | `/group_snapshot` | 群成员快照;必填 `md5` | -| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | -| POST | `/report` | 将已有日报结构渲染为 HTML/PNG | -| GET | `/agent/status` | Agent Hub、连接器和数据库状态 | -| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 | -| POST | `/agent/send` | 已连接机器人发送测试 | +| 方法 | 路径 | 用途 | +| ------ | ----------------------------------- | ------------------------------------------------- | +| GET | `/health` | 健康和数据库状态 | +| GET | `/current_time` | 本机时间与时区 | +| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` | +| GET | `/chatroom` | 群聊列表;可传 `keyword` | +| GET | `/recent_chat` | 最近会话;可传 `limit` | +| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | +| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 | +| GET | `/group_snapshot` | 群成员快照;必填 `md5` | +| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | +| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 | +| GET | `/scheduled-reports` | 查询全部定时日报任务 | +| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 | +| POST | `/scheduled-reports` | 创建定时日报任务 | +| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 | +| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) | +| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 | +| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 | +| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution | +| GET | `/scheduled-reports/:id/executions` | 查询执行记录 | +| POST | `/report` | 将已有日报结构渲染为 HTML/PNG | +| GET | `/agent/status` | Agent Hub、连接器和数据库状态 | +| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 | +| POST | `/agent/send` | 已连接机器人发送测试 | + +## 定时日报管理 + +定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。 + +### 创建任务 + +用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行: + +1. 调用 `/health`,确认 TraceMemo 和数据库可用。 +2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。 +3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。 +4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。 +5. 使用唯一群聊的 `talker` 创建: + +```json +{ + "name": "技术交流群 · 每日日报", + "group": { "talker": "xxx@chatroom", "name": "技术交流群" }, + "schedule": { "type": "daily", "time": "09:00" }, + "reportRange": "yesterday", + "target": { "type": "wechat_group", "talker": "xxx@chatroom" }, + "enabled": true +} +``` + +如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。 + +### 查看、修改和执行 + +- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。 +- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。 +- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。 +- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。 +- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。 + +### 删除确认 + +删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。 ## 时间与上下文规则 diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 2d44752..83a0f77 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -11,6 +11,17 @@ import { import { exportGroupReport } from './group-report-service' import { GroupReportExportRequest } from '../shared/group-report' import { generateAgentGroupReport } from './services/agent-group-report-service' +import { scheduledReportService } from './services/scheduled-report-service' +import { personalWechatCapabilityService } from './services/personal-wechat-capability-service' +import { + ScheduledReportApiError, + ScheduledReportApiService, + type ScheduledReportApiDependencies +} from './services/scheduled-report-api-service' +import type { + ScheduledReportApiCreateRequest, + ScheduledReportApiUpdateRequest +} from '../shared/scheduled-report-api' import { agentHubService } from './services/agent-hub-service' import { safeError, safeLog, safeWarn } from './safe-log' import { apiTokenStore } from './api-token-store' @@ -35,6 +46,11 @@ interface RouteContext { export interface HttpServerOptions { tokenProvider?: () => string | null mediaProvider?: (messageId: string) => Promise + scheduledReportService?: ScheduledReportApiDependencies['service'] + scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability'] + scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts'] + scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady'] + scheduledReportPlatform?: NodeJS.Platform } type RouteHandler = (ctx: RouteContext) => void | Promise @@ -67,7 +83,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean { if (!isAllowedCorsOrigin(origin)) return false res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Vary', 'Origin') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') return true } @@ -367,6 +383,138 @@ const routes: Record = { } } +const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports' +const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability' + +function parseJsonBody(body: unknown): unknown { + if (typeof body !== 'string' || !body.trim()) { + throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空') + } + try { + return JSON.parse(body) + } catch { + throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败') + } +} + +function sendScheduledError(res: ServerResponse, error: unknown): void { + if (error instanceof ScheduledReportApiError) { + sendJson(res, error.status, { + error: error.code, + message: error.message, + ...(error.details !== undefined ? { details: error.details } : {}) + }) + return + } + safeError('[HttpServer] scheduled report request failed:', error) + sendJson(res, 500, { + error: 'internal_error', + message: '定时日报 API 执行失败' + }) +} + +function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService { + return new ScheduledReportApiService({ + service: options.scheduledReportService || scheduledReportService, + getCapability: + options.scheduledReportCapabilityProvider || + (() => personalWechatCapabilityService.getPersonalWechatSendCapability()), + listContacts: options.scheduledReportContactsProvider || listContacts, + isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady, + platform: options.scheduledReportPlatform + }) +} + +function createScheduledReportRoute( + pathname: string, + api: ScheduledReportApiService +): RouteHandler | undefined { + if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) { + return async ({ req, res }) => { + if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求') + try { + sendJson(res, 200, { capability: await api.getCapability() }) + } catch (error) { + sendScheduledError(res, error) + } + } + } + + if (pathname === SCHEDULED_REPORTS_ROUTE) { + return async ({ req, res, body }) => { + try { + if (req.method === 'GET') { + const tasks = await api.list() + sendJson(res, 200, { count: tasks.length, tasks }) + return + } + if (req.method !== 'POST') { + sendError(res, 405, '需要 GET 或 POST 请求') + return + } + const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest) + sendJson(res, 201, { created: true, task }) + } catch (error) { + sendScheduledError(res, error) + } + } + } + + const prefix = `${SCHEDULED_REPORTS_ROUTE}/` + if (!pathname.startsWith(prefix)) return undefined + const segments = pathname.slice(prefix.length).split('/').filter(Boolean) + if (!segments.length || segments.length > 2) return undefined + let taskId: string + try { + taskId = decodeURIComponent(segments[0]) + } catch { + return undefined + } + const action = segments[1] + + return async ({ req, res, body }) => { + try { + if (!action && req.method === 'GET') { + sendJson(res, 200, { task: await api.get(taskId) }) + return + } + if (!action && req.method === 'PATCH') { + const task = await api.update( + taskId, + parseJsonBody(body) as ScheduledReportApiUpdateRequest + ) + sendJson(res, 200, { updated: true, task }) + return + } + if (!action && req.method === 'DELETE') { + sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) }) + return + } + if (action === 'enable' && req.method === 'POST') { + sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) }) + return + } + if (action === 'disable' && req.method === 'POST') { + sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) }) + return + } + if (action === 'run' && req.method === 'POST') { + const execution = await api.run(taskId) + sendJson(res, 200, { success: execution.status === 'success', execution }) + return + } + if (action === 'executions' && req.method === 'GET') { + const executions = await api.executions(taskId) + sendJson(res, 200, { count: executions.length, executions }) + return + } + sendError(res, 405, '请求方法或定时日报操作不受支持') + } catch (error) { + sendScheduledError(res, error) + } + } +} + const MEDIA_ROUTE_PREFIX = '/api/v1/media/' function createMediaRoute( @@ -424,6 +572,7 @@ export function startHttpServer( ): Promise { const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication()) const mediaProvider = options.mediaProvider || readImageMedia + const scheduledReportApi = createScheduledReportApi(options) return new Promise((resolve, reject) => { const server: Server = http.createServer(async (req, res) => { try { @@ -437,6 +586,7 @@ export function startHttpServer( } const handler = routes[url.pathname] || + createScheduledReportRoute(url.pathname, scheduledReportApi) || (url.pathname.startsWith(MEDIA_ROUTE_PREFIX) ? createMediaRoute(mediaProvider) : undefined) diff --git a/src/main/services/scheduled-report-api-service.ts b/src/main/services/scheduled-report-api-service.ts new file mode 100644 index 0000000..2ff7cef --- /dev/null +++ b/src/main/services/scheduled-report-api-service.ts @@ -0,0 +1,451 @@ +import type { PersonalWechatSendCapability } from '../../shared/personal-wechat' +import type { + ScheduledReportApiCreateRequest, + ScheduledReportApiExecution, + ScheduledReportApiGroupRef, + ScheduledReportApiTargetRef, + ScheduledReportApiTask, + ScheduledReportApiUpdateRequest +} from '../../shared/scheduled-report-api' +import type { + ScheduledReportExecution, + ScheduledReportRange, + ScheduledReportTask, + ScheduledReportUpdateInput +} from '../../shared/scheduled-report' +import type { FormattedContact } from './chat-service' +import { resolveContact } from './contact-resolution-service' +import type { ScheduledReportService } from './scheduled-report-service' + +export interface ScheduledReportApiDependencies { + service: Pick< + ScheduledReportService, + | 'listTasks' + | 'listExecutions' + | 'createTask' + | 'updateTask' + | 'deleteTask' + | 'setTaskEnabled' + | 'runScheduledReportNow' + > + getCapability: () => Promise + listContacts: () => FormattedContact[] + isDatabaseReady: () => boolean + platform?: NodeJS.Platform +} + +export class ScheduledReportApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: unknown + ) { + super(message) + this.name = 'ScheduledReportApiError' + } +} + +const REPORT_RANGES = new Set(['today', 'yesterday', '7days', 'recent24h']) + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const asTrimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') + +function publicCapability(capability: PersonalWechatSendCapability): Record { + return { + supported: capability.supported, + ready: capability.ready, + status: capability.status, + capabilities: capability.capabilities, + message: capability.message, + ...(capability.error ? { error: capability.error } : {}) + } +} + +export class ScheduledReportApiService { + private mutationQueue: Promise = Promise.resolve() + + constructor(private readonly deps: ScheduledReportApiDependencies) {} + + async getCapability(): Promise> { + try { + return publicCapability(this.applyPlatformCapability(await this.deps.getCapability())) + } catch (error) { + throw new ScheduledReportApiError( + 503, + 'wechat_capability_unavailable', + '微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。', + error instanceof Error ? error.message : String(error) + ) + } + } + + async list(): Promise { + this.assertDatabaseReady() + return (await this.deps.service.listTasks()).map((task) => this.toPublicTask(task)) + } + + async get(taskId: string): Promise { + this.assertDatabaseReady() + const task = await this.findTask(taskId) + if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务') + return this.toPublicTask(task) + } + + async create(request: ScheduledReportApiCreateRequest): Promise { + return this.withMutationLock(() => this.createUnlocked(request)) + } + + private async createUnlocked( + request: ScheduledReportApiCreateRequest + ): Promise { + this.assertDatabaseReady() + await this.assertCapabilityReady() + if (!isRecord(request)) + throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效') + + const groupRef = request.group + if (!(typeof groupRef === 'string' || isRecord(groupRef))) { + throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker') + } + const group = this.resolveGroup(groupRef as ScheduledReportApiGroupRef | string) + const targetRef = request.target + if (targetRef !== undefined && !isRecord(targetRef)) { + throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效') + } + const target = this.resolveTarget(targetRef as ScheduledReportApiTargetRef | undefined, group) + const schedule = this.parseSchedule(request.schedule) + const reportRange = this.parseRange(request.reportRange) + const name = asTrimmed(request.name) || `${group.m_nsNickName} · 每日日报` + const enabled = request.enabled !== false + + const duplicate = await this.findDuplicate({ + group: group.md5, + target: target.m_nsUsrName, + scheduleTime: schedule.time, + reportRange, + enabled + }) + if (duplicate) { + throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', { + existingTaskId: duplicate.id, + task: this.toPublicTask(duplicate) + }) + } + + const result = await this.deps.service.createTask({ + name, + group: group.md5, + target: target.m_nsUsrName, + scheduleTime: schedule.time, + reportRange, + enabled + }) + if (!result.success || !result.data) { + throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报创建失败') + } + return this.toPublicTask(result.data) + } + + async update( + taskId: string, + request: ScheduledReportApiUpdateRequest + ): Promise { + return this.withMutationLock(() => this.updateUnlocked(taskId, request)) + } + + private async updateUnlocked( + taskId: string, + request: ScheduledReportApiUpdateRequest + ): Promise { + this.assertDatabaseReady() + const current = await this.findTask(taskId) + if (!current) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务') + if (!isRecord(request)) + throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效') + + const currentGroup = this.contactForTask(current) + let group = currentGroup + if (request.group !== undefined) { + if (!(typeof request.group === 'string' || isRecord(request.group))) { + throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker') + } + group = this.resolveGroup(request.group as ScheduledReportApiGroupRef | string) + } + if (request.target !== undefined && !isRecord(request.target)) { + throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效') + } + const target = + request.target === undefined + ? group + : this.resolveTarget(request.target as unknown as ScheduledReportApiTargetRef, group) + const schedule = + request.schedule === undefined + ? { type: 'daily' as const, time: current.scheduleTime } + : this.parseSchedule(request.schedule) + const reportRange = + request.reportRange === undefined ? current.reportRange : this.parseRange(request.reportRange) + const enabled = request.enabled === undefined ? current.enabled : Boolean(request.enabled) + const name = request.name === undefined ? current.name : asTrimmed(request.name) + if (!name) throw new ScheduledReportApiError(400, 'invalid_request', '日报名称不能为空') + + const duplicate = await this.findDuplicate( + { + group: group.md5, + target: target.m_nsUsrName, + scheduleTime: schedule.time, + reportRange, + enabled + }, + taskId + ) + if (duplicate) { + throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', { + existingTaskId: duplicate.id, + task: this.toPublicTask(duplicate) + }) + } + + const patch: ScheduledReportUpdateInput = { + name, + group: group.md5, + target: target.m_nsUsrName, + scheduleTime: schedule.time, + reportRange, + enabled + } + const result = await this.deps.service.updateTask(taskId, patch) + if (!result.success || !result.data) { + throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报更新失败') + } + return this.toPublicTask(result.data) + } + + async delete(taskId: string): Promise<{ deletedId: string }> { + this.assertDatabaseReady() + const result = await this.deps.service.deleteTask(taskId) + if (!result.success || !result.data) { + throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务') + } + return result.data + } + + async setEnabled(taskId: string, enabled: boolean): Promise { + this.assertDatabaseReady() + const result = await this.deps.service.setTaskEnabled(taskId, enabled) + if (!result.success || !result.data) { + throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务') + } + return this.toPublicTask(result.data) + } + + async run(taskId: string): Promise { + this.assertDatabaseReady() + const result = await this.deps.service.runScheduledReportNow(taskId) + if (!result.data) { + throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务') + } + return result.data + } + + async executions(taskId: string): Promise { + this.assertDatabaseReady() + const task = await this.findTask(taskId) + if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务') + return (await this.deps.service.listExecutions(taskId)).map((execution) => ({ ...execution })) + } + + private assertDatabaseReady(): void { + if (!this.deps.isDatabaseReady()) { + throw new ScheduledReportApiError(503, 'database_not_ready', 'TraceMemo 数据库未初始化') + } + } + + private async withMutationLock(operation: () => Promise): Promise { + const previous = this.mutationQueue + let release!: () => void + this.mutationQueue = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await operation() + } finally { + release() + } + } + + private async assertCapabilityReady(): Promise { + let capability: PersonalWechatSendCapability + try { + capability = this.applyPlatformCapability(await this.deps.getCapability()) + } catch (error) { + throw new ScheduledReportApiError( + 503, + 'wechat_capability_unavailable', + '微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。', + error instanceof Error ? error.message : String(error) + ) + } + if (capability.ready && capability.capabilities.image) return + throw new ScheduledReportApiError( + 409, + 'wechat_not_ready', + capability.message || 'TraceMemo 当前还没有完成微信消息发送能力配置', + { capability: publicCapability(capability) } + ) + } + + private applyPlatformCapability( + capability: PersonalWechatSendCapability + ): PersonalWechatSendCapability { + if ((this.deps.platform || process.platform) === 'darwin') return capability + return { + ...capability, + supported: false, + ready: false, + status: 'unsupported', + capabilities: { text: false, image: false, voice: false }, + message: '微信消息发送目前仅支持 macOS' + } + } + + private async findTask(taskId: string): Promise { + const normalized = asTrimmed(taskId) + if (!normalized) return undefined + return (await this.deps.service.listTasks()).find((task) => task.id === normalized) + } + + private resolveGroup(ref: ScheduledReportApiGroupRef | string): FormattedContact { + const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group') + const query = typeof ref === 'string' ? ref : isRecord(ref) ? asTrimmed(ref.name) : '' + const talker = typeof ref === 'string' ? '' : isRecord(ref) ? asTrimmed(ref.talker) : '' + if (!query && !talker) { + throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker') + } + if (talker) { + const direct = contacts.find( + (contact) => contact.m_nsUsrName === talker || contact.md5 === talker + ) + if (direct) return direct + throw new ScheduledReportApiError(404, 'group_not_found', '未找到 talker 对应的唯一群聊') + } + const resolution = resolveContact(query || talker, contacts, 'group') + if (resolution.ambiguous) { + throw new ScheduledReportApiError( + 409, + 'group_ambiguous', + '群聊名称匹配到多个群,请先确认目标群聊', + { + candidates: resolution.candidates + } + ) + } + if (!resolution.matched || !resolution.conversationId) { + throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊') + } + const contact = contacts.find((item) => item.md5 === resolution.conversationId) + if (!contact) throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊') + return contact + } + + private resolveTarget( + ref: ScheduledReportApiTargetRef | undefined, + group: FormattedContact + ): FormattedContact { + if (!ref) return group + if (!isRecord(ref) || ref.type !== 'wechat_group') { + throw new ScheduledReportApiError(400, 'invalid_target', 'target.type 必须是 wechat_group') + } + const talker = asTrimmed(ref.talker) + const name = asTrimmed(ref.name) + const target = this.resolveGroup({ talker, name }) + if (target.md5 !== group.md5) { + throw new ScheduledReportApiError(400, 'target_mismatch', '发送目标必须是选择的微信群') + } + return target + } + + private parseSchedule(value: unknown): { type: 'daily'; time: string } { + if (!isRecord(value) || value.type !== 'daily' || !asTrimmed(value.time)) { + throw new ScheduledReportApiError( + 400, + 'invalid_schedule', + 'schedule 目前只支持 { type: "daily", time: "HH:mm" }' + ) + } + const time = asTrimmed(value.time) + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) { + throw new ScheduledReportApiError(400, 'invalid_schedule', 'schedule.time 必须是 HH:mm') + } + return { type: 'daily', time } + } + + private parseRange(value: unknown): ScheduledReportRange { + const range = value === undefined ? 'yesterday' : value + if (typeof range !== 'string' || !REPORT_RANGES.has(range as ScheduledReportRange)) { + throw new ScheduledReportApiError(400, 'invalid_report_range', 'reportRange 不受支持') + } + return range as ScheduledReportRange + } + + private contactForTask(task: ScheduledReportTask): FormattedContact { + const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group') + const contact = contacts.find( + (item) => + item.md5 === task.group || + item.m_nsUsrName === task.target || + item.m_nsUsrName === task.group + ) + if (contact) return contact + return { + md5: task.group, + m_nsUsrName: task.target, + m_nsNickName: task.group, + type: 'group' + } + } + + private async findDuplicate( + candidate: { + group: string + target: string + scheduleTime: string + reportRange: ScheduledReportRange + enabled: boolean + }, + excludeTaskId?: string + ): Promise { + const tasks = await this.deps.service.listTasks() + return tasks.find( + (task) => + task.id !== excludeTaskId && + task.group === candidate.group && + task.target === candidate.target && + task.scheduleTime === candidate.scheduleTime && + task.reportRange === candidate.reportRange && + task.enabled === candidate.enabled + ) + } + + private toPublicTask(task: ScheduledReportTask): ScheduledReportApiTask { + const group = this.contactForTask(task) + const talker = task.target || group.m_nsUsrName + const name = group.m_nsNickName || task.group + return { + id: task.id, + name: task.name, + group: { talker, name }, + schedule: { type: 'daily', time: task.scheduleTime }, + reportRange: task.reportRange, + target: { type: 'wechat_group', talker, name }, + enabled: task.enabled, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ...(task.lastRunAt ? { lastRunAt: task.lastRunAt } : {}), + nextRunAt: task.nextRunAt + } + } +} diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss index d6acc75..72febcb 100644 --- a/src/renderer/src/styles/reports-core.scss +++ b/src/renderer/src/styles/reports-core.scss @@ -120,6 +120,7 @@ .report-scheduled-empty { display: grid; + flex: 1 1 auto; min-height: 260px; place-items: center; align-content: center; @@ -145,7 +146,24 @@ } .report-scheduled-executions { + display: flex; + max-height: clamp(160px, 30vh, 320px); + min-height: 0; + flex: 0 0 auto; + flex-direction: column; margin-top: 6px; + overflow: hidden; +} + +.report-scheduled-executions > .grid { + min-height: 0; + overflow-y: auto; +} + +.report-scheduled-page > .overflow-x-auto { + min-height: 0; + flex: 1 1 auto; + overflow: auto; } @media (max-width: 760px) { @@ -158,6 +176,18 @@ } } +@media (max-height: 720px) { + .report-scheduled-page { + gap: 12px; + padding-top: 16px; + padding-bottom: 24px; + } + + .report-scheduled-executions { + max-height: clamp(140px, 26vh, 220px); + } +} + .report-center-page { display: grid; grid-template-columns: 292px minmax(420px, 1fr) 304px; diff --git a/src/shared/local-api-test.ts b/src/shared/local-api-test.ts index 8667a7e..08c2c81 100644 --- a/src/shared/local-api-test.ts +++ b/src/shared/local-api-test.ts @@ -11,6 +11,16 @@ export const LOCAL_API_ENDPOINTS = { }, 'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] }, resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] }, + 'wechat-send-capability': { + method: 'GET', + path: '/api/v1/wechat-personal/send-capability', + queryKeys: [] + }, + 'scheduled-reports': { + method: 'GET', + path: '/api/v1/scheduled-reports', + queryKeys: [] + }, report: { method: 'POST', path: '/api/v1/report', queryKeys: [] }, 'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] }, 'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] }, diff --git a/src/shared/scheduled-report-api.ts b/src/shared/scheduled-report-api.ts new file mode 100644 index 0000000..0e21ba0 --- /dev/null +++ b/src/shared/scheduled-report-api.ts @@ -0,0 +1,67 @@ +import type { ScheduledReportRange, ScheduledReportExecutionStatus } from './scheduled-report' + +export type ScheduledReportApiSchedule = { + type: 'daily' + time: string +} + +export interface ScheduledReportApiGroupRef { + talker?: string + name?: string +} + +export interface ScheduledReportApiTargetRef { + type: 'wechat_group' + talker?: string + name?: string +} + +export interface ScheduledReportApiTask { + id: string + name: string + group: { + talker: string + name: string + } + schedule: ScheduledReportApiSchedule + reportRange: ScheduledReportRange + target: { + type: 'wechat_group' + talker: string + name: string + } + enabled: boolean + createdAt: string + updatedAt: string + lastRunAt?: string + nextRunAt: string +} + +export interface ScheduledReportApiCreateRequest { + name?: string + group: ScheduledReportApiGroupRef | string + schedule: ScheduledReportApiSchedule + reportRange?: ScheduledReportRange + target?: ScheduledReportApiTargetRef + enabled?: boolean +} + +export interface ScheduledReportApiUpdateRequest { + name?: string + group?: ScheduledReportApiGroupRef | string + schedule?: ScheduledReportApiSchedule + reportRange?: ScheduledReportRange + target?: ScheduledReportApiTargetRef + enabled?: boolean +} + +export interface ScheduledReportApiExecution { + id: string + taskId: string + startedAt: string + finishedAt?: string + status: ScheduledReportExecutionStatus + error?: string + message?: string + scheduledSlot?: string +} diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts index 75bea93..501e29b 100644 --- a/tests/integration/local-api-auth.test.ts +++ b/tests/integration/local-api-auth.test.ts @@ -264,6 +264,8 @@ describe('Local API authentication', () => { }) expect(response.status).toBe(204) expect(response.headers.get('access-control-allow-origin')).toBe(origin) + expect(response.headers.get('access-control-allow-methods')).toContain('PATCH') + expect(response.headers.get('access-control-allow-methods')).toContain('DELETE') expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization') }) diff --git a/tests/integration/scheduled-report-api.test.ts b/tests/integration/scheduled-report-api.test.ts new file mode 100644 index 0000000..1c7b816 --- /dev/null +++ b/tests/integration/scheduled-report-api.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + capability: { + supported: true, + ready: true, + status: 'ready' as const, + capabilities: { text: true, image: true, voice: true }, + senderStatus: {} as never, + message: '个人微信已准备好发送日报' + }, + contacts: [ + { + md5: 'tech-md5', + m_nsUsrName: 'tech@chatroom', + m_nsNickName: '技术交流群', + type: 'group' as const + }, + { + md5: 'product-md5', + m_nsUsrName: 'product@chatroom', + m_nsNickName: '产品交流群', + type: 'group' as const + } + ] +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/tracememo-scheduled-report-api' }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) +vi.mock('../../src/main/services/chat-service', () => ({ + isReady: () => true, + listContacts: () => fixture.contacts, + listMessages: () => [], + getGroupSnapshot: () => null, + listRecentChat: () => [], + resolveMd5: () => null +})) +vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({ + personalWechatCapabilityService: { + getPersonalWechatSendCapability: vi.fn(async () => fixture.capability) + } +})) +vi.mock('../../src/main/services/scheduled-report-service', () => ({ scheduledReportService: {} })) +vi.mock('../../src/main/services/agent-group-report-service', () => ({ + generateAgentGroupReport: vi.fn(async () => ({ success: true })) +})) +vi.mock('../../src/main/group-report-service', () => ({ + exportGroupReport: vi.fn(async () => ({ success: true })) +})) +vi.mock('../../src/main/services/agent-hub-service', () => ({ + agentHubService: { getStatus: () => ({}), testSend: vi.fn(async () => ({ success: true })) } +})) + +import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server' +import type { ScheduledReportTask } from '../../src/shared/scheduled-report' +import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service' + +const TOKEN = 'A'.repeat(43) +const handles: HttpServerHandle[] = [] +const originalContacts = fixture.contacts + +const makeTask = (overrides: Partial = {}): ScheduledReportTask => ({ + id: 'task-1', + name: '技术交流群 · 每日日报', + group: 'tech-md5', + target: 'tech@chatroom', + scheduleTime: '09:00', + reportRange: 'yesterday', + enabled: true, + createdAt: '2026-08-27T00:00:00.000Z', + updatedAt: '2026-08-27T00:00:00.000Z', + nextRunAt: '2026-08-28T01:00:00.000Z', + ...overrides +}) + +function baseUrl(handle: HttpServerHandle): string { + return `http://${handle.host}:${handle.port}` +} + +async function startFixture( + options: { tasks?: ScheduledReportTask[] } = {} +): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> { + const tasks = options.tasks || [] + const executions: Array> = [] + const service = { + listTasks: vi.fn(async () => tasks), + listExecutions: vi.fn(async (taskId?: string) => + taskId ? executions.filter((item) => item.taskId === taskId) : executions + ), + createTask: vi.fn(async (input: Record) => { + const created = makeTask({ + id: `task-${tasks.length + 1}`, + name: String(input.name), + group: String(input.group), + target: String(input.target), + scheduleTime: String(input.scheduleTime), + reportRange: input.reportRange as ScheduledReportTask['reportRange'], + enabled: Boolean(input.enabled) + }) + tasks.push(created) + return { success: true, data: created } + }), + updateTask: vi.fn(async (id: string, input: Record) => { + const current = tasks.find((item) => item.id === id) + if (!current) return { success: false, error: '未找到定时日报任务' } + const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' } + tasks[tasks.indexOf(current)] = updated + return { success: true, data: updated } + }), + deleteTask: vi.fn(async (id: string) => { + const index = tasks.findIndex((item) => item.id === id) + if (index < 0) return { success: false, error: '未找到定时日报任务' } + tasks.splice(index, 1) + return { success: true, data: { deletedId: id } } + }), + setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => { + const current = tasks.find((item) => item.id === id) + if (!current) return { success: false, error: '未找到定时日报任务' } + const updated = { ...current, enabled } + tasks[tasks.indexOf(current)] = updated + return { success: true, data: updated } + }), + runScheduledReportNow: vi.fn(async (id: string) => { + const execution = { + id: `execution-${executions.length + 1}`, + taskId: id, + startedAt: '2026-08-27T01:00:00.000Z', + finishedAt: '2026-08-27T01:01:00.000Z', + status: 'success' as const, + message: '日报生成成功,微信发送成功' + } + executions.push(execution) + return { success: true, data: execution } + }) + } + const handle = await startHttpServer('127.0.0.1', 0, { + tokenProvider: () => TOKEN, + scheduledReportService: service, + scheduledReportCapabilityProvider: async () => fixture.capability, + scheduledReportContactsProvider: () => fixture.contacts, + scheduledReportDatabaseReadyProvider: () => true, + scheduledReportPlatform: 'darwin' + }) + handles.push(handle) + return { handle, service } +} + +const auth = { Authorization: `Bearer ${TOKEN}` } + +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())) + fixture.contacts = originalContacts + fixture.capability = { + ...fixture.capability, + ready: true, + status: 'ready', + message: '个人微信已准备好发送日报' + } +}) + +describe('scheduled report Local HTTP API', () => { + it('requires authentication and exposes capability plus CRUD lifecycle', async () => { + const unauthenticated = await startFixture() + expect( + (await fetch(`${baseUrl(unauthenticated.handle)}/api/v1/scheduled-reports`)).status + ).toBe(401) + + const { handle, service } = await startFixture() + const create = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group: { name: '技术交流群' }, + schedule: { type: 'daily', time: '09:00' }, + reportRange: 'yesterday' + }) + }) + expect(create.status).toBe(201) + const created = await create.json() + expect(created).toMatchObject({ created: true, task: { group: { talker: 'tech@chatroom' } } }) + const taskId = created.task.id + + const list = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, { headers: auth }) + expect(await list.json()).toMatchObject({ count: 1, tasks: [{ id: taskId }] }) + const get = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, { + headers: auth + }) + expect((await get.json()).task.id).toBe(taskId) + + const update = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, { + method: 'PATCH', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ schedule: { type: 'daily', time: '10:00' } }) + }) + expect((await update.json()).task.schedule.time).toBe('10:00') + await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/disable`, { + method: 'POST', + headers: auth + }) + await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/enable`, { + method: 'POST', + headers: auth + }) + const run = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/run`, { + method: 'POST', + headers: auth + }) + expect(await run.json()).toMatchObject({ success: true, execution: { status: 'success' } }) + const executions = await fetch( + `${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/executions`, + { headers: auth } + ) + expect((await executions.json()).executions).toHaveLength(1) + const capability = await fetch(`${baseUrl(handle)}/api/v1/wechat-personal/send-capability`, { + headers: auth + }) + expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } }) + expect(service.createTask).toHaveBeenCalledOnce() + }) + + it('rejects unsupported capability, ambiguous groups, and duplicate tasks', async () => { + fixture.capability = { + ...fixture.capability, + ready: false, + status: 'needs_binding', + message: '请先绑定个人微信' + } + const first = await startFixture() + const blocked = await fetch(`${baseUrl(first.handle)}/api/v1/scheduled-reports`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } }) + }) + expect(blocked.status).toBe(409) + expect(await blocked.json()).toMatchObject({ error: 'wechat_not_ready' }) + + fixture.capability = { ...fixture.capability, ready: true, status: 'ready' } + fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' })) + const ambiguous = await startFixture() + const ambiguousResponse = await fetch(`${baseUrl(ambiguous.handle)}/api/v1/scheduled-reports`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ group: '重复群', schedule: { type: 'daily', time: '09:00' } }) + }) + expect(ambiguousResponse.status).toBe(409) + expect(await ambiguousResponse.json()).toMatchObject({ error: 'group_ambiguous' }) + }) +}) diff --git a/tests/unit/scheduled-report-api-service.test.ts b/tests/unit/scheduled-report-api-service.test.ts new file mode 100644 index 0000000..9df9902 --- /dev/null +++ b/tests/unit/scheduled-report-api-service.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat' +import type { ScheduledReportTask } from '../../src/shared/scheduled-report' +import { + ScheduledReportApiService, + type ScheduledReportApiDependencies +} from '../../src/main/services/scheduled-report-api-service' + +type ApiTestService = ScheduledReportApiDependencies['service'] & { + createTask: ReturnType + updateTask: ReturnType +} +type ApiTestResult = { + api: ScheduledReportApiService + service: ApiTestService +} + +const contacts = [ + { + md5: 'tech-md5', + m_nsUsrName: 'tech@chatroom', + m_nsNickName: '技术交流群', + type: 'group' as const + }, + { + md5: 'product-md5', + m_nsUsrName: 'product@chatroom', + m_nsNickName: '产品交流群', + type: 'group' as const + } +] + +const task = (overrides: Partial = {}): ScheduledReportTask => ({ + id: 'task-1', + name: '技术交流群 · 每日日报', + group: 'tech-md5', + target: 'tech@chatroom', + scheduleTime: '09:00', + reportRange: 'yesterday', + enabled: true, + createdAt: '2026-08-27T00:00:00.000Z', + updatedAt: '2026-08-27T00:00:00.000Z', + nextRunAt: '2026-08-28T01:00:00.000Z', + ...overrides +}) + +const capability = ( + status: PersonalWechatSendCapability['status'] +): PersonalWechatSendCapability => ({ + supported: status !== 'unsupported', + ready: status === 'ready', + status, + capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' }, + senderStatus: {} as PersonalWechatSendCapability['senderStatus'], + message: status === 'ready' ? 'ready' : '请先配置微信' +}) + +function makeApi( + options: { capability?: PersonalWechatSendCapability; tasks?: ScheduledReportTask[] } = {} +): ApiTestResult { + const tasks = options.tasks || [] + const service = { + listTasks: vi.fn(async () => tasks), + listExecutions: vi.fn(async () => []), + createTask: vi.fn(async (input: Record) => { + const created = task({ + id: `task-${tasks.length + 1}`, + name: String(input.name), + group: String(input.group), + target: String(input.target), + scheduleTime: String(input.scheduleTime), + reportRange: input.reportRange as ScheduledReportTask['reportRange'], + enabled: Boolean(input.enabled) + }) + tasks.push(created) + return { success: true, data: created } + }), + updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })), + deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })), + setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({ + success: true, + data: task({ id, enabled }) + })), + runScheduledReportNow: vi.fn(async (id: string) => ({ + success: true, + data: { + id: 'execution-1', + taskId: id, + startedAt: '2026-08-27T01:00:00.000Z', + status: 'success' as const + } + })) + } + return { + api: new ScheduledReportApiService({ + service, + getCapability: async () => options.capability || capability('ready'), + listContacts: () => contacts, + isDatabaseReady: () => true, + platform: 'darwin' + }), + service + } +} + +describe('ScheduledReportApiService', () => { + it('resolves a unique group and maps the public create request to the existing service', async () => { + const { api, service } = makeApi() + const result = await api.create({ + group: { name: '技术交流群' }, + schedule: { type: 'daily', time: '09:00' }, + reportRange: 'yesterday' + }) + expect(result).toMatchObject({ + name: '技术交流群 · 每日日报', + group: { talker: 'tech@chatroom', name: '技术交流群' }, + schedule: { type: 'daily', time: '09:00' }, + target: { type: 'wechat_group', talker: 'tech@chatroom' } + }) + expect(service.createTask).toHaveBeenCalledWith( + expect.objectContaining({ group: 'tech-md5', target: 'tech@chatroom' }) + ) + }) + + it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)( + 'rejects creation before touching the task service for capability state %s', + async (status) => { + const { api, service } = makeApi({ capability: capability(status) }) + await expect( + api.create({ + group: '技术交流群', + schedule: { type: 'daily', time: '09:00' } + }) + ).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 }) + expect(service.createTask).not.toHaveBeenCalled() + } + ) + + it('enforces the macOS platform gate even if a provider reports ready', async () => { + const { service } = makeApi() + const api = new ScheduledReportApiService({ + service, + getCapability: async () => capability('ready'), + listContacts: () => contacts, + isDatabaseReady: () => true, + platform: 'win32' + }) + await expect( + api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } }) + ).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 }) + expect(service.createTask).not.toHaveBeenCalled() + }) + + it('returns candidates for ambiguous group names and rejects duplicate tasks', async () => { + const ambiguousContacts = contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' })) + const { service: ambiguousService } = makeApi() + const ambiguousApi = new ScheduledReportApiService({ + service: ambiguousService, + getCapability: async () => capability('ready'), + listContacts: () => ambiguousContacts, + isDatabaseReady: () => true, + platform: 'darwin' + }) + await expect( + ambiguousApi.create({ group: '重复群', schedule: { type: 'daily', time: '09:00' } }) + ).rejects.toMatchObject({ code: 'group_ambiguous', status: 409 }) + + const duplicateApi = makeApi({ tasks: [task()] }).api + await expect( + duplicateApi.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } }) + ).rejects.toMatchObject({ code: 'duplicate', status: 409 }) + }) + + it('updates a changed group target together when target is omitted', async () => { + const existing = task() + const { api, service } = makeApi({ tasks: [existing] }) + vi.mocked(service.updateTask).mockResolvedValue({ + success: true, + data: task({ group: 'product-md5', target: 'product@chatroom', name: '产品日报' }) + }) + const result = await api.update('task-1', { + group: { name: '产品交流群' }, + name: '产品日报' + }) + expect(result.group).toMatchObject({ talker: 'product@chatroom', name: '产品交流群' }) + expect(service.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ group: 'product-md5', target: 'product@chatroom' }) + ) + }) + + it('serializes concurrent creates so only one identical task is created', async () => { + const { api, service } = makeApi() + const request = { + group: '技术交流群', + schedule: { type: 'daily' as const, time: '09:00' }, + reportRange: 'yesterday' as const + } + const results = await Promise.allSettled([api.create(request), api.create(request)]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1) + expect(service.createTask).toHaveBeenCalledOnce() + }) +})