feat: 定时日报并入自动化,统一规则管理与发送链路

发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
This commit is contained in:
电摇小子
2026-09-28 17:11:50 +07:00
parent c835d5c570
commit 4d5c6459fe
49 changed files with 6436 additions and 4863 deletions
+216 -16
View File
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
})
})
/*
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
*
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
*/
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom']
})
const store = new AutomationRuleStore(deps(root))
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: false,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]',
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: true
}
})
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.notifyScope).toBe('all')
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
* 只勾了一部分时无法无损映射 —— 这时候:
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
*/
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
const store = new AutomationRuleStore(deps(root))
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave?.enabled).toBe(false)
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 二次勾选逐字保留:只通知 b,不多不少。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'needs_review',
outcome: 'lossless_selected_groups',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
@@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => {
})
})
/**
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
*
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
*/
describe('AutomationRuleStore · 定时日报迁移', () => {
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
const backupFile = (): string =>
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
const resolver = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
}
const writeLegacyTasks = (value: unknown): void => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeJsonSync(legacyTasksFile(), value)
}
const legacyTasks = (): unknown[] => [
{
id: 'scheduled_report_a',
name: 'A 群日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
},
{
id: 'scheduled_report_b',
name: 'B 群日报',
group: 'product-md5',
target: 'product@chatroom',
scheduleTime: '10:00',
reportRange: 'today',
enabled: false,
createdAt: '2026-08-02T01:00:00.000Z',
updatedAt: '2026-08-02T01:00:00.000Z'
},
{
id: 'scheduled_report_c',
name: '发到别的群',
group: 'tech-md5',
target: 'product@chatroom',
scheduleTime: '11:00',
reportRange: 'today',
enabled: true,
createdAt: '2026-08-03T01:00:00.000Z',
updatedAt: '2026-08-03T01:00:00.000Z'
}
]
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'scheduled-reports'))
})
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
const summary = store.getLastScheduledReportMigration()
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
// 「发到别的群」那条:不自动改写目标,且必须停用。
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
expect(needsReview?.enabled).toBe(false)
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
})
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
writeLegacyTasks(legacyTasks())
const first = new AutomationRuleStore(deps(root))
first.setLegacyConversationResolver(resolver)
expect(
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
).toHaveLength(3)
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
const second = new AutomationRuleStore(deps(root))
second.setLegacyConversationResolver(resolver)
const ids = second
.listRules()
.filter((rule) => rule.ruleType === 'scheduled_report')
.map((rule) => rule.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
// 已迁移标记落盘 ⇒ 不再有待迁任务。
expect(second.hasPendingScheduledReportMigration()).toBe(false)
})
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(true)
expect(store.getLastScheduledReportMigration()).toBeNull()
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
})
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
const tasks = legacyTasks()
writeLegacyTasks(tasks)
const store = new AutomationRuleStore(deps(root))
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
store.setLegacyConversationResolver(resolver)
store.listRules()
expect(fs.pathExistsSync(backupFile())).toBe(true)
const backup = fs.readJsonSync(backupFile()) as {
summary: { migrated: number }
legacyTasks: unknown[]
}
expect(backup.summary.migrated).toBe(3)
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
expect(backup.legacyTasks).toEqual(tasks)
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
})
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
expect(store.getLastScheduledReportMigration()).toBeNull()
expect(fs.pathExistsSync(backupFile())).toBe(false)
})
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
@@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => {
expect(harness.executions).toHaveLength(0)
})
/*
* 「通知范围」=历史「退群监控 → 通知群聊」的第二层。
* 缺了它,规则一启用就等于给全部已监控群聊发通知 —— 就是那个被报回来的问题。
*/
it('范围 = 仅选中的群聊:勾中的群照常发送', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['room@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
expect(harness.executions).toHaveLength(1)
})
it('范围 = 仅选中的群聊:没勾的群不发,且不创建 execution', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['other@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
// 被范围排除**不是**"一次失败的执行",所以不能留失败记录吓人。
expect(harness.executions).toHaveLength(0)
})
it('范围 = 全部已监控群聊:不筛群,照常发送', async () => {
const harness = createHarness({ config: { notifyScope: 'all', notifyRoomIds: [] } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
/*
* 范围判定必须在幂等**之前**:否则被排除的群会白白占用该事件的幂等位,
* 用户随后补勾了那个群,同一个事件反而再也发不出去。
*/
it('范围外不占用幂等位:补勾之后同一事件仍能发出', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: [] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
harness.setRule(
createDefaultLeaveNotificationRule(NOW, {
notifyScope: 'selected',
notifyRoomIds: ['room@chatroom']
})
)
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
it('缺少 eventId 时直接跳过,绝不发送', async () => {
const harness = createHarness()
@@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
*/
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
const harness = createHarness({
@@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「不同事件」:同群两个成员先后退出就是两条通知。
* 不同事件:同群两个成员先后退出就是两条通知。
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
*/
it('不同 eventId 各自执行一次,不被合并', async () => {
+54 -22
View File
@@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
]
/**
* 构造一份合法配置。
*
* 本文件关心的是**目标解析**,通知范围(`notifyScope`)与之正交,
* 所以在这里统一补默认值 —— 契约再新增字段时也只改这一处,
* 不必去逐个补十几处字面量(那才是漏字段的根源)。
*/
function config(overrides: Partial<LeaveNotificationConfig> = {}): LeaveNotificationConfig {
return {
target: { type: 'source_chat' },
template: '',
notifyScope: 'all',
notifyRoomIds: [],
...overrides
}
}
function resolve(
config: LeaveNotificationConfig,
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
@@ -59,7 +76,7 @@ function resolve(
describe('leaveNotificationTarget · 四种目标', () => {
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
const result = resolve({ target: { type: 'source_chat' }, template: '' })
const result = resolve(config())
expect(result).toEqual({
ok: true,
@@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
// 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称,
// 比「当前群聊」更具体,且**仍然不是** roomId。
const withNickname = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts,
selfWxid: 'wxid_self'
})
expect(withNickname.ok).toBe(true)
if (withNickname.ok) {
expect(withNickname.target.recipient.name).toBe('测试群')
expect(withNickname.target.displayName).toBe('测试群')
}
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.target.recipient.name).toBe('当前群聊')
expect(result.target.displayName).toBe('发生退群事件的群聊')
// 群名与联系人表**都**拿不到 ⇒ 退到中性称呼。
// 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。
const fallback = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts: [],
selfWxid: 'wxid_self'
})
expect(fallback.ok).toBe(true)
if (fallback.ok) {
expect(fallback.target.recipient.name).toBe('当前群聊')
expect(fallback.target.displayName).toBe('发生退群事件的群聊')
// `recipient.id` 本来就该是真实会话 id(发送要用它),
// 这条断言约束的是**显示名**:不许把 id 当称呼给人看。
expect(fallback.target.recipient.name).not.toContain('@chatroom')
expect(fallback.target.displayName).not.toContain('@chatroom')
}
})
it('当前群聊在缺少 conversationId 时直接失败', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
config: config(),
event: { ...EVENT, conversationId: '' },
contacts,
selfWxid: 'wxid_self'
@@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('发给自己 = 当前登录账号的真实 wxid', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: 'wxid_me' })
expect(result).toEqual({
ok: true,
@@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
*/
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' })
expect(result.ok).toBe(false)
if (!result.ok) {
@@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
const result = resolve(config({ target: { type: 'file_transfer' } }))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('指定好友用稳定 id,显示名用备注', () => {
const result = resolve({
target: { type: 'contact', contactId: 'wxid_friend' },
template: ''
})
const result = resolve(
config({ target: { type: 'contact', contactId: 'wxid_friend' } })
)
expect(result).toEqual({
ok: true,
@@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => {
describe('leaveNotificationTarget · 失效与非法', () => {
it('联系人不在了 → 失败,且不往别处发', () => {
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId: 'wxid_gone' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
})
it('还没选好友 → 失败并提示重新选择', () => {
const result = resolve({ target: { type: 'contact' }, template: '' })
const result = resolve(config({ target: { type: 'contact' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
@@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
WECHAT_FILE_HELPER_USERNAME,
'wxid_self'
]) {
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId } }))
expect(result.ok).toBe(false)
}
})
@@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
})
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
const result = resolve({
target: { type: 'webhook' as never },
template: ''
})
const result = resolve(config({ target: { type: 'webhook' as never } }))
expect(result.ok).toBe(false)
})
})
+60 -30
View File
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import {
ScheduledReportApiService,
type ScheduledReportApiDependencies
@@ -60,36 +66,60 @@ function makeApi(
): ApiTestResult {
const tasks = options.tasks || []
const service = {
listTasks: vi.fn(async () => tasks),
listExecutions: vi.fn(async () => []),
createTask: vi.fn(async (input: Record<string, unknown>) => {
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
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (_taskId?: string): Promise<ScheduledReportExecution[]> => []
),
createTask: vi.fn(
async (input: ScheduledReportCreateInput): Promise<ScheduledReportResult<ScheduledReportTask>> => {
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 (
_taskId: string,
_input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: tasks[0]
})
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => ({
success: true,
data: { deletedId: id }
})
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: task({ id, enabled })
})
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => ({
success: true,
data: {
id: 'execution-1',
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
status: 'success' as const
}
})
)
}
return {
api: new ScheduledReportApiService({
@@ -0,0 +1,296 @@
import { describe, expect, it } from 'vitest'
import {
planScheduledReportMigration,
planScheduledReportMigrationBatch,
scheduledReportMigrationRuleId,
type LegacyScheduledReportTask
} from '../../src/main/services/scheduled-report-migration'
import { SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE } from '../../src/shared/automation'
/**
* 旧「定时日报任务」→ `AutomationRule(ruleType='scheduled_report')` 的映射器。
*
* 这是本次迁移里**唯一会改变用户既有行为**的地方,所以每条断言都对应一条产品约束:
* - Rule ID 优先复用旧 taskId(不许拿显示名当稳定 ID);
* - 配置无损映射(时间 / 范围 / 消息类型 / 模板 / 成员名口径 / 超时);
* - 只有"能证明是同一个群"才无损映射成 `source_chat`;
* 历史行为事实上支持"生成 A 群日报 → 发 B 群",新产品不支持 ⇒ 必须 `targetNeedsReview`
* 且 **enabled = false**,绝不自动改写、绝不静默丢掉目标;
* - 解析不到群标识时**保留原值**,运行期如实失败,而不是在这里猜一个群;
* - 批次按 id 去重 ⇒ 幂等(同一份文件跑两次结果一致)。
*/
const NOW = Date.parse('2026-08-27T10:00:00+08:00')
/** 假联系人表:md5 / 群名 → roomId。 */
const CONTACTS: Record<string, string> = {
'tech-md5': 'tech@chatroom',
'product-md5': 'product@chatroom',
'技术交流群': 'tech@chatroom'
}
/**
* 与 main 侧 `setLegacyConversationResolver` 注入的真实解析器**同口径**:
* - 以 `@chatroom` 结尾 ⇒ 原样返回(旧 UI 的 `target` 存的就是 roomId,
* 而 `chat.resolveMd5` 对 roomId 是查不到的,所以这条 pass-through 是必需的);
* - 否则按 md5 / 群名查联系人表。
*/
const resolve = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return CONTACTS[key]
}
const task = (overrides: LegacyScheduledReportTask = {}): LegacyScheduledReportTask => ({
id: 'scheduled_report_old_1',
name: '技术交流群 · 每日日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:15',
reportRange: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600,
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-20T01:00:00.000Z',
...overrides
})
describe('定时日报迁移 · Rule ID', () => {
it('优先原样复用旧 taskId(不拿显示名当稳定 ID)', () => {
expect(scheduledReportMigrationRuleId(task())).toBe('scheduled_report_old_1')
// 改名字不影响 id —— 显示名不是身份。
expect(scheduledReportMigrationRuleId(task({ name: '换个名字' }))).toBe(
'scheduled_report_old_1'
)
})
it('旧 id 缺失时退化成由稳定字段派生的确定性 id(重复迁移不产生新规则)', () => {
const broken = task({ id: undefined })
const first = scheduledReportMigrationRuleId(broken)
const second = scheduledReportMigrationRuleId({ ...broken })
expect(first).toMatch(/^scheduled-report:[0-9a-f]{32}$/)
expect(second).toBe(first)
})
it('id 全是空白字符时同样走派生路径', () => {
expect(scheduledReportMigrationRuleId(task({ id: ' ' }))).toMatch(/^scheduled-report:/)
})
})
describe('定时日报迁移 · 逐字段无损映射', () => {
it('配置字段一个不丢(不是"看起来差不多")', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.name).toBe('技术交流群 · 每日日报')
expect(plan.enabled).toBe(true)
expect(plan.config.schedule).toEqual({ time: '09:15' })
expect(plan.config.report).toEqual({
// 来源群存**稳定会话 id**:旧值 'tech-md5' 被解析成 roomId。
sourceConversationId: 'tech@chatroom',
range: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600
})
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
expect(plan.config.legacyTarget).toBeUndefined()
// 来源群被改写过 ⇒ 留下原文方便用户核对。
expect(plan.config.legacySourceGroup).toBe('tech-md5')
})
it('createdAt / updatedAt 继承旧值(ISO → 毫秒),非法值回落迁移时刻', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.createdAt).toBe(Date.parse('2026-08-01T01:00:00.000Z'))
expect(plan.updatedAt).toBe(Date.parse('2026-08-20T01:00:00.000Z'))
const broken = planScheduledReportMigration(
task({ createdAt: '不是时间', updatedAt: undefined }),
resolve,
NOW
)
expect(broken.createdAt).toBe(NOW)
expect(broken.updatedAt).toBe(NOW)
})
it('继承调度游标(lastRunAt / lastScheduledSlot),避免迁移后重复补跑', () => {
const slot = new Date('2026-08-26T09:15:00+08:00').toISOString()
const plan = planScheduledReportMigration(
task({ lastRunAt: '2026-08-26T01:15:00.000Z', lastScheduledSlot: slot }),
resolve,
NOW
)
expect(plan.config.lastRunAt).toBe('2026-08-26T01:15:00.000Z')
expect(plan.config.lastScheduledSlot).toBe(slot)
})
it('脏值一律收敛到合法默认值,绝不落一条跑不起来的规则', () => {
const plan = planScheduledReportMigration(
task({
scheduleTime: '99:99',
reportRange: '一个不存在的范围',
messageTypes: ['不存在的类型'],
templateId: '不存在的模板',
memberNameMode: '不存在的口径',
timeoutSeconds: 999999
}),
resolve,
NOW
)
expect(plan.config.schedule.time).toBe('18:30')
expect(plan.config.report.range).toBe('today')
expect(plan.config.report.messageTypes).toEqual(['text'])
expect(plan.config.report.templateId).toBe('v1')
expect(plan.config.report.memberNameMode).toBe(SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE)
expect(plan.config.report.timeoutSeconds).toBe(1800)
})
it('名字缺失时回落到「定时日报」,不会留一条空名字的规则', () => {
expect(planScheduledReportMigration(task({ name: ' ' }), resolve, NOW).name).toBe('定时日报')
})
})
describe('定时日报迁移 · 发送目标(唯一会改变行为的地方)', () => {
it('target 为空 ⇒ 旧系统本就回落到来源群,等价于 source_chat', () => {
const plan = planScheduledReportMigration(task({ target: '' }), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('target 与 group 文本相同 ⇒ 无损映射(不依赖能否解析)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech-md5' }),
() => undefined,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('两者都解析到同一个 roomId ⇒ 无损映射(旧 UI 的常态:group=md5, target=roomId)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
// 这条无损判定**依赖**解析器对 `@chatroom` 的 pass-through:
// 少了它,旧 UI 里每一组 (md5, roomId) 都会退化成 needs_review,
// 等于把所有用户的定时日报全部停用。
expect(resolve('tech@chatroom')).toBe('tech@chatroom')
})
it('target 指向**另一个群** ⇒ needs_review + 强制停用 + 保留旧目标原文', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'product@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
// 不能自动改写目标,也不能在用户确认前就被定时发出去。
expect(plan.enabled).toBe(false)
expect(plan.config.targetNeedsReview).toBe(true)
expect(plan.config.legacyTarget).toBe('product@chatroom')
})
it('target 无法解析 ⇒ 一律 needs_review(拿不到证据就不自动改写)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'unknown-id' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
expect(plan.enabled).toBe(false)
expect(plan.config.legacyTarget).toBe('unknown-id')
})
it('needs_review 的规则即使旧任务是启用的也必须停用', () => {
const plan = planScheduledReportMigration(
task({ target: 'product@chatroom', enabled: true, group: 'tech-md5' }),
resolve,
NOW
)
expect(plan.enabled).toBe(false)
})
it('来源群解析不到 ⇒ 保留原值,不在迁移期猜一个群', () => {
const plan = planScheduledReportMigration(task({ group: '未知群', target: '' }), resolve, NOW)
// 原值留着 —— 运行期会如实失败,比"悄悄换一个群发"安全得多。
expect(plan.config.report.sourceConversationId).toBe('未知群')
expect(plan.config.legacySourceGroup).toBeUndefined()
})
})
describe('定时日报迁移 · 批次幂等', () => {
const tasks: LegacyScheduledReportTask[] = [
task({ id: 'scheduled_report_a', name: 'A', group: 'tech-md5', target: 'tech@chatroom' }),
task({ id: 'scheduled_report_b', name: 'B', group: 'product-md5', target: 'product@chatroom' }),
task({ id: 'scheduled_report_c', name: 'C', group: 'tech-md5', target: 'product@chatroom' })
]
it('3 条旧任务 ⇒ 3 条规则,并给出判定分布', () => {
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(plans.map((plan) => plan.ruleId)).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
})
it('重复执行结果完全一致(幂等,不是"看起来没多")', () => {
const first = planScheduledReportMigrationBatch(tasks, resolve, NOW)
const second = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(second).toEqual(first)
})
it('同一 id 出现两次只迁一条,并如实计入 duplicatesSkipped', () => {
const { plans, summary } = planScheduledReportMigrationBatch(
[tasks[0], task({ id: 'scheduled_report_a', name: '重复' })],
resolve,
NOW
)
expect(plans).toHaveLength(1)
expect(plans[0].name).toBe('A')
expect(summary.total).toBe(2)
expect(summary.migrated).toBe(1)
expect(summary.duplicatesSkipped).toBe(1)
})
it('文件里混进非对象元素时跳过而不是崩掉', () => {
const { plans } = planScheduledReportMigrationBatch(
[tasks[0], null as unknown as LegacyScheduledReportTask, 'x' as unknown as LegacyScheduledReportTask],
resolve,
NOW
)
expect(plans).toHaveLength(1)
})
it('空文件 ⇒ 0 条规则(全新安装路径)', () => {
const { plans, summary } = planScheduledReportMigrationBatch([], resolve, NOW)
expect(plans).toHaveLength(0)
expect(summary.migrated).toBe(0)
})
})
File diff suppressed because it is too large Load Diff