Flutter-Cursor三件套实战指南
基于 Flutter 的 Cursor 三件套实战:借鉴 GitHub 顶流规范,告别 AI 竞态、性能与组件复用的人肉死盯时代
贯穿案例:一个基于 Flutter 3.x、Dart 3、Riverpod、
device_calendar、googleapis和 SQLite 的智能日程与跨端同步 App。
用户编辑一场每周重复的会议,点击“保存”。看起来只是一个按钮,背后却涉及三个存储系统、两种网络状态、一组时区语义,以及随时可能被再次点击的界面。
这篇指南要解决的,就是如何把这些容易遗漏的工程约束,变成 Cursor 每次开发都能读取、执行和验证的项目资产。
先明确规范依据:**MDC 加载机制以 Cursor 官方文档为准,Skill 结构以 Agent Skills 规范为准,GitHub 社区提供组织方式和规则素材。**下文的日历同步契约是针对本案例设计的工程模板,不声称高星仓库已经验证过这套业务实现,也不把提示词当作编译器。
一、引言:为什么用 AI 写 Flutter 像在“伺候实习生”?
你让 Cursor 实现一个“新建日程”页面,它很快交付:
ElevatedButton(
onPressed: () async {
await saveToDatabase();
await saveToDeviceCalendar();
await saveToGoogleCalendar();
},
child: Text('保存'),
)
代码短得让人舒适,后续问题却长得让人疲惫:
- 项目已有
AppButton,它又造了一套按钮样式。 - 用户选择一天,整个月视图跟着 rebuild。
- 连点两次,系统日历出现两条相同会议。
- Google 请求超时,页面显示失败,但远端其实已经创建成功。
- 为了“加回滚”,AI 又把已经保存的本地日程删了。
- 上海上午九点的会议,换到另一台设备后错了八小时。
这不是一句“请遵循最佳实践”能够解决的。
AI 容易生成在局部看来合理的代码,却不知道项目中哪些条件不可破坏:公共组件在哪里、哪个数据源负责保存用户意图、什么叫同一次保存,以及“同步失败”是否等于“保存失败”。
因此,我们把单轮 Prompt 改造成三层上下文防线:
- AGENTS.md:告诉 AI 项目如何运转。
- Rules:告诉 AI 当前这类文件允许怎样写。
- Skill:告诉 AI 写完之后怎样证明没有写错。
Cursor 支持根目录与子目录的 AGENTS.md,以及按文件路径加载的 .cursor/rules/*.mdc。这些规则进入 Agent 上下文,提供持续约束。Cursor Rules 官方文档
真正减少人肉盯代码,还需要最后一步:把可机械验证的要求落实到分析器、测试和 CI。
二、架构总览:Flutter 日历 App 的三层防线
2.1 项目宪法、流水线规程、质检 SOP
| 层次 | 实际载体路径 | 匹配或触发机制 | 负责的问题 |
|---|---|---|---|
| 项目宪法 | AGENTS.md | 项目或目录级指令 | 技术栈、分层、组件入口、离线优先、隐私 |
| UI 规程 | .cursor/rules/flutter-widgets.mdc | 匹配 presentation 文件 | 公共组件、const、订阅粒度、UI 生命周期 |
| 同步规程 | .cursor/rules/calendar-sync.mdc | 匹配 data、repository 文件 | 幂等、互斥、乱序、三方 ID、持久化任务 |
| 质检 SOP | .cursor/skills/audit-calendar-sync/SKILL.md | /audit-calendar-sync | 故障注入、补偿、时区、权限、回归验证 |
这里有两个容易抄错的格式问题:
AGENTS.md是普通 Markdown,不需要 MDC Frontmatter。skills/*.md可以作为概念简称,但本文采用 Cursor 可发现的.cursor/skills/<name>/SKILL.md。Skill 使用自己的name、description元数据,不套用 MDC 的globs配置。Cursor Skills 文档、Agent Skills 规范
建议目录如下:
.
├── AGENTS.md
├── .cursor/
│ ├── rules/
│ │ ├── flutter-widgets.mdc
│ │ └── calendar-sync.mdc
│ └── skills/
│ └── audit-calendar-sync/
│ └── SKILL.md
├── lib/
│ ├── design_system/
│ │ ├── design_system.dart
│ │ ├── app_button.dart
│ │ └── app_date_time_picker.dart
│ └── features/calendar/
│ ├── domain/
│ ├── presentation/
│ ├── repository/
│ └── data/
├── test/calendar/
└── integration_test/
2.2 先把“三方保存”定义清楚
虽然业务通常把它叫作“双写同步”,本案例实际涉及三个一致性域:
这里采用以下业务契约:
点击保存后,先在 SQLite 中原子提交“日程的新版本”和“待同步任务”;随后尝试写入系统日历,并异步推送 Google。界面分别展示本地保存、系统日历、Google 三种状态。
**SQLite 提交成功,不等于三方已经全部成功。**系统日历和 Google 不参与 SQLite 事务,不能把三个 await 当成分布式事务。
还要提前排除一种隐蔽重复:
如果系统日历的目标本身就是同一个 Google 账户下的日历,操作系统可能已经替你同步。此时再通过 REST 创建同一事件,会形成两条写入路径。
本方案默认使用经平台能力确认、不会再次上传到同一 Google 目标的系统日历投影。不能保证这一点时,必须选择单一写入负责人,不能继续无条件双写。
三、GitHub 级实战配置:针对四大痛点的落地模板
以下四份配置可以完整保存到对应路径。
Dart 示例中的 AppButton、Store 和 Gateway 是项目接口;示例展示关键实现与契约,不伪装成复制后即可运行的完整 App。实际接入应以仓库中锁定的依赖版本和已有组件签名为准。
3.1 AGENTS.md:建立项目全局大局观
全局文件只放所有任务都应该知道的信息。详细同步算法留给局部规则和实现文档。
# 智能日程与跨端同步 App:项目约定
## 技术栈与结构
- 使用项目锁定的 Flutter 3.x、Dart 3 和 Riverpod 版本。
- 系统日历通过 device_calendar,Google Calendar 通过 googleapis。
- SQLite 保存用户意图、版本、同步任务与映射。
- presentation → repository → data;domain 不依赖 Flutter UI。
- 修改前读取 pubspec.yaml、相关接口及现有测试,不猜测第三方 API。
## 公共组件
- 公共组件入口:lib/design_system/design_system.dart。
- 保存按钮使用 AppButton;时间范围选择使用 AppDateTimePicker。
- 先检查真实参数和已有用例,再编写调用代码。
- 缺少能力时扩展公共组件并补测试,不在业务页面复制一套。
## 离线优先与同步
- SQLite 是本设备用户编辑意图的持久化来源。
- 日程变更与 Outbox 必须在同一 SQLite 事务提交。
- 系统日历和 Google 是独立同步目标,分别记录状态。
- 远端变更需通过版本与冲突流程合并,不盲目覆盖本地待同步修改。
- Google 超时、离线、401 不得导致已接受的本地日程被自动删除。
- 撤销是可恢复、带版本保护的补偿流程,不是假装跨系统原子回滚。
- 系统日历目标不得与 REST Google 写入形成未经管理的重复同步路径。
## 时间与重复事件
- 定时事件保存 UTC instant,同时保存 IANA timezone ID。
- 重复事件另外保留当地墙上时间、RRULE 和例外语义。
- 全天事件保存日期区间;结束日期为排他边界。
- 禁止手工加减 8 小时;禁止给本地字符串直接追加 Z。
- 明确编辑整组、单次或本次及以后;不得静默扩大编辑范围。
## 隐私与日志
- 不提交或输出 OAuth token、refresh token、Authorization header。
- 日志不得包含日程标题、描述、参会人邮箱或未经脱敏的完整响应。
- 日志使用随机追踪 ID、版本、目标类型、错误分类和重试次数。
- 测试只能使用合成日程及测试账户。
## Negative Constraints
- 不在 Widget 中直接操作 SQLite、device_calendar 或 CalendarApi。
- 不用按钮防抖替代业务幂等。
- 不把“请求取消”解释为“服务端没有执行”。
- 不声称未运行的测试通过。
## Bad
```dart
// 页面直接写三方,且错误地把失败等同于撤销。
await database.insert(event);
await deviceCalendar.create(event);
try {
await googleCalendar.insert(event);
} catch (_) {
await database.delete(event.id);
}
```
## Good
```dart
// Repository 内原子提交日程和 Outbox;Worker 分别收敛。
final receipt = await repository.save(command);
// receipt 表示本地已接受,不表示所有同步目标已完成。
```
## 验证
- 执行 dart format、flutter analyze 和相关 flutter test。
- 修改同步逻辑必须覆盖重复提交、乱序、重启恢复和失败分类。
- 报告实际运行命令、结果和未验证项。
这份文件的价值,是给后续代码一个稳定的判断依据:
Google 故障时,保护用户已经保存的数据,比制造“看起来一致”的删除操作更重要。
3.2 .cursor/rules/flutter-widgets.mdc:组件复用与性能
MDC 的 globs 用于匹配进入上下文的文件,多个模式可以逗号分隔。alwaysApply: false 避免把 UI 规则塞进所有任务;它不表示规则失效。MDC 格式与匹配说明
---
description: "约束 Flutter 日历 presentation 层的公共组件、Riverpod 订阅粒度和异步 UI 生命周期。"
globs: "lib/**/presentation/**/*.dart"
alwaysApply: false
---
# Flutter Widget Contract
## 执行前
1. 阅读 lib/design_system/design_system.dart 及目标组件定义。
2. 阅读当前页面使用的 Provider 和不可变状态类型。
3. 确认月视图、周视图、日期单元格各自需要哪些字段。
## Negative Constraints
- 业务 presentation 禁止直接创建 ElevatedButton、FilledButton、
TextButton,以及直接调用 showDatePicker/showTimePicker;
使用项目对应公共组件。原生封装仅允许在 design_system 内实现。
- 禁止通过别名、局部包装或复制组件绕过以上要求。
- 禁止月/周网格直接 watch 整个日历聚合状态。
- 订阅复合状态时必须使用 .select(),选择实际显示的不可变字段。
- 禁止 .select((state) => state) 这种无效选择。
- 禁止原地修改被 select 返回的 List、Map 或状态对象。
- 禁止在 build 中执行数据库查询、网络调用或 RRULE 大规模展开。
- 可编译期常量化的节点必须 const;动态数据不得硬改为 const。
- 禁止把 RepaintBoundary 当成阻止 rebuild 的工具。
- await 后使用 State、context、Navigator、ScaffoldMessenger 前,
必须检查相应生命周期:if (!mounted) return;
或 if (!context.mounted) return;
- 禁止把 UI mounted 检查放入 Repository。
## Good vs Bad:组件与订阅
### Bad
```dart
final calendar = ref.watch(calendarProvider);
return ElevatedButton(
onPressed: calendar.isSaving ? null : save,
child: Text('保存'),
);
```
### Good
```dart
final isSaving = ref.watch(
calendarProvider.select((state) => state.isSaving),
);
return AppButton(
onPressed: isSaving ? null : save,
child: const Text('保存'),
);
```
## Good vs Bad:日期选择
### Bad
```dart
final day = await showDatePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
```
### Good
```dart
final range = await AppDateTimePicker.show(
context: context,
initialRange: draft.range,
timeZoneId: draft.timeZoneId,
);
if (!context.mounted) return;
if (range == null) return;
ref.read(editorProvider.notifier).setRange(range);
```
## 验证
- 核对公共组件真实签名;示例参数不能作为 API 存在的证据。
- 事件回调使用 ref.read 获取命令入口,不为执行动作创建订阅。
- 单值且已充分隔离的 Provider 可直接 watch,但注明隔离理由。
- 添加日期选择变化的 Widget 测试,检查无关单元格未被通知重建。
- 使用 profile 模式验证滚动帧耗时,不以 const 数量证明性能。
日期网格:把订阅放到真正变化的单元格
下面使用 Dart 3 record 表示日期,避免把“本地某天”误当作 UTC 时间点:
typedef DayKey = ({int year, int month, int day});
class CalendarDayCell extends ConsumerWidget {
const CalendarDayCell({
required this.day,
super.key,
});
final DayKey day;
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(
calendarProvider.select((s) => s.selectedDay == day),
);
final count = ref.watch(
calendarProvider.select((s) => s.eventCounts[day] ?? 0),
);
return AppCalendarDayTile(
day: day.day,
selected: selected,
eventCount: count,
onTap: () {
ref.read(calendarProvider.notifier).selectDay(day);
},
);
}
}
当选中日期从 17 日变成 18 日,其他日期的 selected 仍为 false,不会因此收到重建通知。日期计数也只在对应数值变化时通知消费者。
不过,.select() 的计算仍可能执行;父节点重建和其他依赖变化也可能触发 Widget 更新。选择器不是“永不 rebuild”的魔法。Riverpod select 文档
const 同样用于减少可避免的构建工作,并不阻止自身状态或继承依赖引发的更新。优化时应区分 build、layout 和 paint。Flutter 性能指南
再让分析器接手机械检查:
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_constructors_in_immutables: true
prefer_const_literals_to_create_immutables: true
use_build_context_synchronously: true
公共组件禁令则应补充基于 Dart AST 的架构检查或自定义 lint:检查真实符号与文件路径,允许 lib/design_system/,拒绝业务层直接实例化。单纯搜索字符串容易漏掉导入别名。
3.3 .cursor/rules/calendar-sync.mdc:竞态、乱序与三方映射
先拆开几个经常混为一谈的概念:
| 机制 | 解决的问题 | 不能解决的问题 |
|---|---|---|
| 按钮禁用、点击合并 | 同一界面的重复操作 | 进程重启、后台重复消费 |
| Mutex | 同一进程内临界区并发 | 跨设备、跨 isolate 互斥 |
| IdempotencyKey | 同一保存意图被重复执行 | 两次真实不同的编辑 |
| CancelToken | 停止过期查询或后续处理 | 撤销已经到达服务器的写入 |
| revision、条件更新 | 旧结果污染当前本地状态 | 单独阻止远端旧写覆盖 |
| Google ETag | 检测远端并发更新 | 自动决定冲突如何合并 |
Google Calendar 支持客户端指定事件 ID;它不是通用 Idempotency-Key 请求头。更新已有事件时,可使用 ETag 与 If-Match 做条件修改。Events.insert、资源版本与条件修改
完整规则如下:
---
description: "约束日历数据与仓储层的离线保存、幂等、互斥、取消、版本控制及三方同步。"
globs: "lib/**/data/**/*.dart,lib/**/repository/**/*.dart"
alwaysApply: false
---
# Calendar Sync Contract
## 执行前
1. 阅读日程、映射、Outbox 的 schema 和 migration。
2. 确认保存命令的 eventId、operationId、expectedRevision。
3. 确认 Google 账户、目标 calendarId 和本设备日历身份。
4. 核对 googleapis、device_calendar 和 HTTP transport 的真实 API。
## Negative Constraints
- 禁止把 SQLite、系统日历和 Google 写入当作一个原子事务。
- 禁止在 SQLite 事务内等待网络请求或系统日历调用。
- 禁止每次重试重新生成 eventId、operationId 或 Google eventId。
- 禁止仅依赖 Mutex、按钮禁用或时间防抖保证幂等。
- 禁止在每次 save 调用中临时 new Mutex;必须注入共享实例。
- 禁止忽略 SQLite UNIQUE、版本 CAS、Worker 任务领取与重启恢复。
- 禁止将取消请求视为远端写入未发生。
- 禁止旧 revision 的结果把新 revision 标记为已同步。
- 禁止同一事件在同一目标上的写操作无序并发。
- 禁止用设备时钟的 updatedAt 直接决定跨设备胜负。
- 禁止假设 googleapis 原生支持 Dio CancelToken。
- 禁止在 Repository 使用 BuildContext、mounted 或 setState。
- 禁止 Google 超时、401 后直接删除本地或系统日历。
- 禁止将 systemEventId 当作跨设备通用 ID。
## 保存协议
- 保存命令必须包含客户端生成、可复用的 IdempotencyKey。
- 一个编辑意图使用一个 key;网络重试沿用;新的编辑生成新 key。
- 同一 key 携带不同 payload 必须报冲突,不可静默返回旧结果。
- 注入共享 Mutex,保护进程内保存临界区。
- SQLite 事务中原子执行:
幂等查询、expectedRevision 校验、日程写入、Outbox 插入、
保存回执持久化。
- 事务失败必须整体回滚;事务成功后即视为本地接受。
- Worker 必须能从数据库恢复,唤醒信号不能成为唯一执行入口。
## 同步协议
- 按 eventId + target 串行处理已提交的写操作。
- Worker 领取任务使用持久化租约/条件更新,支持崩溃恢复。
- 超时或取消后的不确定写入必须先对账,再决定是否重试。
- Google 创建使用稳定、格式合法的客户端事件 ID。
- Google 更新使用 ETag 条件请求;412 进入冲突处理。
- 只在当前 revision 等于任务 revision 时更新当前同步状态。
- 旧任务的回执与 ID 映射仍需持久化,不可简单丢弃。
- 查询使用注入的 CancelToken 和请求序号过滤过期结果。
- 已提交写操作由持久化 Worker 承担,不随页面销毁而丢弃。
## 生命周期交界
- 返回 presentation 的 Future 被 await 后,调用方操作 UI 前
必须执行 if (!mounted) return 或 context.mounted 检查。
- 数据层仅检查取消状态、任务所有权和业务版本。
## Bad
```dart
Future<void> save(Event event) async {
final mutex = Mutex(); // 每次调用一个锁,无法互斥。
await mutex.protect(() async {
await google.events.insert(event, 'primary');
await local.markSynced(event.id);
});
}
```
## Good
```dart
Future<SaveReceipt> save(SaveCommand command) {
return saveMutex.protect(() {
return store.commitIntent(command);
// commitIntent 在同一 SQLite 事务中完成:
// 幂等回执检查 + payload 校验 + revision CAS +
// 日程写入 + 两个目标的 Outbox + 保存回执。
});
}
```
## Good vs Bad:异步结果
### Bad
```dart
await gateway.push(job);
await store.markCurrentEventSynced(job.eventId);
```
### Good
```dart
final ack = await gateway.push(job);
await store.recordAckAndAdvanceIfCurrent(
operationId: job.operationId,
eventId: job.eventId,
expectedRevision: job.revision,
ack: ack,
);
```
## 验证
- 覆盖同一命令并发提交、同 key 不同 payload、旧响应晚到。
- 覆盖提交后进程退出、外部成功但回执尚未落库时退出。
- 覆盖 Google 401、超时、409、412、429/5xx。
- 覆盖系统日历拒绝权限、映射失效和不确定创建。
A. 幂等必须有数据库落点
一个最小数据模型应包括:
| 表 | 关键字段或约束 |
|---|---|
events | event_id、revision、时间与 RRULE、删除标记 |
save_operations | operation_id PRIMARY KEY、payload 摘要、保存回执 |
outbox | operation、event、revision、target、状态、重试时间、租约 |
device_event_mapping | event、device、calendar、system event ID |
google_event_mapping | event、account、calendar、Google event ID、ETag |
compensations | 原操作、目标、补偿动作、前像、执行状态 |
映射的边界很重要:
App eventId
├── 本设备 A + systemCalendarId → systemEventId A
├── 本设备 B + systemCalendarId → systemEventId B
└── Google accountId + calendarId → googleEventId + ETag
Google ID 应在首次创建请求前稳定生成并持久化,不能等待网络成功后才决定。
Repository 的核心接口可以保持很小:
import 'package:mutex/mutex.dart';
typedef IdempotencyKey = String;
final class SaveCommand {
const SaveCommand({
required this.eventId,
required this.operationId,
required this.expectedRevision,
required this.canonicalPayload,
});
final String eventId;
final IdempotencyKey operationId;
final int expectedRevision;
// 固定字段顺序、时间编码和空值语义后的不可变序列化内容。
final String canonicalPayload;
}
final class SaveReceipt {
const SaveReceipt(this.eventId, this.revision);
final String eventId;
final int revision;
}
abstract interface class CalendarStore {
/// 实现必须是一个 SQLite 事务:
/// 1. 查询 operationId,校验 payload,一致则返回已有回执;
/// 2. 校验 expectedRevision;
/// 3. 写入日程新版本;
/// 4. 插入 device/google 两个目标的 Outbox;
/// 5. 持久化 operationId、payload 摘要与回执。
Future<SaveReceipt> commitIntent(SaveCommand command);
}
final class CalendarRepository {
CalendarRepository({
required CalendarStore store,
required Mutex saveMutex,
}) : _store = store,
_saveMutex = saveMutex;
final CalendarStore _store;
final Mutex _saveMutex;
Future<SaveReceipt> save(SaveCommand command) {
return _saveMutex.protect(() => _store.commitIntent(command));
}
}
Mutex.protect 是包提供的临界区执行接口;注入实例应由应用级依赖容器持有。Mutex API
更新日程必须使用条件写入,而不是先查后无条件覆盖:
UPDATE events
SET payload_json = ?,
revision = revision + 1
WHERE event_id = ?
AND revision = ?;
受影响行数为零,表示编辑基线已经过期,应进入冲突处理。这个判断和 Outbox 写入必须处于同一个事务。
锁让并发更可控,数据库约束才让重启后的正确性仍然成立。
B. CancelToken:明确逻辑取消与传输取消
googleapis 的 CalendarApi 使用 http.Client;不能把 Dio 的 cancelToken: 参数直接加到生成的 API 方法上。CalendarApi 构造接口
本项目可以定义一个框架无关的逻辑取消令牌,用于查询结果淘汰:
final class CancelToken {
bool _cancelled = false;
bool get isCancelled => _cancelled;
void cancel() => _cancelled = true;
}
final class LatestQuery<T> {
LatestQuery(this.load);
final Future<T> Function(CancelToken token) load;
CancelToken? _active;
int _generation = 0;
Future<void> run(void Function(T) publish) async {
_active?.cancel();
final token = CancelToken();
_active = token;
final generation = ++_generation;
try {
final result = await load(token);
if (token.isCancelled || generation != _generation) return;
publish(result);
} catch (_) {
if (token.isCancelled || generation != _generation) return;
rethrow;
}
}
void dispose() {
_active?.cancel();
++_generation;
}
}
这里的 CancelToken 只负责逻辑取消,不会自动中断 HTTP。若需要真实网络取消,应在兼容当前依赖版本的 transport adapter 中实现并测试。
对“切换月份后旧查询晚到”,可以直接丢弃旧结果;对“已经提交的保存操作”,不能直接丢弃。后者仍要记录回执、恢复映射并推进持久化任务。
C. Google 幂等创建与有条件更新
Google 事件 ID 允许使用规定字符集的客户端 ID。一个可用策略是:把符合约束的小写 UUID 去掉连字符,并在数据库中固定下来。同一个事件沿用同一个 Google ID,每次编辑使用新的 operationId。Google 事件 ID 约束
对于重复创建:
insert(stableGoogleEventId)
├── 成功:记录 ID、ETag、操作回执
├── 超时:状态为 uncertain,按 ID 查询对账
└── 409:读取已有事件,校验归属与版本
├── 确认为同一操作:恢复回执
└── 不匹配:进入冲突流程
不能把所有 409 都当成成功。
对于编辑:
读取目标版本/ETag
→ 带 If-Match 发起修改
→ 成功:持久化新 ETag
→ 412:重新读取,比较本地意图与远端变化
ETag 请求头应由实际支持它的 HTTP 适配层设置,不能假设生成的 Dart 方法必然存在 ifMatch: 命名参数。
本地回执推进还需版本保护:
UPDATE event_sync_state
SET synced_revision = ?,
status = 'synced'
WHERE event_id = ?
AND target = ?
AND desired_revision = ?;
即使旧任务成功,也只能记录“旧版本曾成功”;不能把正在等待同步的新版本标成完成。
D. mounted 放在 UI 边界
以下方法属于 ConsumerState。_pendingCommand 必须在重试时保留,只有用户产生新的编辑意图时才替换。
Future<void> onSavePressed() async {
if (_saving) return;
final command = _pendingCommand;
setState(() => _saving = true);
try {
await ref.read(calendarRepositoryProvider).save(command);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已保存到本机,同步状态可在日程详情查看'),
),
);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存未完成,请重试'),
),
);
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
页面退出后,UI 不再弹提示;数据库中已经提交的同步任务仍然继续存在。
3.4 .cursor/skills/audit-calendar-sync/SKILL.md:检查逻辑与数据脑裂
Rules 负责约束生成过程,Skill 负责执行一套有输入、有证据、有退出条件的审计。
下例使用 Agent Skills 的 name、description,并使用 Cursor 支持的 disable-model-invocation: true,使其由 /audit-calendar-sync 显式触发。该字段属于 Cursor 的调用控制,不应误认为所有 Skill 宿主都支持。Cursor Skills
---
name: audit-calendar-sync
description: "审计 Flutter 智能日程 App 的离线保存、三方同步、竞态、时区、权限和失败补偿,并修复与验证缺陷。"
disable-model-invocation: true
---
# Audit Calendar Sync
## 目标
证明“保存并同步日程”在重复操作、离线、乱序、权限拒绝、
进程退出和 Google 故障下,不丢失已接受的用户意图,
不静默覆盖新版本,并可从持久化状态继续收敛。
## 前置条件
- 阅读 AGENTS.md 与两份 calendar 相关 MDC。
- 确定本次 diff;若未提供范围,追踪保存入口到全部同步出口。
- 读取锁定依赖、数据库 schema、migration、组件接口和现有测试。
- 确认系统日历是否会自行同步到同一个 Google 目标。
- 测试使用 fake、合成数据或专用测试账户。
- 缺少 SDK、设备、凭据时记录 blocked,不伪造通过。
- 本 Skill 不运行真实用户日历的破坏性故障注入。
## SOP
### 1. 建立路径与不变量
输出调用链:
UI → Repository → SQLite transaction → Outbox →
device_calendar / googleapis → mapping / receipt。
写出本次涉及的:
eventId、operationId、revision、Google ETag、设备映射作用域。
### 2. Checklist
#### 组件与 UI
- [ ] 页面使用 AppButton 和 AppDateTimePicker。
- [ ] 静态节点使用 const,网格不 watch 整个聚合状态。
- [ ] 复合状态使用 select,返回值不可变且比较语义正确。
- [ ] await 后操作 UI 前检查 mounted/context.mounted。
- [ ] 页面退出不会取消或丢弃已经提交的持久化写任务。
#### 幂等与顺序
- [ ] 同一操作重试使用相同 IdempotencyKey。
- [ ] 同 key 不同 payload 会报错。
- [ ] 使用注入的共享 Mutex,而不是每次调用新建锁。
- [ ] SQLite 有幂等唯一约束和 revision 条件更新。
- [ ] 日程、Outbox、保存回执在同一事务写入。
- [ ] 同一事件同一目标写操作串行;任务领取有持久化所有权。
- [ ] 旧回执不会将新版本标记为已同步。
- [ ] 查询取消与写操作不确定状态被分别处理。
#### Google 失败与补偿
- [ ] 超时先对账,不直接删除本地或系统日历。
- [ ] 401 执行受控刷新;失败后标记 needsAuth,避免无限重试。
- [ ] 429/可重试 5xx 使用持久化退避与抖动。
- [ ] 创建使用稳定 Google ID;409 校验既有事件归属和版本。
- [ ] 更新使用 ETag;412 进入冲突处理。
- [ ] 永久错误标记 blocked,并保留用户可编辑的数据。
- [ ] 用户撤销有持久化补偿任务、前像和版本保护。
- [ ] 补偿自身失败可重试,不以吞异常宣称恢复成功。
- [ ] 补偿不会删除其他设备或用户后续编辑的事件。
#### 三方映射与恢复
- [ ] systemEventId 按 deviceId + calendarId 作用域保存。
- [ ] Google 映射包含 accountId + calendarId。
- [ ] 外部创建成功、映射未落库时退出可进入对账恢复。
- [ ] 系统日历不确定创建不会被无条件重复执行。
- [ ] 无法证明系统事件身份时标记 needsReconcile,而非猜测删除。
- [ ] 没有系统日历与 Google REST 的重复上传路径。
#### 时间与 RRULE
- [ ] 定时事件持久化 UTC instant 与 IANA timezone。
- [ ] 本地墙上时间通过指定 timezone 解析,不依赖设备默认时区。
- [ ] 全天事件使用日期和排他结束日期。
- [ ] RRULE 保留时区与墙上时间语义。
- [ ] 覆盖 DST 不存在时间和重复时间的显式处理策略。
- [ ] 整组、单次、本次及以后编辑不会相互混淆。
- [ ] 插件不支持的重复规则或实例编辑不会静默降级。
#### 权限
- [ ] 系统日历拒绝权限时,本地保存和 Google 状态独立。
- [ ] 显示 permissionDenied,不重复弹权限框。
- [ ] 授权恢复后可重新调度未完成任务。
- [ ] 不支持系统日历的平台显示 unsupported,并正常保存本地数据。
### 3. 故障注入
至少覆盖:
1. 同一命令并发提交 20 次。
2. 两次不同编辑使用相同基线 revision。
3. 旧请求晚于新请求返回。
4. Google 已写入但客户端超时。
5. Google 401 且刷新失败。
6. device_calendar 拒绝权限。
7. SQLite 提交后立即模拟进程退出。
8. 系统日历创建成功、映射持久化前退出。
9. 撤销补偿执行前又发生新编辑。
10. 上海与纽约显示同一 instant,以及 DST 重复日程。
### 4. 闭环自修复
- 每项缺陷先说明被破坏的不变量和触发条件。
- 新增能复现缺陷的测试;观察其在修复前失败。
- 做最小修复,不扩大到无关重构。
- 运行失败测试,再运行相关测试集和静态分析。
- 若仍失败,继续定位与修复;不能通过删除断言或吞异常过关。
- 若依赖产品冲突策略或缺少执行条件,明确标记 blocked。
### 5. 执行与报告
在 Flutter SDK 可用且路径存在时执行:
```bash
dart format --output=none --set-exit-if-changed lib test
flutter analyze --fatal-infos
flutter test test/calendar
```
设备集成测试与 profile 性能测试按实际设备执行。
不存在的测试目录先建立真实测试,不输出虚构通过记录。
报告格式:
- PASS / FAIL / BLOCKED
- 受影响不变量
- 文件与位置
- 复现方式
- 修复摘要
- 实际执行命令与结果
- 仍需设备验证或产品决策的事项
## Negative Constraints
- 禁止把“已阅读代码”写成“测试通过”。
- 禁止自动删除真实日历数据来验证回滚。
- 禁止用固定 sleep 代替可控的并发测试调度。
- 禁止忽略失败或未执行项。
## Bad
“代码看起来有 try/catch,回滚应该没有问题。”
## Good
“超时后误删本地事件;新增远端成功但响应丢失的测试,
修复为 uncertain → 对账 → 恢复回执;测试实际通过。”
A. 失败补偿:先决定补什么,再决定是否撤销
生产级策略应明确区分以下情况:
| 故障 | 本地日程 | 系统日历 | 后续动作 | |
|---|---|---|---|---|
| SQLite 事务失败 | 不接受新版本 | 不执行 | 不执行 | 本地事务回滚 |
| 系统权限拒绝 | 保留 | permissionDenied | 独立同步 | 提示授权,之后重试 |
| Google 超时 | 保留 | 保留已成功投影 | uncertain | 按稳定 ID 对账 |
| Google 401 | 保留 | 保留 | needsAuth | 受控刷新或重新授权 |
| Google 400 永久错误 | 保留 | 按已提交状态保留 | blocked | 修正无效字段 |
| 用户明确撤销 | 记录新的撤销版本 | 补偿删除或恢复 | 条件补偿 | 持久化 Saga |
| 补偿时发现新编辑 | 不覆盖新编辑 | 停止盲目恢复 | 检测版本冲突 | 合并或人工决策 |
401、409、412 以及退避处理都有不同语义,不能落进同一个 catch 分支。Google Calendar 错误处理
对于“撤销刚才那次保存”,补偿规则应是:
- 撤销创建:只删除能够证明属于该操作、且没有后续变更的外部事件。
- 撤销编辑:恢复保存前快照,但前提是当前版本仍对应被撤销操作。
- Google 补偿:使用 ETag 保护,检测到变化就转冲突。
- 系统日历补偿:插件没有等价的跨系统原子 CAS 保证;读取后比较仍存在竞态,无法确认时转人工核对。
- 补偿失败:保留补偿任务继续恢复,不删记录、不吞异常。
这才是“有补偿回滚机制”的准确含义:可以追踪、重试和解释的业务撤销流程。
B. 时区:UTC 保存瞬间,IANA 保存规则语义
上海 2026 年 9 月 17 日上午 9 点,对应 UTC 01:00:
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
void timeZoneExample() {
tzdata.initializeTimeZones();
final shanghai = tz.getLocation('Asia/Shanghai');
final wallTime = tz.TZDateTime(
shanghai,
2026,
9,
17,
9,
);
final instant = wallTime.toUtc();
assert(
instant.toIso8601String() == '2026-09-17T01:00:00.000Z',
);
final restored = tz.TZDateTime.from(instant, shanghai);
assert(restored.hour == 9);
}
不要写:
// 错:时区不是手工加减固定小时数。
final utc = picked.subtract(const Duration(hours: 8));
Google 定时事件可以显式传递时间点和时区:
import 'package:googleapis/calendar/v3.dart' as gcal;
gcal.Event buildGoogleEvent({
required String googleEventId,
required String appEventId,
required String title,
required DateTime startUtc,
required DateTime endUtc,
required String timeZoneId,
String? rrule,
}) {
if (!startUtc.isUtc ||
!endUtc.isUtc ||
!endUtc.isAfter(startUtc)) {
throw ArgumentError('必须提供合法的 UTC 时间范围');
}
return gcal.Event(
id: googleEventId,
summary: title,
start: gcal.EventDateTime(
dateTime: startUtc,
timeZone: timeZoneId,
),
end: gcal.EventDateTime(
dateTime: endUtc,
timeZone: timeZoneId,
),
recurrence: rrule == null ? null : [rrule],
extendedProperties: gcal.EventExtendedProperties(
private: {'appEventId': appEventId},
),
);
}
rrule 应由领域层验证并标准化,例如 RRULE:FREQ=WEEKLY;BYDAY=TH。
重复会议“每周四纽约时间上午九点”不能用 UTC 时间每次加七天实现。跨夏令时后,当地九点对应的 UTC 偏移会变化;必须保留重复规则的时区语义。全天日程则使用日期,不能强行转换成 UTC 零点。Google 日历事件与时区
device_calendar 使用 TZDateTime 处理时区,且文档明确提示其重复事件编辑能力存在范围限制。不能把任意 RRULE 或“仅编辑本次”默认视为跨平台等价支持。device_calendar 文档
四、协同演练:“保存并同步日程”的全流程免死盯闭环
现在把四份配置放回同一个业务过程。
用户把“每周四上午九点的项目例会”改为九点半,然后快速点击两次保存。
4.1 编写 UI:规则进入对应文件上下文
Cursor 编辑:
lib/features/calendar/presentation/event_editor_page.dart
UI 规则匹配后,Agent 应执行:
- 阅读
AppButton、AppDateTimePicker的真实接口。 - 复用组件,保留时区与时间范围语义。
- 保存按钮只订阅
isSaving。 - 月视图各单元格订阅自己的日期状态和数量。
- await 后校验
mounted。
此处的“自动纠偏”是 Agent 依据规则调整代码。最终是否违反组件禁令,还由架构检查和 CI 验证。
4.2 编写同步:规则要求稳定意图和持久化任务
Cursor 修改 Repository 时,同步规则进入上下文。
第一次点击:
eventId = E
operationId = O
expectedRevision = 7
SQLite 事务写入:
事件 E:revision 7 → 8
操作 O:保存回执
Outbox:E/revision 8/device
Outbox:E/revision 8/google
第二次点击:
- UI 层先尝试合并重复点击。
- 如果仍进入 Repository,使用相同 operationId。
- 数据库返回原有回执,不再创建第二组任务。
如果用户又把会议改成十点,这是新的编辑意图,必须使用新的 operationId 和新的版本基线。不能用“时间间隔很短”把它当成重复点击吞掉。
4.3 同步运行:先保住数据,再让副本收敛
系统日历成功,Google 请求超时。
此时正确界面是:
本机:已保存
系统日历:已同步
Google:正在核对同步结果
Worker 根据稳定 Google event ID 对账:
- 已存在且内容对应本次操作:补记回执。
- 确认不存在:按策略重试。
- 已存在但有其他版本:进入冲突处理。
- 无法查询:保留不确定状态并退避。
页面关闭不会删除这些任务。应用重新启动后,Worker 从 SQLite 继续处理;移动系统限制后台执行时,恢复时机应如实体现为下次获准执行或前台启动。
4.4 验收:输入 /audit-calendar-sync
Agent 沿调用链检查,并注入:
Google 已经创建事件,但客户端收到超时。
如果代码中存在:
catch (_) {
await local.delete(eventId);
await device.delete(systemEventId);
}
Skill 应产出完整修复链:
发现:网络超时导致已接受的本地日程被删除
复现:fake Google 写入成功后抛出超时
断言:本地日程、系统投影和待对账任务必须保留
修复:超时进入 uncertain,添加按 ID 对账
回归:验证不会重复创建,不会误标新版本同步完成
并发测试使用 Completer、fake clock 和可控 Gateway 安排完成顺序,不靠固定 sleep 碰运气。
验收至少需要以下证据:
| 场景 | 应验证的结果 |
|---|---|
| 同一命令并发 20 次 | 一个保存回执、一个新版本、每目标一个逻辑任务 |
| 同 key 不同内容 | 明确报幂等冲突 |
| 旧查询晚返回 | 当前月份不被旧数据覆盖 |
| 旧写回执晚到 | 不把新版本标记为已同步 |
| 远端成功但超时 | 对账恢复,无盲目删除或重复创建 |
| 系统日历成功后进程退出 | 恢复映射或明确进入待核对 |
| 权限拒绝 | 本地可用,状态准确,无反复弹窗 |
| DST 重复事件 | 当地墙上时间符合产品定义 |
“免死盯”的交付物,不是 Agent 的一句“应该没问题”,而是规则、实现、失败测试和实际验证结果彼此对应。
五、GitHub 避坑史与高星资源推荐
5.1 最常见的七种反模式
| 反模式 | 后果 | 替代方式 |
|---|---|---|
巨石 AGENTS.md | 每次任务背负大量无关信息 | 全局原则精简,局部约束进入 MDC |
所有规则 alwaysApply: true | UI、数据库、后端规则相互污染 | 精确 glob,检查实际匹配文件 |
| “使用最佳实践”式口号 | 没有可判断的违例条件 | Negative Constraints + Good/Bad |
所有 Provider 强制 .select((s) => s) | 形式合规,订阅范围没缩小 | 选择实际使用的不可变字段 |
| 把 Mutex 当幂等 | 重启或另一进程仍重复写 | SQLite 唯一约束、回执、任务恢复 |
给所有异步代码塞 mounted | 数据层出现无效 UI 概念 | UI 检查生命周期,数据层检查版本 |
| 同步失败立即删除本地 | 超时变成用户数据丢失 | 故障分类、对账、持久化补偿 |
还有一个经常被忽略的事实:系统日历 API 不提供你想象中的端到端 exactly-once。
外部创建成功、映射尚未落库时进程退出,是无法靠普通 Mutex 消除的窗口。需要平台允许的稳定标记、查询对账和冲突状态;无法证明归属时,应明确待核对,不能按“标题相同”猜测删除。
5.2 两个优先抄作业的社区入口
1. PatrickJS / awesome-cursorrules
推荐入口:
该仓库当前整理了现代 MDC 规则,并在 README 的 Mobile Development 下提供 Flutter 入口。
适合借鉴 Flutter 分层、组件组织、命名与测试要求。把通用要求迁入项目时,再补上本项目的真实组件路径、状态管理版本和日历一致性契约。
2. cursor.directory 社区生态
推荐入口:
本次核对时,历史仓库地址 pontusab/cursor.directory 已重定向到 cursor/community-plugins。因此收藏旧教程时,要确认跳转后的资产类型与安装方式,不能假设旧目录和导入步骤仍然适用。当前 GitHub 仓库
社区入口适合发现规则和工作流素材;下载后仍要检查它是 .mdc、旧 .cursorrules、Skill 还是完整插件。
5.3 十分钟筛选一份移动端规则
按以下顺序检查:
- 找技术栈:搜索 Flutter、Dart、Riverpod、mobile、testing。
- 找具体契约:是否写出禁止行为、正确示例和验证方法。
- 核对版本:是否存在旧 Riverpod 用法、过时插件参数。
- 核对作用域:把宽泛路径改成实际 presentation、data、repository。
- 检查冲突:不要同时导入“统一 Bloc”和“统一 Riverpod”。
- 审查负向约束:不能出现“所有失败都回滚”“所有请求都取消”等绝对化误导。
- 跑一个真实任务:用本案例验证组件复用、并发保存和超时恢复。
- 固定来源:记录许可证、来源链接和提交版本,升级时审查差异。
高星帮助我们发现值得研究的经验;适用范围、依赖版本和可重复的测试,决定它能否进入生产。
这套三件套最终应让团队成员少说四句话——“换公共组件”“别监听整个状态”“补幂等”“超时别删数据”——因为这些要求已经成为项目中可读取、可执行、可验证的工程契约。
更多推荐



所有评论(0)