基于 Flutter 的 Cursor 三件套实战:借鉴 GitHub 顶流规范,告别 AI 竞态、性能与组件复用的人肉死盯时代

贯穿案例:一个基于 Flutter 3.x、Dart 3、Riverpod、device_calendargoogleapis 和 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 改造成三层上下文防线:

  1. AGENTS.md:告诉 AI 项目如何运转。
  2. Rules:告诉 AI 当前这类文件允许怎样写。
  3. 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 使用自己的 namedescription 元数据,不套用 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 先把“三方保存”定义清楚

虽然业务通常把它叫作“双写同步”,本案例实际涉及三个一致性域:

编辑页面

CalendarRepository

SQLite 事务

日程与版本

持久化 Outbox

系统日历 Worker

Google Worker

本设备 ID 映射

Google ID 与 ETag

这里采用以下业务契约:

点击保存后,先在 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. 幂等必须有数据库落点

一个最小数据模型应包括:

关键字段或约束
eventsevent_idrevision、时间与 RRULE、删除标记
save_operationsoperation_id PRIMARY KEY、payload 摘要、保存回执
outboxoperation、event、revision、target、状态、重试时间、租约
device_event_mappingevent、device、calendar、system event ID
google_event_mappingevent、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:明确逻辑取消与传输取消

googleapisCalendarApi 使用 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 的 namedescription,并使用 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. 失败补偿:先决定补什么,再决定是否撤销

生产级策略应明确区分以下情况:

故障本地日程系统日历Google后续动作
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 应执行:

  1. 阅读 AppButtonAppDateTimePicker 的真实接口。
  2. 复用组件,保留时区与时间范围语义。
  3. 保存按钮只订阅 isSaving
  4. 月视图各单元格订阅自己的日期状态和数量。
  5. 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: trueUI、数据库、后端规则相互污染精确 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 十分钟筛选一份移动端规则

按以下顺序检查:

  1. 找技术栈:搜索 Flutter、Dart、Riverpod、mobile、testing。
  2. 找具体契约:是否写出禁止行为、正确示例和验证方法。
  3. 核对版本:是否存在旧 Riverpod 用法、过时插件参数。
  4. 核对作用域:把宽泛路径改成实际 presentation、data、repository。
  5. 检查冲突:不要同时导入“统一 Bloc”和“统一 Riverpod”。
  6. 审查负向约束:不能出现“所有失败都回滚”“所有请求都取消”等绝对化误导。
  7. 跑一个真实任务:用本案例验证组件复用、并发保存和超时恢复。
  8. 固定来源:记录许可证、来源链接和提交版本,升级时审查差异。

高星帮助我们发现值得研究的经验;适用范围、依赖版本和可重复的测试,决定它能否进入生产。

这套三件套最终应让团队成员少说四句话——“换公共组件”“别监听整个状态”“补幂等”“超时别删数据”——因为这些要求已经成为项目中可读取、可执行、可验证的工程契约。

Logo

一站式 AI 云服务平台

更多推荐