Merge remote-tracking branch 'origin/develop' into feature/deploy-admin-api-lan-access
This commit is contained in:
commit
1bf74bdd4c
21 files changed
+6773
-15
No files matched your search
+16
-2
@@ -113,10 +113,24 @@ POST /app/auth/register
|
||||
| `targetType=PHONE` | 维持原状:`code` **必填** |
|
||||
| 邮箱规范化 | 去空格 + 转小写(`batch1@Example.com ` → `batch1@example.com`);格式不合法直接 `400 邮箱格式不正确` |
|
||||
| 密码强度 | 8–64 位,且**同时包含字母与数字**(不强制特殊字符:低端机输入成本高,拦机器注册靠频控) |
|
||||
| 注册频控 | 同 IP 24h ≤ **5** 个、同设备 24h ≤ **3** 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
|
||||
| 注册频控 | 同 IP / 同设备 24h 默认上限分别为 5 / 3 个,超出返回 `429`(`TOO_MANY_REQUESTS`) |
|
||||
| 邮箱验证状态 | 免码注册时 `app_user_credential.verified = 0`(保留"待验证"),**不阻塞登录**;只有**验证码登录**会把 `verified` 置 1,密码登录不改变该状态 |
|
||||
|
||||
频控口径落库在 `app_user.register_ip` / `register_device_id` 上(按 24h 计数,不新建计数表)。
|
||||
频控口径落库在 `app_user.register_ip` / `app_user.register_device_id` 上(按 24h 计数,不新建计数表)。
|
||||
|
||||
注册频控阈值存储在 `sys_config`,可在后台「系统配置」修改:
|
||||
|
||||
```text
|
||||
app.register.max-per-ip-per-day
|
||||
app.register.max-per-device-per-day
|
||||
```
|
||||
|
||||
运行时优先读取 `sys_config`。键缺失或值不是整数时,回退 `application.yml`
|
||||
中的 `fruitshorts.auth.register-max-per-ip-per-day` /
|
||||
`fruitshorts.auth.register-max-per-device-per-day`,缺省为 5 / 3;
|
||||
值小于等于 0 表示对应维度不限制。
|
||||
开发种子配置为 0 / 0,生产建议恢复为 5 / 3。
|
||||
|
||||
**残留风险**:IP 取自 `X-Forwarded-For`、设备号由客户端上报,都能伪造,只能挡脚本批量;
|
||||
真要防住需要接图形/行为验证码或风控服务,本阶段未做。验证码通道目前是 `LogOtpSender`(只写日志不发信),
|
||||
所以邮箱**可达性**未验证 —— 这也是"不阻塞注册"的前提。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)
|
||||
|
||||
> 依据:`docs/plans/INBOX.md` 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整,
|
||||
> 仍然严格按照 specs 执行」→ 本轮做客户端 `ReelShort`(Flutter),目标是一条完整看剧闭环:
|
||||
> **首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
|
||||
> 契约以 `FruitServer/docs/api-phase1.md` §1.4 / §1.5 为准。
|
||||
|
||||
## 一、勘察更正(先说清,免得后面对不上)
|
||||
|
||||
条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条**不准确**,实测:
|
||||
|
||||
| 接口 | 现状 |
|
||||
| --- | --- |
|
||||
| `GET /app/dramas/{id}` | **已接**:`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用 |
|
||||
| `GET /app/episodes/{id}/play` | **半接**:`CatalogApi.playTicket` + `CatalogController.resolvePlay` 存在,但**没有任何页面调用**;详情下发的 `playUrl`(免费集已带签名)被直接使用 |
|
||||
| `/app/watch/*` | **没接**:`WatchHistoryRepository` 是 `SharedPreferences` 本地实现,`demoProfileId` 假身份 |
|
||||
| `/app/favorites/*` | **没接**:收藏走本地 `EngagementController`(SharedPreferences),点赞也是本地(点赞属 K3,不动) |
|
||||
|
||||
所以本轮真正要做的是:**播放票据接上并带过期重取**、**进度从本地换远程**、**收藏换服务端**、
|
||||
**三态 UI**,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。
|
||||
|
||||
## 二、方案(分层:api → repository → logic → view)
|
||||
|
||||
### 1. 新增 api 类(`lib/core/data/remote/`)
|
||||
|
||||
| 文件 | 方法 | 对应接口 |
|
||||
| --- | --- | --- |
|
||||
| `playback_api.dart`(新增) | `ticket(episodeId)` | `GET /app/episodes/{id}/play` |
|
||||
| `watch_api.dart`(新增) | `saveProgress` / `progressOfDrama` / `recent` | `POST /app/watch/progress`、`GET /app/watch/progress?dramaId=`、`GET /app/watch/recent?limit=` |
|
||||
| `library_api.dart`(新增) | `toggleFavorite` / `favorites` / `removeFavorite` | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
|
||||
|
||||
票据调用从 `CatalogApi` **搬**到 `PlaybackApi`(条目要求新增 playback_api),
|
||||
`CatalogRepository.resolvePlay` 随之移到新的 `PlaybackRepository`——这是本轮唯一"动到既有目录层"的地方,最小接线。
|
||||
|
||||
### 2. repository(保留抽象,测试注入假实现)
|
||||
|
||||
- `playback_repository.dart`(新增):`ticketFor(episodeId, {forceRefresh})`。
|
||||
`HttpPlaybackRepository` 内含**短时缓存**:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量),
|
||||
**时钟与 TTL 可注入** → 过期自动重取,测试用假时钟钉死。
|
||||
- `watch_history_repository.dart`(**改签名**):服务端按 `(user, drama)` 存一条续看记录,
|
||||
所以接口从 `(profileId, dramaId, episodeId)` 变成
|
||||
`progressFor(dramaId)` / `save({episodeId, position, duration, finished})` / `recent(limit)`;
|
||||
`HttpWatchHistoryRepository` + `InMemoryWatchHistoryRepository`(测试用);
|
||||
`demoProfileId` 删除;`clampResumePosition` 保留(续看钳位规则不变)。
|
||||
- `favorite_repository.dart`(新增):`toggle` / `list` / `remove`;HTTP + 内存实现。
|
||||
|
||||
### 3. controller / logic
|
||||
|
||||
- `favorite_controller.dart`(新增):`savedIds` + `cards` + `loading/error`,
|
||||
`toggle(dramaId)` **乐观更新 → 失败回滚**(`DELETE`/`toggle` 任一失败都回到原状态)。
|
||||
- `DramaDetailLogic` / `MineLogic` / `PlayerLogic`:改用新的 `WatchHistoryRepository`,
|
||||
续看点从 `WatchProgress(episodeId, positionMs)` 来(不再是本地 episodeId 字符串)。
|
||||
- `PlayerLogic`:
|
||||
- 起播前 `ticketFor(episode)`,把签名地址写回 session 的剧集对象;**地址原样交给播放器**
|
||||
(`AppConfig.mediaUrl` 只补 host,不动 path/query —— 新增测试钉住 `?e=&s=` 原样保留);
|
||||
- 票据拿不到 / `accessType=COIN_REQUIRED|NO_MEDIA` → 由**后端返回值**驱动 UI(锁定/无片源提示 + 重试);
|
||||
- 进度上报**节流 10 秒**(原实现是 5 秒),切集/退出/切后台各补一次,`finished` 在播完时上报。
|
||||
|
||||
### 4. UI
|
||||
|
||||
- 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
|
||||
- 播放页:票据加载中 / 失败(可重试)/ 锁定(`accessType` 驱动)三态;收藏按钮真实状态 + 回滚 +
|
||||
失败提示(未登录先引导登录,沿用现有 `ensureSignedIn`)。
|
||||
- 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 `demoProfileId` 假身份。
|
||||
- 文案:新增键写进 `app_en.arb` / `app_zh.arb` / `app_id.arb`,`flutter gen-l10n` 重新生成。
|
||||
|
||||
## 三、影响面清单
|
||||
|
||||
新增:`playback_api.dart`、`watch_api.dart`、`library_api.dart`、`watch_progress.dart`(模型)、
|
||||
`playback_repository.dart`、`favorite_repository.dart`、`favorite_controller.dart`。
|
||||
|
||||
改:`watch_history_repository.dart`(签名)、`catalog_api.dart`(去掉 playTicket)、
|
||||
`catalog_repository.dart`(去掉 resolvePlay)、`catalog_controller.dart`(同步)、
|
||||
`play_ticket.dart`(补 dramaId/number/previewSeconds)、`app.dart`(注入三个仓库 + FavoriteController)、
|
||||
`drama_detail_{logic,view,binding}.dart`、`mine_{logic,view,binding}.dart`、
|
||||
`player_{logic,view,binding}.dart`、`discover_{logic,binding}.dart`(续看点来源)、
|
||||
`app_{en,zh,id}.arb` + 生成的 4 个 l10n 文件。
|
||||
|
||||
测试:改 `test/support/fake_watch_history.dart`、`test/playback/watch_history_test.dart`、
|
||||
`test/catalog/catalog_controller_test.dart`、`test/support/fake_catalog.dart`、
|
||||
`test/{discover,mine,drama_detail,player}/…`(续看点改签名);新增
|
||||
`playback_ticket_expiry_test.dart`、`progress_throttle_test.dart`、`watch_history_remote_test.dart`、
|
||||
`favorite_controller_test.dart`、`player_ticket_gate_test.dart`、`integration/live_watch_flow_test.dart`。
|
||||
|
||||
## 四、风险 / 待确认
|
||||
|
||||
1. **改动面比"只加接口"大**:`WatchHistoryRepository` 的签名必须改(服务端是"每剧一条续看记录",
|
||||
没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
|
||||
2. **未登录的边界**:`/app/watch/*` 与 `/app/favorites/*` 需要登录态。客户端策略:
|
||||
未登录时**不发**这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
|
||||
3. **票据过期无法从响应判断**(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取;
|
||||
若后端后续补 `expiresAt`,直接换成按字段判断即可(口径已写进计划)。
|
||||
4. **`/app/watch/recent` 只给 dramaId/episodeId/位置**,没有剧名封面 —— K1 里"我的"页用
|
||||
已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
|
||||
5. 联调测试需要登录态:新增的 live 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 2026-10-17 K10 统一空列表占位(EmptyState)
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
新建统一 `EmptyState` 组件,替换任务书列出的 10 处纯文字空态,保留现有空态
|
||||
文案、按钮行为和错误态逻辑。只改空态视觉、三语 l10n 与测试,不改数据、接口、
|
||||
列表加载/失败逻辑或页面信息架构。
|
||||
|
||||
- 客户端分支:`feature/flutter-k10-empty-states`,基线 `develop 19babb8`。
|
||||
- 文档分支:`docs/k10-empty-states`,FruitServer 基线 K10 任务书 `3f84bc6`。
|
||||
- 不引入图片、SVG、字体或第三方依赖。
|
||||
- 不跑真机,设备侧深色/浅色、小屏和页面状态由用户验收。
|
||||
|
||||
## 二、组件设计
|
||||
|
||||
`lib/core/widgets/empty_state.dart`:
|
||||
|
||||
1. 默认居中,最大宽度 280,外层 Padding 24。
|
||||
2. 默认图形 96x96、圆角 28,背景 `AppColors.blue` 透明度 0.11;图标 42。
|
||||
3. 深色图标色 `AppColors.blueSoft`,浅色图标色 `AppColors.blue`。
|
||||
4. compact 图形 64x64、圆角 18、图标 28,用于评论弹窗。
|
||||
5. 标题 16/w600(compact 14)、最多 2 行;可选副标题 13、`muted`、最多 2 行。
|
||||
6. 可选主色 TextButton,最小点击区域 44x44;保留调用方动作。
|
||||
7. 外层 `Semantics(label: title)`;图形、标题和副标题文本使用
|
||||
`ExcludeSemantics`,避免重复朗读,操作按钮仍保持可访问。
|
||||
8. 稳定 Key:`empty-state-art`、`empty-state-icon`、`empty-state-title`、
|
||||
`empty-state-action`。
|
||||
|
||||
新增通用副标题 `emptyHint`:
|
||||
|
||||
- zh:有新内容时会显示在这里。
|
||||
- en:New content will appear here.
|
||||
- id:Konten baru akan muncul di sini.
|
||||
|
||||
## 三、10 处替换与图标
|
||||
|
||||
| 位置 | 图标 | 行为 |
|
||||
|---|---|---|
|
||||
| 首页 noStories | `movie_outlined` | 保留重试 |
|
||||
| 首页分类空 | `grid_view_rounded` | 无新增动作 |
|
||||
| 首页搜索空 | `search_off_rounded` | 无新增动作 |
|
||||
| 收藏 noFavorites | `favorite_border_rounded` | 原按钮不变 |
|
||||
| 通知 noNotices | `notifications_none_rounded` | 原下拉刷新不变 |
|
||||
| 详情 noEpisodesYet | `video_library_outlined` | 保留重试 |
|
||||
| 我的-片单 noSavedShows | `bookmark_border_rounded` | 无新增动作 |
|
||||
| 我的-最近剧目 noStories | `history_rounded` | 无新增动作 |
|
||||
| 下载 noDownloads | `download_outlined` | 原清空按钮不变 |
|
||||
| 观看记录 noWatchRecords | `history_rounded` | 原清空/刷新逻辑不变 |
|
||||
| 评论 noComments | `chat_bubble_outline_rounded` | compact |
|
||||
|
||||
首页错误态继续使用 `error_outline_rounded` 和 `catalogError`;分类、收藏、通知、
|
||||
记录、评论等失败态继续使用原错误图标/重试,不允许用 `EmptyState` 冒充失败。
|
||||
|
||||
## 四、影响文件
|
||||
|
||||
- `lib/core/widgets/empty_state.dart`(新增)
|
||||
- `lib/pages/home/home_view.dart`
|
||||
- `lib/pages/discover/widgets/home_drama_grid.dart`
|
||||
- `lib/pages/favorites/favorites_page.dart`
|
||||
- `lib/pages/notices/notices_page.dart`
|
||||
- `lib/pages/drama_detail/drama_detail_view.dart`
|
||||
- `lib/pages/mine/mine_view.dart`
|
||||
- `lib/pages/downloads/downloads_page.dart`
|
||||
- `lib/pages/watch_records/watch_records_page.dart`
|
||||
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||
- `lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
|
||||
- 组件测试及收藏/下载/记录/通知/评论页面空态测试
|
||||
- `FruitServer/docs/plans/2026-10-17-K10.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`
|
||||
|
||||
## 五、TDD 与门禁
|
||||
|
||||
1. 先新增 `EmptyState` 组件测试和五处页面空态测试,确认组件不存在时红灯。
|
||||
2. 实现组件并替换 10 处空态后跑定向测试绿灯。
|
||||
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
4. 构建前检查并发 `flutter run/build`;当前有用户 iOS `flutter run` 会话,
|
||||
不主动终止,若仍未结束则等待其退出后再构建。
|
||||
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 六、风险与待确认
|
||||
|
||||
- 首页当前把 `catalog.error` 与空列表放在同一分支,需先拆出错误态,再单独用
|
||||
`EmptyState` 渲染 noStories,避免错误态被误认为空态。
|
||||
- 部分空态位于 Sliver、固定高度滚动区或弹窗内,需保持可滚动和居中。
|
||||
- 组件外层统一读标题,若操作按钮位于同一 Semantics 容器内,必须确保按钮自身
|
||||
语义未被 `ExcludeSemantics` 吞掉。
|
||||
- 最大宽度 280 与 compact 64 图标容器需在 320px 宽度和浅/深色主题下无溢出。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 2026-10-17 K11 首页网格 3 列 + 分类首位「全部」
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
在 K9 首页「全部剧目」网格上做最小修订:每行从 2 个改为 3 个,分类首个仍绑定
|
||||
`CategoryController.allCode`,文案改为「全部 / All / Semua」。
|
||||
|
||||
- 客户端分支:`feature/flutter-k11-home-grid-3col`,基线 `develop 0466b4b`。
|
||||
- 文档分支:`docs/k11-home-grid-3col`,FruitServer 基线任务书 `2a06190`。
|
||||
- 不改轮播、空态、三态、数据源、接口、分页或其他页面。
|
||||
- 不跑真机;设备侧 3 列布局、分类切换和详情跳转由用户验收。
|
||||
|
||||
## 二、实现方案
|
||||
|
||||
1. `home_drama_grid.dart`:
|
||||
- `crossAxisCount: 3`
|
||||
- `mainAxisSpacing: 14`
|
||||
- `crossAxisSpacing: 10`
|
||||
- `childAspectRatio: 2 / 3` 不变
|
||||
2. 3 列下卡片仍只保留标题最多 2 行、类型最多 1 行;由现有 `Text` 的
|
||||
`maxLines` / `ellipsis` 保证不溢出。
|
||||
3. `discover_view.dart` 分类第一项 code 保持 `CategoryController.allCode`,
|
||||
label 从 `l10n.forYou` 改为 `l10n.all`。
|
||||
4. 已核对三份 ARB 已存在 `all` key:
|
||||
- en:All
|
||||
- zh:全部
|
||||
- id:Semua
|
||||
|
||||
因此直接复用现有 l10n,不重复插入 JSON key;热门、最新和服务端分类顺序不变。
|
||||
|
||||
## 三、测试
|
||||
|
||||
- 更新 K9 网格断言:`crossAxisCount == 3`、`crossAxisSpacing == 10`、
|
||||
`mainAxisSpacing == 14`、比例仍为 2/3。
|
||||
- 新增首个分类 chip 文案断言为 `All`;切换分类时仍能更新网格。
|
||||
- K9/K10 既有测试保留,不删除用例;只更新本次行为明确变化的参数断言。
|
||||
- 组件/页面空态测试保持通过。
|
||||
|
||||
## 四、影响文件
|
||||
|
||||
- `ReelShort/lib/pages/discover/widgets/home_drama_grid.dart`
|
||||
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
|
||||
- `FruitServer/docs/plans/2026-10-17-K11.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`
|
||||
|
||||
## 五、门禁与 Git
|
||||
|
||||
1. 先更新测试取得与 3 列/全部文案相关的红灯。
|
||||
2. 实现后运行定向测试。
|
||||
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
4. 当前有 Android `flutter run` 会话,不主动终止;构建前检查进程,必要时使用
|
||||
独立工作区副本构建,避免争用原项目 `build/`。
|
||||
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 六、风险与待确认
|
||||
|
||||
- 3 列后单卡宽度变窄,长标题和类型必须继续 ellipsis,不允许改变卡片结构。
|
||||
- 现有 `all` l10n 已具备三语,新增重复 key 会破坏 ARB;本批复用现有 key。
|
||||
- 用户正在运行的 Flutter `run` 不终止;APK 构建需隔离原 `build/`。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 2026-10-17 K12 首页去标题 + 空态视觉重做
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
在 K11 首页基础上做两项视觉修订:
|
||||
|
||||
1. 删除首页「全部剧目」标题,分类 chips 后直接显示网格。
|
||||
2. 将 `EmptyState` 从单个实心圆角块改为轻盈的双层同心圆。
|
||||
|
||||
只改首页间距、`EmptyState` 视觉与对应测试,不改接口、数据、轮播、三态或其他页面。
|
||||
|
||||
- 客户端分支:`feature/flutter-k12-home-empty-polish`,基线 `develop b5a8656`。
|
||||
- 文档分支:`docs/k12-home-empty-polish`,FruitServer 基线任务书 `87a37e9`。
|
||||
- 不引入图片、SVG、字体或动画;不跑真机。
|
||||
|
||||
## 二、首页去标题
|
||||
|
||||
- 删除 `Key('home-all-dramas-title')` 的 Text。
|
||||
- 删除标题与 chips 之间、标题与网格之间的多余间距。
|
||||
- chips 与网格间距调整为 14px。
|
||||
- 网格、数据、空态和分类切换逻辑保持不变。
|
||||
|
||||
## 三、EmptyState 双层同心圆
|
||||
|
||||
默认:
|
||||
|
||||
- 外层直径 84,blue 5.5% 填充,1px blue 11% 描边。
|
||||
- 内层直径 56,blue 11% 填充。
|
||||
- 图标 27px,深色 `blueSoft` / 浅色 `blue`。
|
||||
- 标题 15/w600,与图形间距 14。
|
||||
- 副标题 12.5 muted,行高 1.45,与标题间距 6。
|
||||
- TextButton 13 主色,点击区至少 44,与副标题间距 8。
|
||||
- 上下 Padding 28,最大宽度 260。
|
||||
|
||||
compact:
|
||||
|
||||
- 外层 60、内层 40、图标 21。
|
||||
- 图形与标题间距 10,上下 Padding 16。
|
||||
|
||||
保留外层 Key `empty-state-art`,新增内层 Key `empty-state-art-inner`;继续使用
|
||||
`empty-state-icon/title/action`。外层 Semantics 仍只读标题。
|
||||
|
||||
## 四、测试
|
||||
|
||||
- 更新组件测试:外/内层尺寸、图标尺寸、标题 15、副标题 12.5、按钮 44、
|
||||
最大宽 260、compact 尺寸与深浅主题颜色。
|
||||
- 首页测试:`home-all-dramas-title` 不存在;chips 存在后直接能找到
|
||||
`home-all-dramas-grid`。
|
||||
- K9/K10/K11 既有用例保留,不删除测试文件;只更新本次明确变化的视觉/锚点断言。
|
||||
- 既有壳层测试中若用「All Dramas」作为页面定位锚点,改用当前稳定锚点
|
||||
`All` chip,不降低导航流程覆盖。
|
||||
|
||||
## 五、影响文件
|
||||
|
||||
- `ReelShort/lib/core/widgets/empty_state.dart`
|
||||
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||
- `ReelShort/test/empty_state/empty_state_test.dart`
|
||||
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`
|
||||
- `ReelShort/test/widget_test.dart`(仅稳定锚点更新)
|
||||
- `FruitServer/docs/plans/2026-10-17-K12.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`
|
||||
|
||||
## 六、门禁与 Git
|
||||
|
||||
1. 先更新组件与首页测试,取得标题仍存在、空态仍是旧尺寸的红灯。
|
||||
2. 实现后跑定向测试。
|
||||
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
4. 当前已确认真机会话停止且无并发构建;构建前再次检查。
|
||||
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 七、风险与待确认
|
||||
|
||||
- 删除标题后,部分既有测试用「All Dramas」文本定位页面;需改为稳定的
|
||||
`All` chip 或网格 Key,保持原测试流程。
|
||||
- 双层圆使用固定尺寸和 Stack,小屏需保证 84/60 不溢出。
|
||||
- 副标题 12.5 与按钮 13 是视觉规格;测试按 double 精确断言。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 2026-10-17 K13 运行时服务器地址设置(仅 debug)
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
解决 debug APK 不带 `--dart-define` 时固定回退到
|
||||
`http://jiangdeMacBook-Air.local:8081` 的问题。增加仅 debug 可用的运行时地址
|
||||
覆盖,保存后下次启动生效。
|
||||
|
||||
优先级:
|
||||
|
||||
```text
|
||||
SharedPreferencesAsync dev.api_base_url
|
||||
> --dart-define(API_BASE_URL)
|
||||
> AppConfig 现有默认地址
|
||||
```
|
||||
|
||||
- 客户端分支:`feature/flutter-k13-dev-server-setting`,基线 `b762289`。
|
||||
- 文档分支:`docs/k13-dev-server-setting`,FruitServer 基线 `1a5951c`。
|
||||
- release 不读取 SharedPreferences 覆盖,也不显示设置入口。
|
||||
- 不做环境列表、远程配置、接口或后端改动。
|
||||
- 不跑真机。当前有真机 `flutter run` 时,构建使用隔离副本,不终止用户会话。
|
||||
|
||||
## 二、运行时解析
|
||||
|
||||
新增 `lib/core/config/server_endpoint.dart`:
|
||||
|
||||
1. 常量 key:`dev.api_base_url`。
|
||||
2. `normalize`:
|
||||
- `trim()`;
|
||||
- 只接受 `http://` 或 `https://`;
|
||||
- `Uri.host` 必须非空;
|
||||
- 去掉末尾所有 `/`。
|
||||
3. `resolve`:
|
||||
- 本地值有效时使用本地值;
|
||||
- 否则使用有效 dart-define;
|
||||
- 否则使用现有默认值。
|
||||
4. `load({debug, preferences})`:
|
||||
- debug=false 时立即返回,不创建/读取 SharedPreferences;
|
||||
- debug=true 时读取并规范化,合法值写入 `AppConfig` 的运行时覆盖。
|
||||
5. `save` 与 `clear`:
|
||||
- save 前规范化,非法值返回 false 且不写;
|
||||
- clear 删除 key。
|
||||
|
||||
`AppConfig` 增加运行时覆盖读写入口和编译期/默认地址 getter;`apiBaseUrl` 优先
|
||||
返回已加载的运行时值。`main()` 在 `runApp` 前
|
||||
`await ServerEndpoint.load(debug: kDebugMode)`,确保 `LumaDramaApp` 创建
|
||||
`ApiClient` 前已加载完成。
|
||||
|
||||
## 三、Debug 设置入口
|
||||
|
||||
`MineLogic.showLanguage` 增加 `showServerSetting` 参数。我的页设置弹窗中在语言
|
||||
列表下方增加「服务器地址」行:
|
||||
|
||||
- 仅 `MyPage.debugShowSetting == true` 时显示,默认值 `kDebugMode`,测试可注入;
|
||||
- 副标题显示当前 `AppConfig.apiBaseUrl`;
|
||||
- 点击打开编辑弹窗,预填当前地址;
|
||||
- 保存合法地址后关闭编辑弹窗并提示“重启 App 生效”;
|
||||
- 非法地址在输入框显示校验错误,不保存;
|
||||
- 提供「恢复默认」,清除本地 key,并提示重启生效;
|
||||
- release 默认不显示,也不触发本地读取。
|
||||
|
||||
新增三语文案:
|
||||
|
||||
- `serverAddress`
|
||||
- `serverAddressHint`
|
||||
- `saveAddress`
|
||||
- `restartToApply`
|
||||
- `restoreDefault`
|
||||
- `invalidServerAddress`
|
||||
|
||||
## 四、测试
|
||||
|
||||
新增 `test/config/server_endpoint_test.dart`:
|
||||
|
||||
- 本地覆盖 > dart-define > 默认;
|
||||
- 非法本地值忽略并回落;
|
||||
- trim、末尾斜杠、http/https;
|
||||
- `ftp://`、无 scheme、空 host 被拒绝;
|
||||
- debug=false 不读取覆盖;
|
||||
- save/clear 对 SharedPreferencesAsync 的行为。
|
||||
|
||||
新增/扩展设置弹窗测试:
|
||||
|
||||
- debug 注入 true:显示服务器地址行,可打开编辑弹窗;
|
||||
- 注入 false:不显示入口;
|
||||
- 非法地址显示错误且不写入。
|
||||
|
||||
既有测试只更新必要锚点/新增断言,不删除用例。
|
||||
|
||||
## 五、影响文件
|
||||
|
||||
- `ReelShort/lib/core/config/server_endpoint.dart`(新增)
|
||||
- `ReelShort/lib/core/config/app_config.dart`
|
||||
- `ReelShort/lib/main.dart`
|
||||
- `ReelShort/lib/pages/mine/mine_view.dart`
|
||||
- `ReelShort/lib/pages/mine/mine_logic.dart`
|
||||
- `ReelShort/lib/l10n/app_en.arb`、`app_zh.arb`、`app_id.arb` 与生成文件
|
||||
- `ReelShort/test/config/server_endpoint_test.dart`(新增)
|
||||
- 我的设置弹窗测试
|
||||
- `FruitServer/docs/plans/2026-10-17-K13.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`
|
||||
|
||||
## 六、门禁与 Git
|
||||
|
||||
1. 先写运行时解析与入口可见性测试,确认红灯。
|
||||
2. 实现后跑定向测试。
|
||||
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
4. 当前真机 run 会话不终止;构建在独立副本中进行,原 `build/` 不受影响。
|
||||
5. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 七、风险与待确认
|
||||
|
||||
- `AppConfig.apiBaseUrl` 当前是纯 getter;增加运行时静态状态后,测试必须显式
|
||||
清理,避免用例间污染。
|
||||
- release 的“完全不读取”由 `ServerEndpoint.load(debug: kDebugMode)` 和主入口
|
||||
的 debug 参数共同保证;测试使用可注入 debug 参数验证。
|
||||
- 保存和恢复默认都只在下次启动生效,当前 ApiClient 不在运行中重建。
|
||||
- 设置入口只在 debug 显示,不修改发布包的信息架构。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 2026-10-17 K16 注册限流可配置
|
||||
|
||||
## 范围
|
||||
|
||||
将 C 端注册的 IP/设备 24 小时限流阈值从硬编码改为后台 `sys_config` 配置:
|
||||
|
||||
```text
|
||||
app.register.max-per-ip-per-day
|
||||
app.register.max-per-device-per-day
|
||||
```
|
||||
|
||||
运行时优先读取后台配置;键缺失或非整数时回退 `@Value` 注入值(默认 5/3);
|
||||
值小于等于 0 时跳过对应 guard。注册接口、错误码和错误文案不变。
|
||||
|
||||
## 实现
|
||||
|
||||
1. `AppAuthService` 注入 `ConfigService`,从 `sys_config` 优先读取两个键;
|
||||
再用 `@Value` 注入 yml 值作为缺失/非法时的 fallback。
|
||||
2. `sql/data-phase1.sql` 幂等写入开发默认 0/0 和说明。
|
||||
3. `application.yml` 只声明 fallback(默认 5/3),运行时不以后台固化值为准。
|
||||
4. `docs/api-phase1.md` §1.1 补充后台配置键、缺省/非法回退和 0=不限制口径。
|
||||
5. 测试 mock `ConfigService`,覆盖自定义、0、缺省/非数字回退。
|
||||
6. 确认后台「系统设置」页面可访问 `/admin/configs`;若现有表单未展示新键,
|
||||
记录为待后续 Web 表单补充,不把配置固化回 yml。
|
||||
|
||||
## 验证与交付
|
||||
|
||||
- `mvn test`
|
||||
- `mvn -pl fruitshorts-app-api -am package -DskipTests`
|
||||
- 停止当前 8081 服务,启动新 jar,`/healthz` 通过
|
||||
- 使用新设备号连续注册至少 4 次,确认不再 429(接口自检)
|
||||
- 后台把 IP 上限临时改为 2:前两次成功,第三次返回 429;恢复 0 后继续注册成功
|
||||
- `feature/backend-register-rate-config-fix` 以 `--no-ff` 合并回 `develop` 并 push
|
||||
- PROGRESS 记录命令、原始输出和解读
|
||||
|
||||
## 风险
|
||||
|
||||
- 配置 0 会关闭注册滥用保护,仅用于当前开发环境;生产需在后台恢复 5/3。
|
||||
- 旧 8081 服务重启期间会有短暂不可用。
|
||||
- 限流仍基于客户端可伪造 IP/设备号,本次不放宽其他风控。
|
||||
@@ -0,0 +1,170 @@
|
||||
# K2 客户端第二批:分类 + 观看记录 + 收藏列表 + 下载授权管理
|
||||
|
||||
> 依据:`docs/plans/INBOX.md` 2026-10-17 `[待处理] K2` 条目。
|
||||
> 客户端基线:`ReelShort` `develop` / `00280ca`。
|
||||
> 接口唯一权威:`FruitServer/docs/api-phase1.md` §0、§1.3、§1.5、§1.6。
|
||||
> 本批只做 K2,完成后停下写回执,不连做 K3/K4。
|
||||
|
||||
## 一、范围与完成口径
|
||||
|
||||
### K2-A 分类
|
||||
|
||||
- 接 `GET /app/categories`,分类 id 全程按字符串处理。
|
||||
- 发现页分类条改为服务端分类;保留现有分类条样式。
|
||||
- 分类内容只从 `GET /app/discover/feed` 的 `sections[]` 中读取
|
||||
`code == "category-{id}"` 的分区,不在客户端拼装分类请求。
|
||||
- 分类在发现 feed 中没有对应分区时显示空态。
|
||||
- 保留 `hot` / `new` 入口与既有样式;其内容同样只按发现 feed 中对应 `code`
|
||||
分区读取,缺失时显示空态。
|
||||
|
||||
### K2-B 观看记录页
|
||||
|
||||
- 接 `GET /app/watch/recent?limit=20`、`DELETE /app/watch/progress/{dramaId}`、
|
||||
`DELETE /app/watch/progress`。
|
||||
- 最近记录先去重 `dramaId`,再按 id 补 `GET /app/dramas/{id}` 卡片数据。
|
||||
- 详情补取并发上限 4,同页按 `dramaId` 只请求一次。
|
||||
- 单条详情失败只影响该条:显示“加载失败”占位与单项重试,其余记录正常渲染。
|
||||
- 独立“观看记录”页:封面、剧名、集号、进度、更新时间;点击详情并续看;
|
||||
单条删除、清空均有二次确认;删除采用乐观更新,失败回滚并提示。
|
||||
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||
|
||||
### K2-C 收藏列表页
|
||||
|
||||
- 复用 `GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、
|
||||
`POST /app/favorites/toggle`,不增加分页参数。
|
||||
- 独立“我的收藏”页,复用卡片数据模型与列表卡片视觉;点击进详情。
|
||||
- 取消收藏采用乐观更新,失败回滚并提示。
|
||||
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||
|
||||
### K2-D 下载授权管理
|
||||
|
||||
- 接契约 §1.6 全部 8 个接口:
|
||||
`GET /app/downloads/policy`、`PUT /app/downloads/settings`、
|
||||
`POST /app/downloads`、`GET /app/downloads`、
|
||||
`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、
|
||||
`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads`。
|
||||
- 详情页与播放页增加下载入口;后端拒绝时直接展示后端 `message`。
|
||||
- 独立“我的下载”页:策略卡、授权列表、单条删除、清空、仅 Wi-Fi、最高清晰度。
|
||||
- 授权状态与任务状态完全使用后端字符串,客户端不新增状态枚举、不自行判断付费/配额。
|
||||
- 加载 / 空 / 失败可重试三态;未登录使用现有登录引导。
|
||||
- 明确不做:文件分片落盘、断点续传、离线播放、`licenseKey` 本地校验。
|
||||
`url` 与 `progress` 本批只提供并测试 API 方法,不驱动实际下载任务。
|
||||
|
||||
## 二、实施顺序与 TDD
|
||||
|
||||
1. 基线:从 `ReelShort/develop` 建 `feature/flutter-k2-library`。
|
||||
2. K2-A:先写分类解析、`category-{id}` 映射、无分区空态和筛选状态测试,
|
||||
确认红灯后实现;再跑该块门禁。
|
||||
3. K2-B:先写控制器测试覆盖详情去重、并发上限 4、单条失败降级、
|
||||
删除/清空乐观更新与回滚;确认红灯后实现 API、repository、controller、页面;
|
||||
再跑门禁。
|
||||
4. K2-C:先写独立收藏页取消收藏失败回滚、三态与登录引导测试,
|
||||
确认红灯后实现页面与导航;再跑门禁。
|
||||
5. K2-D:先写 DTO 字符串 ID/时间解析、策略/列表渲染、授权失败透传、
|
||||
设置更新、删除/清空和未登录测试;确认红灯后实现;
|
||||
`ApiClient` 仅补契约所需的 `PUT` 能力;再跑门禁。
|
||||
6. 三份 ARB 一次补齐并生成 l10n;全量跑
|
||||
`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
7. 门禁全绿后合并回 `develop` 并 push;不动 `main`。
|
||||
8. 在 `PROGRESS.md` 与 INBOX K2 条目追加回执;真机验证交用户执行。
|
||||
|
||||
## 三、预计影响文件
|
||||
|
||||
### 数据层
|
||||
|
||||
- 修改:`lib/core/data/remote/api_client.dart`(补 `put`)
|
||||
- 修改:`lib/core/data/models/watch_progress.dart`(补 `updatedAt`)
|
||||
- 修改:`lib/core/data/remote/watch_api.dart`(补清空)
|
||||
- 修改:`lib/core/data/repositories/watch_history_repository.dart`(补清空)
|
||||
- 新增:`lib/core/data/models/content_category.dart`
|
||||
- 新增:`lib/core/data/remote/category_api.dart`
|
||||
- 新增:`lib/core/data/repositories/category_repository.dart`
|
||||
- 新增:`lib/core/data/services/category_controller.dart`
|
||||
- 新增:`lib/core/data/models/watch_record.dart`
|
||||
- 新增:`lib/core/data/services/watch_records_controller.dart`
|
||||
- 新增:`lib/core/data/models/download_policy.dart`
|
||||
- 新增:`lib/core/data/models/download_license.dart`
|
||||
- 新增:`lib/core/data/remote/download_api.dart`
|
||||
- 新增:`lib/core/data/repositories/download_repository.dart`
|
||||
- 新增:`lib/core/data/services/download_controller.dart`
|
||||
|
||||
### 页面与共享 UI
|
||||
|
||||
- 修改:`lib/app.dart`(新仓库/控制器注入)
|
||||
- 修改:`lib/core/widgets/auth_gate.dart`(页面级登录引导复用)
|
||||
- 修改:`lib/pages/discover/discover_logic.dart`
|
||||
- 修改:`lib/pages/discover/discover_view.dart`
|
||||
- 修改:`lib/pages/home/home_view.dart`(我的页入口接线)
|
||||
- 修改:`lib/pages/mine/mine_view.dart`(观看记录、收藏、下载入口)
|
||||
- 修改:`lib/pages/drama_detail/drama_detail_view.dart`(下载入口)
|
||||
- 修改:`lib/pages/player/player_view.dart`(当前集下载入口)
|
||||
- 新增:`lib/pages/watch_records/watch_records_binding.dart`
|
||||
- 新增:`lib/pages/watch_records/watch_records_view.dart`
|
||||
- 新增:`lib/pages/favorites/favorites_binding.dart`
|
||||
- 新增:`lib/pages/favorites/favorites_view.dart`
|
||||
- 新增:`lib/pages/downloads/downloads_binding.dart`
|
||||
- 新增:`lib/pages/downloads/downloads_view.dart`
|
||||
- 视复用需要新增列表卡片/状态组件,预计位于 `lib/core/widgets/` 或各页面 `widgets/`。
|
||||
|
||||
### 文案
|
||||
|
||||
- 修改:`lib/l10n/app_en.arb`
|
||||
- 修改:`lib/l10n/app_zh.arb`
|
||||
- 修改:`lib/l10n/app_id.arb`
|
||||
- 重新生成:`lib/l10n/app_localizations*.dart`
|
||||
|
||||
### 测试
|
||||
|
||||
- 修改:`test/catalog/catalog_controller_test.dart` 及分类筛选相关测试
|
||||
- 修改:`test/discover/discover_logic_test.dart`
|
||||
- 修改:`test/mine/mine_logic_test.dart`
|
||||
- 修改:`test/library/favorite_controller_test.dart`
|
||||
- 新增:分类 API/控制器与 section 映射测试
|
||||
- 新增:观看记录 API、并发加载、单项降级、删除/清空回滚和页面三态测试
|
||||
- 新增:收藏独立页、取消收藏回滚、三态和登录引导测试
|
||||
- 新增:下载 DTO/API/controller/page 测试
|
||||
- 新增:未登录 `AuthGate` 页面测试
|
||||
|
||||
## 四、契约缺口(本批登记,不改契约/后端)
|
||||
|
||||
1. `GET /app/watch/recent` 未返回剧名与封面,客户端只能先取最近记录,
|
||||
再对去重后的 `dramaId` 调详情补齐卡片;代价是 N+1 请求。
|
||||
建议后续在 `ProgressRespVO` 补卡片字段(需 specs 负责人改契约)。
|
||||
2. `GET /app/favorites` 没有分页参数并返回全量数组,与
|
||||
`TC-RECORD-04` 的“分页正确”不一致。本批按全量列表实现,
|
||||
不自造 `page/size`。
|
||||
3. `api-phase1.md` 没有搜索接口,本批不做搜索,也不以本地模糊匹配冒充搜索接口。
|
||||
|
||||
## 五、风险与控制
|
||||
|
||||
1. 记录页 N+1 详情请求可能放大流量和延迟:严格去重、并发上限 4、页内缓存,
|
||||
单项失败降级,不在失败后自动无限重试。
|
||||
2. 页面销毁时异步详情仍可能返回:controller 的 `notify` 做 disposed 保护,
|
||||
不把迟到结果写回已退出页面。
|
||||
3. `updatedAt` 为后端 `LocalDateTime` 字符串,解析失败只能显示占位,
|
||||
不能让整条记录解析失败。
|
||||
4. 下载契约里授权与任务状态均为字符串,客户端只原样展示/映射现有文案,
|
||||
不根据未知值猜测业务状态。
|
||||
5. 下载地址字段 `playUrl` 后端当前未填充,本批不做实际下载;不能据此宣称
|
||||
“可离线播放”。
|
||||
6. 本机可能缺少 Android SDK,`flutter build apk --debug` 若失败将保留原始输出,
|
||||
回执标记“未验证”,不静默跳过。
|
||||
7. 真机/模拟器可达性由用户验证;开发机 `curl` 不作为设备证据。
|
||||
|
||||
## 六、待确认(不阻塞本批)
|
||||
|
||||
1. 发现页 `hot` / `new` 入口保留,但当前后端 `discoverFeed()` 只生成
|
||||
`category-{id}` 分区。若后续要展示热门/最新内容,需要后端在发现 feed 下发
|
||||
对应分区;在此之前按契约显示空态。
|
||||
2. 收藏分页与观看记录卡片字段待契约负责人后续补足;本批按 INBOX 降级方案执行。
|
||||
3. 下载真实落盘、断点续传与离线播放需要存储、权限、加密播放方案,另立批次。
|
||||
|
||||
## 七、完成定义
|
||||
|
||||
- 四块均有“红 → 绿”测试证据。
|
||||
- `dart analyze lib test` 零告警,`flutter test` 全过。
|
||||
- `flutter build apk --debug` 通过,或保留缺 SDK 的原始失败输出并标记未验证。
|
||||
- 三语言 ARB 齐全且生成物已更新。
|
||||
- 独立记录、收藏、下载页和分类条均接契约接口;未登录入口可完成登录引导。
|
||||
- 不改 `main`、不改 specs、不改 FruitServer 后端、不做搜索。
|
||||
- `PROGRESS.md` 与 INBOX K2 回执包含验证步骤、预期结果、提交号与原始门禁输出。
|
||||
@@ -0,0 +1,92 @@
|
||||
# 2026-10-17 K3 客户端第三批:互动
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本轮只完成 Flutter 客户端互动能力的真实接口接入:
|
||||
|
||||
- K3-A 评论:匿名游标列表、顶层评论与一层回复、发布、敏感词三档、删除、三态与重试。
|
||||
- K3-B 点赞:剧集与评论点赞、乐观更新、失败回滚、未登录拦截、防连点。
|
||||
- K3-C 举报:举报评论与短剧、固定原因单选、可选描述、成功与失败提示。
|
||||
- K3-D 分享回归:沿用现有系统分享,核对标题、简介与 footer 的三语文案。
|
||||
|
||||
不做 K4、拉黑、举报用户、后台治理、图片/表情/@提及、无限嵌套、我的评论列表和并发压测。
|
||||
后端代码、后台 Web、specs 仓库均不改;接口只使用 `FruitServer/docs/api-phase1.md` §1.7。
|
||||
|
||||
## 一之一、实测基线
|
||||
|
||||
- ReelShort:`develop` / `feature/flutter-k3-interaction` 均为 `45f5f25`,功能分支从 K2 合并点派生。
|
||||
- FruitServer:`develop` 为 `a0156ee`,比 `origin/develop` 多 1 个仅包含 INBOX 的派单提交。
|
||||
- 本计划文件在开工前的 FruitServer 工作区中已存在且尚未提交,本轮仅增量补充,不覆盖既有内容。
|
||||
|
||||
## 二、契约与数据口径
|
||||
|
||||
| 能力 | 接口 | 客户端处理 |
|
||||
| --- | --- | --- |
|
||||
| 评论列表 | `GET /app/comments?dramaId=&episodeId=&rootId=&cursor=&size=` | 首屏 20 条,按 `nextCursor` 续拉,用 `hasMore` 控制加载更多 |
|
||||
| 发布评论 | `POST /app/comments` | 顶层传 `dramaId/content`,回复额外传 `parentId` |
|
||||
| 删除评论 | `DELETE /app/comments/{id}` | 仅 `mine=true` 可操作,二次确认,失败恢复列表 |
|
||||
| 点赞切换 | `POST /app/likes/toggle` | `DRAMA` / `COMMENT`,以响应 `{liked,count}` 为最终态 |
|
||||
| 举报 | `POST /app/reports` | `COMMENT` / `DRAMA`,`reasonCode` 必填 |
|
||||
| 系统分享 | 无后端接口 | 保持 `PlatformShareService` |
|
||||
|
||||
评论字段按 `CommentRespVO` 解析,ID 全部使用字符串。所有新增用户可见文案进入
|
||||
`lib/l10n/app_{en,zh,id}.arb` 并重新生成。
|
||||
|
||||
## 三、已确认的契约缺口与降级
|
||||
|
||||
1. `AppDramaDetailVO` / `AppDramaCardVO` 没有 `likeCount` / `liked`。
|
||||
降级:不展示剧集点赞数字,只维护当前会话内的 liked 状态。
|
||||
建议:后端详情与卡片补 `likeCount` / `liked`,需 specs 负责人变更契约。
|
||||
2. §1.7 没有定义 `reasonCode` 枚举,后端当前不校验。
|
||||
降级:客户端固定 `SPAM / ABUSE / PORN / AD / OTHER` 五项,文案进三语 arb。
|
||||
建议:后端补 `reasonCode` 枚举与后台工单映射。
|
||||
3. §1.7 没有拉黑接口。
|
||||
降级:本批不做拉黑,不提供入口。
|
||||
|
||||
## 四、预计影响文件
|
||||
|
||||
FruitServer 文档:
|
||||
|
||||
- `docs/plans/2026-10-17-K3.md`
|
||||
- `docs/plans/PROGRESS.md`
|
||||
- `docs/plans/INBOX.md`
|
||||
|
||||
ReelShort 客户端:
|
||||
|
||||
- `lib/core/data/remote/comment_api.dart`
|
||||
- `lib/core/data/remote/interaction_api.dart`
|
||||
- `lib/core/data/repositories/comment_repository.dart`
|
||||
- `lib/core/data/repositories/interaction_repository.dart`
|
||||
- `lib/core/data/services/comment_controller.dart`
|
||||
- 互动相关模型、Provider 注入与页面/组件
|
||||
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||
- `lib/pages/player/player_view.dart`
|
||||
- 详情页举报入口及互动弹窗
|
||||
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||
- 对应 `test/**/*_test.dart`
|
||||
|
||||
实际文件以现有 K1/K2 分层和注入方式为准,避免无关重构。
|
||||
|
||||
## 五、TDD 与执行顺序
|
||||
|
||||
1. 只读核对 `ReelShort` 与 `FruitServer` 的 `develop` 基线、工作区状态与提交。
|
||||
2. 从 `develop` 建 `feature/flutter-k3-interaction`。
|
||||
3. K3-A:先写评论分页、发布、敏感词、删除、失败回滚和未登录测试并确认红灯,再实现并转绿。
|
||||
4. K3-B:先写剧集/评论点赞成功、计数、乐观更新、回滚和未登录测试并确认红灯,再实现并转绿。
|
||||
5. K3-C:先写举报成功、重复举报 message 透传和未登录测试并确认红灯,再实现并转绿。
|
||||
6. K3-D:补充分享文案回归检查,确认三语资源完整。
|
||||
7. 每块执行定向测试和 `dart analyze lib test`。
|
||||
8. 全量执行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
9. 分块提交,`--no-ff` 合并回 `develop` 并 push;文档单独提交并 push。
|
||||
10. 在 `PROGRESS.md` 与 INBOX 写回执,列出用户真机验证步骤,并明确设备侧取证未由 Codex 执行。
|
||||
|
||||
## 六、风险与待确认
|
||||
|
||||
- 评论游标的实际响应字段以已实现后端 VO 为准;若与任务书 `nextCursor/hasMore` 不一致,先停止并在 INBOX 顶部登记待协助,不自行扩契约。
|
||||
- 剧集点赞没有初始查询接口,页面重建后会话内状态可能丢失;本批按降级方案不伪造初始值。
|
||||
- 匿名列表可见,但发布、点赞、举报必须登录;需确保 AuthGate 在发起业务请求前拦截。
|
||||
- 举报重复与举报自己依赖后端 message,客户端只原样展示,不自行判断。
|
||||
- 敏感词 `REVIEW` 的 pending 评论仅自己可见,客户端必须保留发布成功返回内容并显示“审核中”。
|
||||
- 现有互动控制器为本地演示数据,移除时不得破坏 K2 的分类、记录、收藏、下载和播放进度逻辑。
|
||||
- 全量构建可能受本机 Flutter/Android SDK 环境影响;若失败会记录原始输出,不以开发机接口请求代替设备证据。
|
||||
- 当前没有待确认的产品问题;若执行中出现契约与实现不一致,按任务书先停并登记。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 2026-10-17 K5 客户端视觉:克莱因蓝主题 + 印尼市场适配
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本轮只完成 Flutter 客户端的主题色、蓝色收敛、印尼语 locale 和回归测试:
|
||||
|
||||
- K5-A:建立克莱因蓝 token 与浅色/深色显式 `ColorScheme`,主色统一 `#002FA7`。
|
||||
- K5-B:清理页面层的旧蓝字面量,统一使用 `AppColors` / `ColorScheme`;不改语义色。
|
||||
- K5-C:`LocaleController` 支持 `id`;未显式选择时跟随系统,非 `en/zh/id` 回退 `id`;
|
||||
两个现有语言入口增加 Bahasa Indonesia。
|
||||
- K5-D:补主题 token、对比度、硬编码扫描、locale 回退与语言切换测试,
|
||||
回归深色/浅色、三态和小屏布局。
|
||||
|
||||
不做 K4、支付、Google 登录、新接口、下载落盘、App 图标/启动图或信息架构调整;
|
||||
不改后端、`ReelShortWeb`、specs。
|
||||
|
||||
## 二、用户裁定色值
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `blue` | `#002FA7` | Klein Blue / IKB 主色 |
|
||||
| `bluePressed` | `#00227A` | 按下/激活态与大面积蓝色背景 |
|
||||
| `blueSoft` | `#5B7BE0` | 深色背景上的高亮/次级文字与图标 |
|
||||
| `onPrimary` | `#FFFFFF` | 主色上的文字/图标 |
|
||||
| `ink` | `#0B0C10` | 保持现值 |
|
||||
| `bar` | `#101114` | 保持现值 |
|
||||
| `card` | `#17191F` | 保持现值 |
|
||||
| `muted` | `#B7BDC9` | 保持现值 |
|
||||
|
||||
对比度口径:正文 ≥ 4.5:1,大字/图标 ≥ 3:1;错误红、成功绿、点赞红等语义色不改。
|
||||
|
||||
## 三、预计影响文件
|
||||
|
||||
- `lib/core/theme/app_colors.dart`
|
||||
- `lib/app.dart`
|
||||
- `lib/core/config/locale_controller.dart`
|
||||
- `lib/pages/mine/mine_logic.dart`
|
||||
- `lib/pages/home/widgets/home_quick_drawer.dart`
|
||||
- `lib/pages/home/home_view.dart`
|
||||
- `lib/pages/discover/discover_view.dart`
|
||||
- `lib/pages/drama_detail/drama_detail_view.dart`
|
||||
- `lib/pages/player/player_view.dart`
|
||||
- `lib/pages/player/player_logic.dart`
|
||||
- `lib/pages/player/widgets/seek_progress_bar.dart`
|
||||
- `lib/pages/player/widgets/comment_tile.dart`
|
||||
- `lib/pages/player/widgets/drama_comments_sheet.dart`
|
||||
- `lib/pages/mine/mine_view.dart`
|
||||
- `lib/pages/watch_records/watch_records_page.dart`
|
||||
- `lib/pages/downloads/downloads_page.dart`
|
||||
- `lib/pages/auth/auth_view.dart`
|
||||
- `lib/core/widgets/auth_gate.dart`
|
||||
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||
- `test/theme/*`、`test/config/*`、相关页面 widget 测试
|
||||
|
||||
实际改动以静态扫描结果为准;不改业务逻辑和页面布局。
|
||||
|
||||
## 四、TDD 顺序
|
||||
|
||||
1. 写主题 token/对比度、硬编码蓝色扫描、locale `id` 持久化与未知语言回退测试,确认红灯。
|
||||
2. K5-A:新增色值 token 和浅色/深色 `ColorScheme`,补主题测试转绿。
|
||||
3. K5-B:按扫描结果替换页面蓝色字面量,补语言入口和 widget 回归。
|
||||
4. K5-C:实现 `id` locale、系统语言跟随、未知系统语言回退 `id`,两个入口增加
|
||||
Bahasa Indonesia。
|
||||
5. K5-D:全量扫描、深色/浅色与小屏 widget 回归。
|
||||
6. 门禁:`dart analyze lib test`、`flutter test`、`flutter build apk --debug`。
|
||||
7. `--no-ff` 合并回 `develop` 并 push;FruitServer 仅提交 `docs/plans`。
|
||||
|
||||
## 五、风险与处理
|
||||
|
||||
- 克莱因蓝在浅色背景上作为文字可能对比不足:正文继续使用现有着色,
|
||||
主色主要用于按钮/图标/选中态;主色背景统一配 `onPrimary`。
|
||||
- `blueSoft` 仅用于深色背景高亮,避免在浅色背景当正文色造成低对比度。
|
||||
- 现有 `_orange` 等历史命名可能实际承载蓝色,替换时按语义改名,避免误导。
|
||||
- 直接替换主题色可能影响 FilledButton、Slider、Switch 等 Material 控件:
|
||||
通过显式 `ColorScheme` 和 widget 测试统一验证。
|
||||
- 语言入口两处需同步,避免“我的”和快捷抽屉行为不一致。
|
||||
- 真机、设备截图与系统语言切换证据由用户执行,Codex 不跑真机。
|
||||
|
||||
## 六、待确认
|
||||
|
||||
- 当前无产品歧义;按用户裁定的方案 A 色值执行。
|
||||
- specs 无成文色板,若后续提供 Stitch 视觉稿,再按新稿更新 token 与页面映射。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 2026-10-17 K6 客户端 UI:登录页 + 底部导航优化
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本批只优化 Flutter 登录页和首页底部导航,基于 K5 克莱因蓝主题:
|
||||
|
||||
- K6-A:登录页品牌区、autofill、密码显隐、键盘 next/done 提交、
|
||||
提交中禁用、主题语义错误色、小屏键盘可用性。
|
||||
- K6-B:底部导航背景/边框/选中态统一使用 K5 token,安全区适配,
|
||||
点击区域 ≥48pt,切换动效 ≤200ms。
|
||||
- K6-C:新增可注入 `Haptics` 封装,只对关键操作提供 selection/light/medium 反馈。
|
||||
- K6-D:新增 widget 测试并保持既有 Key、导航逻辑与沉浸隐藏行为。
|
||||
|
||||
不做忘记密码、Google/Apple 登录、隐私勾选、新 tab、页面信息架构调整;
|
||||
不改接口、业务校验、注册/登录流程、后端、Web 或 specs。
|
||||
|
||||
## 二、设计口径
|
||||
|
||||
- 主色:`AppColors.blue = #002FA7`。
|
||||
- 按下态/大面积激活背景:`AppColors.bluePressed = #00227A`。
|
||||
- 深色底高亮:`AppColors.blueSoft = #5B7BE0`。
|
||||
- 主色上的文字/图标:`AppColors.onPrimary = #FFFFFF`。
|
||||
- 错误提示使用 `ColorScheme.error/onError` 对应语义色。
|
||||
- 底栏背景使用现有 `AppColors.bar`,顶部边框使用主题 outline 或现有暗色 token;
|
||||
页面不新增色值字面量。
|
||||
|
||||
## 三、预计影响文件
|
||||
|
||||
- `lib/pages/auth/auth_view.dart`
|
||||
- `lib/pages/home/home_view.dart`
|
||||
- `lib/core/platform/haptics.dart`
|
||||
- `lib/app.dart`(Haptics Provider)
|
||||
- 收藏、点赞、评论、举报、下载、记录相关页面回调
|
||||
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||
- `test/auth/auth_page_test.dart`
|
||||
- `test/widget_test.dart`
|
||||
- 必要时补首页导航专用测试文件
|
||||
|
||||
不修改 `HomeLogic` 的 tab 顺序、选中规则和 Key;不动播放页沉浸状态来源。
|
||||
|
||||
## 四、交互与可用性
|
||||
|
||||
### 登录页
|
||||
|
||||
- 品牌区显示 App 名和印尼市场副标题,全部走 l10n 三语。
|
||||
- 邮箱 `TextInputAction.next`,提交后焦点进入密码框。
|
||||
- 密码 `TextInputAction.done`,提交后直接执行现有 `_submit()`。
|
||||
- 邮箱 `AutofillHints.email`,密码 `AutofillHints.password`。
|
||||
- 密码显隐按钮语义标签走 l10n,所有点击区域 ≥44pt。
|
||||
- `busy` 时禁用两个输入框、显隐按钮、提交和模式切换。
|
||||
- 使用 `resizeToAvoidBottomInset` + ListView/SafeArea,保证小屏键盘下
|
||||
submit 可滚动到可见区域。
|
||||
|
||||
### 底部导航
|
||||
|
||||
- 保持 4 个 tab 的数量、顺序、Key 和回调不变。
|
||||
- 选中态采用克制的背景胶囊(现有方案收敛)或短指示条,二选一即可;
|
||||
使用 `bluePressed` 胶囊 + `blueSoft` 高亮文字/图标。
|
||||
- 底栏高度 + SafeArea bottom 适配 home indicator。
|
||||
- 每个 tab 的可点击区域至少 48pt;动效控制在 200ms 内。
|
||||
- `logic.videoImmersive == true` 时保持 `bottomNavigationBar: null`。
|
||||
|
||||
### 点击震动反馈
|
||||
|
||||
- 使用 Flutter 内置 `HapticFeedback`,统一封装为 `Haptics` 抽象 +
|
||||
`FlutterHaptics` 默认实现,通过 Provider 注入,测试使用 fake。
|
||||
- tab 真正切换:`selectionClick()`。
|
||||
- 登录/注册提交、收藏/取消、点赞/取消、评论发送、举报提交、下载授权申请:
|
||||
`lightImpact()`。
|
||||
- 清空记录、清空下载、删除记录/授权:`mediumImpact()`。
|
||||
- 全部 `unawaited`;平台不可用时默认实现静默忽略;普通列表/滚动/页面切换不触发。
|
||||
|
||||
## 五、TDD 顺序
|
||||
|
||||
1. 新增登录页密码显隐、autofill、next/done 焦点、busy 禁用、错误语义色测试。
|
||||
2. 新增底栏 4 Key、tab 切换选中态、沉浸隐藏、最小点击区域和硬编码色扫描测试。
|
||||
3. 新增 fake Haptics 测试,覆盖 tab selection、主操作 light、危险操作 medium、
|
||||
普通列表不触发与默认实现异常静默。
|
||||
4. 先运行定向测试确认红灯,再实现登录页、底栏与 Haptics 转绿。
|
||||
5. 补 320×568 小屏与键盘弹出回归。
|
||||
6. 全量门禁:`dart analyze lib test`、`flutter test`、
|
||||
`flutter build apk --debug`。
|
||||
|
||||
## 六、风险与处理
|
||||
|
||||
- 登录页已有业务逻辑较长:仅提取品牌区和表单展示组件,不改
|
||||
`AuthLogic`、接口调用与校验规则。
|
||||
- 键盘焦点流转依赖 `FocusNode`:显式创建并释放,测试使用
|
||||
`tester.testTextInput.receiveAction(TextInputAction.next/done)`。
|
||||
- busy 状态下测试请求不能立即返回:使用可控 Fake Auth 或延迟 Future,
|
||||
验证输入和按钮禁用,再释放请求避免测试悬挂。
|
||||
- 底栏新增动画可能影响既有 `pumpAndSettle`:统一使用 ≤200ms,
|
||||
不引入第三方动画库。
|
||||
- Haptics 注入遗漏会让页面测试缺少 Provider:所有直接 pump 页面的测试
|
||||
统一注入 fake;生产默认实现吞掉平台不可用异常,不影响业务。
|
||||
- 安全区/小屏可能压缩按钮高度:使用最小点击区约束与
|
||||
`SafeArea(minimum: ...)`,保持现有 Key 可定位。
|
||||
|
||||
## 七、待确认
|
||||
|
||||
- specs 无 UI 设计稿,按“视觉统一 + 可用性”执行;不引入图片资源。
|
||||
- 当前无产品歧义;若真机出现键盘安全区差异,由用户提供设备侧截图后再做最小修正。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 2026-10-17 K7 首页轮播图 + 站内信通知
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本批只完成 Flutter 客户端两项能力:
|
||||
|
||||
- K7-A:增强现有 `CoverCarousel`,继续使用 `feed.banners` 和 3D 翻转风格,
|
||||
增加自动轮播、交互暂停、指示器、失败占位和语义。
|
||||
- K7-B:新增匿名站内信分页页,接入 `GET /app/notices`,在我的页增加通知入口。
|
||||
- K7-C:补轮播/通知/响应式与 locale 时间测试。
|
||||
|
||||
不做已读未读、红点、FCM、通知设置、`/app/config noticeEnabled`、首页铃铛、
|
||||
新 banner 接口、后端、Web 或 specs 修改。
|
||||
|
||||
## 二、契约口径
|
||||
|
||||
### Banner
|
||||
|
||||
- 数据源:`GET /app/home/feed` / `GET /app/discover/feed` 的 `banners[]`。
|
||||
- 无独立接口;空列表继续 `SizedBox.shrink()`。
|
||||
- 点击继续沿用 `linkType=DRAMA` + `linkValue` 的现有 `onOpen`。
|
||||
|
||||
### Notices
|
||||
|
||||
- `GET /app/notices?pageIndex=1&pageSize=20`,匿名。
|
||||
- 响应:`{total, list:[{id,title,content,type,publishAt,expireAt}]}`。
|
||||
- ID 全部按字符串;`total` 是数字。
|
||||
- `list.length` 用于判断当前页是否有数据;是否加载更多按
|
||||
“已加载数量 < total”判断。
|
||||
- `type` 原样展示,不建立客户端枚举映射。
|
||||
|
||||
## 三、预计影响文件
|
||||
|
||||
轮播:
|
||||
|
||||
- `lib/pages/discover/widgets/cover_carousel.dart`
|
||||
- `lib/pages/discover/discover_view.dart`
|
||||
- `lib/core/widgets/remote_image.dart`(可选失败图标参数)
|
||||
- `test/discover/cover_carousel_test.dart`
|
||||
|
||||
通知:
|
||||
|
||||
- `lib/core/data/models/notice.dart`
|
||||
- `lib/core/data/remote/notice_api.dart`
|
||||
- `lib/core/data/repositories/notice_repository.dart`
|
||||
- `lib/core/data/services/notices_controller.dart`
|
||||
- `lib/pages/notices/notices_page.dart`
|
||||
- `lib/pages/mine/mine_view.dart`
|
||||
- `lib/app.dart`(NoticeRepository Provider)
|
||||
- `test/notice/**`
|
||||
- `lib/l10n/app_{en,zh,id}.arb` 与生成文件
|
||||
|
||||
## 四、实现口径
|
||||
|
||||
### 轮播
|
||||
|
||||
- `Timer.periodic` 4.5s;单张不创建定时器;`dispose` 取消。
|
||||
- 使用 `TickerMode.of(context)`/`Visibility` 语义监听页面可见性;
|
||||
不可见时取消,恢复时重新启动。
|
||||
- pointer down 暂停,up/cancel 恢复;拖拽时沿用现有 3D 翻转。
|
||||
- 指示器采用底部圆点,使用 `AppColors.onPrimary` + `muted` 低透明度。
|
||||
- 语义标签进入三语 arb。
|
||||
- 图片失败使用现有 `RemoteImage` 并显示图标占位。
|
||||
|
||||
### 通知
|
||||
|
||||
- `NoticesController` 管理首屏、分页、加载更多、刷新、错误。
|
||||
- `NoticesPage` 深色页,保留 K5/K6 主题与 Haptics 不触发滑动振动。
|
||||
- 列表项标题/类型/时间/两行摘要;点击打开详情 Dialog。
|
||||
- 详情 `content` 用可滚动 `Text` 保留 `\n`,长内容不溢出。
|
||||
- `publishAt` / `expireAt` 使用现有 `formatLocalizedDateTime`。
|
||||
- 我的页 `_menuCard` 增加通知入口,匿名可进入。
|
||||
|
||||
## 五、TDD 顺序
|
||||
|
||||
1. 轮播:自动切页、单张无 Timer、指示器、触摸暂停、点击回调、失败占位。
|
||||
2. 通知 API/model:分页字段、字符串 ID、时间解析。
|
||||
3. Controller:首屏、追加、加载更多条件、空/失败/刷新。
|
||||
4. NoticesPage:三态、列表、分页、详情、长内容、小屏。
|
||||
5. 我的页入口与三语文案。
|
||||
6. 门禁:`dart analyze lib test`、`flutter test`、
|
||||
`flutter build apk --debug`。
|
||||
|
||||
## 六、风险与待确认
|
||||
|
||||
- 自动轮播 Timer 与 3D AnimationController 并存,需避免 dispose 后 setState。
|
||||
- 页面用 `IndexedStack` 保持状态,需通过 `TickerMode` 判断真实可见性。
|
||||
- `total` 可能在分页期间变化,以本次响应 total 与已加载数量比较。
|
||||
- 详情长内容必须可滚动,避免小屏 overflow。
|
||||
- `noticeEnabled` 属 `/app/config`,本批不接;通知入口暂时常驻,已登记后续。
|
||||
- 无独立 banner 接口和已读接口,不自行扩展契约。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 2026-10-17 K8 登录页视觉重设计
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
将现有登录页从表单堆叠改为深色、克莱因蓝品牌的单页视觉,保留 K6 已完成的
|
||||
AuthLogic、校验、接口、登录/注册流程、autofill、键盘流转、busy 状态和 Haptics。
|
||||
|
||||
- 客户端分支:`feature/flutter-k8-auth-visual`,基线 `develop 6cc5bfc`。
|
||||
- 文档分支:`docs/k8-auth-visual`,FruitServer 只改 `docs/plans/`。
|
||||
- 不改后端、Web、specs、接口契约、其他页面或业务逻辑。
|
||||
- 不新增图片、字体、第三方依赖;品牌图形使用 Material Icons。
|
||||
- 不跑真机,设备侧视觉、键盘与小屏验收由用户执行。
|
||||
|
||||
## 二、视觉与交互实现
|
||||
|
||||
1. 页面背景使用 `AppColors.ink`,顶部叠加克莱因蓝渐变光晕,颜色透明度不超过
|
||||
0.18,并忽略指针事件。
|
||||
2. 品牌区使用 48px 克莱因蓝圆角方块、白色剧集图标、App 名和现有
|
||||
`authBrandTagline`。
|
||||
3. 表单放入 20px 圆角、1px `outlineVariant` 边框、20px 内边距的
|
||||
`AppColors.card` 卡片。
|
||||
4. 输入框使用 52px 高、14px 圆角、`AppColors.bar` 填充,带邮箱/锁前缀图标;
|
||||
聚焦时显示 1.5px 主色边框并高亮图标;密码显隐按钮保留。
|
||||
5. 主按钮全宽 52px、26px 圆角、`AppColors.blue` 背景、白色 16/w700 文字,
|
||||
pressed 态使用 `bluePressed`,loading 为 18px 白色环,busy 时输入、切换与
|
||||
提交均禁用。
|
||||
6. 登录/注册改为双段 pill,实际可见标签为 `signIn` / `signUp`;点击调用现有
|
||||
`toggleMode()`。保留既有 `auth-switch-mode` 兼容入口,避免破坏既有测试。
|
||||
7. 错误提示使用红色 10% 透明背景、12px 圆角、图标和主题 error 色文字的胶囊。
|
||||
8. 首帧使用 220ms 的 `TweenAnimationBuilder` 完成淡入和上滑。
|
||||
9. 保留 `auth-scroll`、`auth-email`、`auth-password`;新增
|
||||
`auth-brand`、`auth-card`、`auth-mode-sign-in`、`auth-mode-sign-up`、
|
||||
`auth-submit`、`auth-error-capsule` 等稳定 Key。
|
||||
|
||||
## 三、影响文件
|
||||
|
||||
- `ReelShort/lib/pages/auth/auth_view.dart`
|
||||
- `ReelShort/test/auth/auth_visual_test.dart`(新增)
|
||||
- `ReelShort/test/auth/auth_page_test.dart`(既有用例保持不变)
|
||||
- `FruitServer/docs/plans/2026-10-17-K8.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`(完成后标记 K8 已处理)
|
||||
|
||||
## 四、TDD 与门禁
|
||||
|
||||
1. 先新增视觉测试并运行,确认分段控件、品牌、错误胶囊和尺寸断言红灯。
|
||||
2. 实现登录页视觉后,定向 auth 测试绿灯。
|
||||
3. 运行 `dart analyze lib test` 零告警。
|
||||
4. 运行 `flutter test` 全过。
|
||||
5. 构建前确认没有并发 `flutter run/build`;运行
|
||||
`flutter build apk --debug`。
|
||||
6. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。提交作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 五、风险与待确认
|
||||
|
||||
- 旧测试按文字链接和 `auth-switch-mode` TextButton 检查交互;本次通过保留
|
||||
兼容入口满足旧用例,同时可见交互必须由新 pill 分段控件完成。
|
||||
- 320px 小屏和键盘弹出时,卡片、错误胶囊及按钮需保持可滚动且不溢出。
|
||||
- 当前检测到 Android `flutter run` 会话;构建门禁前必须复查,若仍占用
|
||||
`build/` 则等待其结束,不能并发构建。
|
||||
- specs 无登录页设计稿,颜色与尺寸以本 INBOX 条目为准,无需新增契约。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 2026-10-17 K9 首页剧目列表改造(2 列全部剧目)
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
首页(发现 tab)移除「继续观看」区块,在分类 chips 下方以懒构建的 2 列网格展示
|
||||
当前分类的全部剧目;保留 header、轮播、分类、三态、空态和底部导航。
|
||||
|
||||
- 客户端分支:`feature/flutter-k9-home-grid`,基线 `develop 99f64ab`。
|
||||
- 文档分支:`docs/k9-home-grid`,FruitServer 基线 K9 任务书 `b434f65`。
|
||||
- 只改首页(发现页)、三语 l10n、对应 widget 测试与 `docs/plans/`。
|
||||
- 不改接口、数据模型、CategoryController 过滤逻辑、轮播、通知、观看记录、
|
||||
收藏、下载或底部导航。
|
||||
- 不新增图片、依赖或分页;继续使用 `RemoteImage` 懒加载。
|
||||
- 不跑真机,设备侧滚动、布局、分类切换和详情跳转由用户验收。
|
||||
|
||||
## 二、实现方案
|
||||
|
||||
1. 删除 `_continueHeader`、`_dramaContent`、`_continueTile`、`_ShowcaseCard`
|
||||
与 `_cardsFor`,首页不再构建或保留「继续观看」文案和代码。
|
||||
2. 将页面滚动容器改为 `CustomScrollView`,依次放置:
|
||||
carousel `SliverToBoxAdapter`、分类与标题 `SliverToBoxAdapter`、状态
|
||||
`SliverToBoxAdapter` 或剧目 `SliverGrid`、底部留白 `SliverToBoxAdapter`。
|
||||
3. 剧目网格使用 `SliverGridDelegateWithFixedCrossAxisCount`:
|
||||
`crossAxisCount: 2`、`crossAxisSpacing: 12`、`mainAxisSpacing: 16`、
|
||||
`childAspectRatio: 2 / 3`;左右由 `SliverPadding` 保持 16。
|
||||
4. 卡片封面使用 2:3 比例、14px 圆角、`RemoteImage` 和现有底部深色渐变;
|
||||
标题最多 2 行,类型最多 1 行。点击调用现有
|
||||
`logic.openCard(..., embedded, onDramaSelected, onAction)`。
|
||||
5. 数据直接使用 `CategoryController.visibleDramas`;`For You` 使用其全量
|
||||
去重结果。分类切换后由 ChangeNotifier 驱动 sliver 内容更新。
|
||||
6. 保留 `categories.loading` 首载进度、`categories.error` 重试、
|
||||
`noCategoryDramas` / `noSearchResults` 空态文案。
|
||||
7. l10n 新增 `allDramas`:
|
||||
`全部剧目` / `All Dramas` / `Semua Drama`,并重新生成 l10n。
|
||||
|
||||
## 三、影响文件
|
||||
|
||||
- `ReelShort/lib/pages/discover/discover_view.dart`
|
||||
- `ReelShort/lib/l10n/app_en.arb`
|
||||
- `ReelShort/lib/l10n/app_zh.arb`
|
||||
- `ReelShort/lib/l10n/app_id.arb`
|
||||
- `ReelShort/lib/l10n/app_localizations*.dart`(生成物)
|
||||
- `ReelShort/test/discover/home_all_dramas_grid_test.dart`(新增)
|
||||
- `ReelShort/test/widget_test.dart`(仅更新作废的「继续观看」断言)
|
||||
- `FruitServer/docs/plans/2026-10-17-K9.md`
|
||||
- `FruitServer/docs/plans/PROGRESS.md`
|
||||
- `FruitServer/docs/plans/INBOX.md`
|
||||
|
||||
## 四、测试与门禁
|
||||
|
||||
1. 先新增 K9 首页 widget 测试并取得红灯。
|
||||
2. 实现后验证:无「继续观看」、有「全部剧目」、2 列 sliver、左右/行列间距、
|
||||
2:3 封面、分类切换更新、空态与失败态。
|
||||
3. 运行 `dart analyze lib test`、`flutter test`、`flutter build apk --debug`;
|
||||
构建前检查无并发 `flutter run/build`。
|
||||
4. 完成后 `merge --no-ff` 回客户端 `develop` 并 push;FruitServer 文档同样
|
||||
合并推送。作者与提交者统一为
|
||||
`Zhou Weidong <502333560@qq.com>`。
|
||||
|
||||
## 五、风险与待确认
|
||||
|
||||
- 既有 `test/widget_test.dart` 有两个壳层用例断言首页出现
|
||||
`Continue Watching`。K9 明确要求该文案彻底消失,因此原断言与
|
||||
`find.text('Continue Watching'), findsNothing` 不可能同时成立。
|
||||
处理方式:保留两个既有用例及完整导航流程,只把已作废的文案断言更新为
|
||||
`All Dramas`,不删除任何测试用例。
|
||||
- 首页 feed 可能一次返回较多剧目;必须使用 `SliverGrid` 懒构建,不能嵌套
|
||||
`GridView(shrinkWrap: true)`。
|
||||
- 2:3 卡片在小屏需要保持标题 2 行和类型 1 行不溢出;测试覆盖 320px 宽度。
|
||||
- 不改 `CategoryController` 的数据语义;筛选仍由现有分区 code 决定。
|
||||
+2079
-2
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,10 @@ springdoc:
|
||||
path: /v3/api-docs
|
||||
|
||||
fruitshorts:
|
||||
auth:
|
||||
# 仅作 sys_config 缺失/非法时的兜底;运行时优先读取后台「系统配置」
|
||||
register-max-per-ip-per-day: ${REGISTER_MAX_PER_IP_PER_DAY:5}
|
||||
register-max-per-device-per-day: ${REGISTER_MAX_PER_DEVICE_PER_DAY:3}
|
||||
interaction:
|
||||
# MACHINE_FIRST:机审通过即发布,命中 REVIEW 级敏感词才转人工
|
||||
# PRE_REVIEW :全部先审后发(生产建议)
|
||||
|
||||
+72
-2
@@ -4,6 +4,7 @@ package com.fruitshorts.app.service;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||
import com.fruitshorts.system.service.ConfigService;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.entity.AppUserCredential;
|
||||
import com.fruitshorts.user.dal.entity.AppUserOtp;
|
||||
@@ -25,6 +26,7 @@ import com.fruitshorts.user.vo.TokenRespVO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -57,6 +59,7 @@ class AppAuthServiceTest {
|
||||
private CredentialHasher hasher;
|
||||
private JwtTokenProvider tokenProvider;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private ConfigService configService;
|
||||
private AppAuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -70,8 +73,11 @@ class AppAuthServiceTest {
|
||||
hasher = mock(CredentialHasher.class);
|
||||
tokenProvider = mock(JwtTokenProvider.class);
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
configService = mock(ConfigService.class);
|
||||
given(configService.get(anyString(), anyString()))
|
||||
.willAnswer(invocation -> invocation.getArgument(1));
|
||||
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder);
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -164,6 +170,7 @@ class AppAuthServiceTest {
|
||||
|
||||
@Test
|
||||
void should_reject_register_when_ip_reaches_the_daily_limit() {
|
||||
setRegisterLimits(5, 3);
|
||||
given(userMapper.selectCount(any())).willReturn(5L);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
|
||||
@@ -173,6 +180,62 @@ class AppAuthServiceTest {
|
||||
.hasMessageContaining("当前网络注册过于频繁");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_register_when_device_reaches_the_daily_limit() {
|
||||
setRegisterLimits(5, 3);
|
||||
given(userMapper.selectCount(any())).willReturn(3L);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
|
||||
.hasMessage("该设备注册过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_fallback_to_default_limit_when_config_is_not_numeric() {
|
||||
ReflectionTestUtils.setField(authService, "maxRegisterPerIpPerDay", 2);
|
||||
given(configService.get("app.register.max-per-ip-per-day", "2")).willReturn("invalid");
|
||||
given(userMapper.selectCount(any())).willReturn(2L);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
|
||||
.hasMessageContaining("当前网络注册过于频繁");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_fallback_to_default_limit_when_config_is_missing() {
|
||||
given(userMapper.selectCount(any())).willReturn(5L);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()))
|
||||
.hasMessageContaining("当前网络注册过于频繁");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_limit_by_ip_when_ip_limit_is_zero() {
|
||||
stubHappyRegister();
|
||||
setRegisterLimits(0, 3);
|
||||
given(userMapper.selectCount(any())).willReturn(2L);
|
||||
|
||||
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
|
||||
.isEqualTo("access-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_limit_by_device_when_device_limit_is_zero() {
|
||||
stubHappyRegister();
|
||||
setRegisterLimits(5, 0);
|
||||
given(userMapper.selectCount(any())).willReturn(4L);
|
||||
|
||||
assertThat(authService.register(emailRegister("abcd1234", null), "203.0.113.8").getAccessToken())
|
||||
.isEqualTo("access-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_tokens_when_password_matches() {
|
||||
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
|
||||
@@ -265,7 +328,14 @@ class AppAuthServiceTest {
|
||||
given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else");
|
||||
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
|
||||
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder);
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService);
|
||||
}
|
||||
|
||||
private void setRegisterLimits(int perIp, int perDevice) {
|
||||
given(configService.get("app.register.max-per-ip-per-day", "5"))
|
||||
.willReturn(String.valueOf(perIp));
|
||||
given(configService.get("app.register.max-per-device-per-day", "3"))
|
||||
.willReturn(String.valueOf(perDevice));
|
||||
}
|
||||
|
||||
private RegisterReqVO emailRegister(String password, String code) {
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-system</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+33
-9
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||
import com.fruitshorts.system.service.ConfigService;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.entity.AppUserConsent;
|
||||
import com.fruitshorts.user.dal.entity.AppUserCredential;
|
||||
@@ -25,6 +26,7 @@ import com.fruitshorts.user.vo.LogoutReqVO;
|
||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
import com.fruitshorts.user.vo.TokenRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -58,6 +60,15 @@ public class AppAuthService {
|
||||
private final CredentialHasher hasher;
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ConfigService configService;
|
||||
|
||||
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
||||
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
|
||||
private int maxRegisterPerIpPerDay = 5;
|
||||
|
||||
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
||||
@Value("${fruitshorts.auth.register-max-per-device-per-day:3}")
|
||||
private int maxRegisterPerDevicePerDay = 3;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO register(RegisterReqVO req, String clientIp) {
|
||||
@@ -301,10 +312,9 @@ public class AppAuthService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 每个 IP 24 小时内最多注册几个账号 */
|
||||
private static final int MAX_REGISTER_PER_IP_PER_DAY = 5;
|
||||
/** 每台设备 24 小时内最多注册几个账号 */
|
||||
private static final int MAX_REGISTER_PER_DEVICE_PER_DAY = 3;
|
||||
private static final String REGISTER_IP_LIMIT_KEY = "app.register.max-per-ip-per-day";
|
||||
private static final String REGISTER_DEVICE_LIMIT_KEY = "app.register.max-per-device-per-day";
|
||||
|
||||
private static final int PASSWORD_MIN_LENGTH = 8;
|
||||
private static final int PASSWORD_MAX_LENGTH = 64;
|
||||
private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
|
||||
@@ -328,7 +338,7 @@ public class AppAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。
|
||||
* 注册频控(邮箱免码注册必须带的兜底):阈值由配置提供,≤0 时跳过对应限制。
|
||||
*
|
||||
* 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表:
|
||||
* 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。
|
||||
@@ -336,26 +346,40 @@ public class AppAuthService {
|
||||
*/
|
||||
private void guardRegisterRisk(String clientIp, String deviceId) {
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(1);
|
||||
int ipLimit = registerLimit(REGISTER_IP_LIMIT_KEY, maxRegisterPerIpPerDay);
|
||||
int deviceLimit = registerLimit(REGISTER_DEVICE_LIMIT_KEY, maxRegisterPerDevicePerDay);
|
||||
String ip = AppIdentifier.nullSafe(clientIp);
|
||||
if (StringUtils.hasText(ip)) {
|
||||
if (ipLimit > 0 && StringUtils.hasText(ip)) {
|
||||
Long byIp = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(AppUser::getRegisterIp, ip)
|
||||
.ge(AppUser::getCreatedAt, since));
|
||||
if (byIp != null && byIp >= MAX_REGISTER_PER_IP_PER_DAY) {
|
||||
if (byIp != null && byIp >= ipLimit) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
String device = AppIdentifier.nullSafe(deviceId);
|
||||
if (StringUtils.hasText(device)) {
|
||||
if (deviceLimit > 0 && StringUtils.hasText(device)) {
|
||||
Long byDevice = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(AppUser::getRegisterDeviceId, device)
|
||||
.ge(AppUser::getCreatedAt, since));
|
||||
if (byDevice != null && byDevice >= MAX_REGISTER_PER_DEVICE_PER_DAY) {
|
||||
if (byDevice != null && byDevice >= deviceLimit) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int registerLimit(String key, int defaultValue) {
|
||||
String raw = configService.get(key, String.valueOf(defaultValue));
|
||||
if (!StringUtils.hasText(raw)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(raw.trim());
|
||||
} catch (NumberFormatException ex) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private AppUser createUser(RegisterReqVO req, String clientIp) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
|
||||
@@ -324,3 +324,12 @@ CROSS JOIN sys_menu m
|
||||
WHERE r.code = 'super_admin'
|
||||
AND m.id IN (31, 32, 33)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- K16:注册频控后台配置
|
||||
-- ============================================================
|
||||
-- 0 = 不限制;生产建议 IP 5 / 设备 3。
|
||||
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
|
||||
(3, 'app.register.max-per-ip-per-day', '0', '0=不限制;生产建议 5'),
|
||||
(4, 'app.register.max-per-device-per-day', '0', '0=不限制;生产建议 3')
|
||||
ON CONFLICT (config_key) DO NOTHING;
|
||||
Reference in new issue
Block a user