iOS 提交

This commit is contained in:
Developer
2026-06-27 04:57:00 +08:00
parent 10a917adf6
commit b8d0bd39b5
20 changed files with 5403 additions and 247 deletions

View File

@@ -6,6 +6,271 @@
***
## [v6.144.0] - 2026-06-27
### 🐛 修复句子广场已收藏句子重复出现且未显示状态 + 按钮轮廓不清
#### 问题现象
1. 句子广场中已收藏的句子在循环加载时重复出现,且未显示"已收藏"状态
2. 未点赞/未收藏时按钮轮廓过淡(边框 0.5px + 25% white用户无法辨识可点击区域
#### 根因分析
1. **服务端根因**`/api/feed/list` 接口 `_batchAttachInteractionCounts` 只回填聚合计数like_count 等),不返回当前用户的 `is_liked`/`is_favorited` 状态。客户端 `HomeSentence.fromFeedItem` 解析时降级为 false导致已收藏句子重复出现时显示"未收藏"。
2. **客户端根因**`fetchRefreshSentences`/`fetchNewSentences` 直接使用服务端返回的 `isLiked`/`isFavorited`,未合并本地 DB 的互动状态。未登录用户服务端始终返回 false本地 DB 是唯一状态来源但未被读取。
3. **循环加载根因**`cycleRound >= 3``allSeenIds.clear()` 清空所有已见 ID已收藏句子的去重记录也一并清除导致循环加载时已收藏内容重复出现。
4. **按钮样式根因**`LiquidGlassActionButton` 未激活态边框仅 0.5px + 25% white 透明度,视觉上几乎不可见。
#### 服务端修复
**`docs/toolsapi/application/api/controller/Feed.php`**
- `_batchAttachInteractionCounts(&$items, $userId = 0)`:新增 `$userId` 参数,当用户已登录时批量查询 `feed_interaction` 表中该用户的 like/favorite 记录,回填 `is_liked`/`is_favorited` 字段。
- `list()` 方法:调用处从 `$this->_batchAttachInteractionCounts($items, $userId)` 改为 `$this->_batchAttachInteractionCounts($items, $this->_getUserId())`(修复未定义变量 `$userId`)。
- `_batchLoadFeedItems()` 方法:同样传入 `$this->_getUserId()`
- **缓存隔离**:缓存命中后重新调用 `_batchAttachInteractionCounts` 按当前用户重查 per-user 字段;缓存写入前剥离 `is_liked`/`is_favorited` 为 false避免不同用户串状态。
#### 客户端修复
**`lib/core/storage/database/app_database.dart`**
- 新增 `getSentencesByIds(List<String> ids)` 批量查询方法,返回 `Map<String, Sentence>`,用于合并本地 DB 互动状态。
**`lib/features/home/providers/home_feed_mixin.dart`**
- 新增 `_mergeLocalInteractionState(sentences)` 私有方法:批量查询本地 DB采用 OR 合并策略(`local true wins`)——服务端 true 或本地 true 均为 true。覆盖未登录场景服务端始终 false本地 DB 是唯一来源)和登录场景(本地兜底未同步的离线操作)。
- `fetchRefreshSentences``result.list.map(HomeSentence.fromFeedItem)` 后调用 `_mergeLocalInteractionState` 合并本地状态。
- `fetchNewSentences`:同上,生成 `mergedSentences` 供后续去重/循环加载使用。
- `cycleRound >= 3` 循环加载:清空 `allSeenIds`/`allSeenTexts` 前保留已收藏句子的 ID 和文本,恢复到去重集合中;`recentIds`/`recentTexts` 合并已收藏内容,确保循环加载时跳过已收藏句子。
**`lib/features/home/presentation/home_sentence_card.dart`**
- `LiquidGlassActionButton` 未激活态:边框宽度 0.5px → 1.2px;背景透明度加深(深色 8%→15% white浅色 18%→28% white边框透明度加深深色 12%→30% white浅色 25%→50% white
#### 验证
- `flutter analyze` 三个修改文件 0 issues
- curl 验证服务端:`limit=8` 非缓存路径返回 `"is_liked":false,"is_favorited":false`(未登录正确);缓存命中路径 `"成功(cache)"` 同样返回字段(重新附加 per-user 状态)
- 缓存隔离验证:缓存写入前剥离 per-user 字段,命中后按当前用户重查,杜绝跨用户串状态
#### 不足与建议
- **缓存命中额外查询**:每次缓存命中调用 `_batchAttachInteractionCounts` 会重新查询聚合计数(冗余),可后续拆分为仅查询 per-user 状态的轻量方法。
- **跨设备同步延迟**:未登录用户依赖本地 DB登录用户首次加载时服务端返回正确状态但缓存命中时 per-user 状态依赖 `_batchAttachInteractionCounts` 重查,若 DB 负载高可能增加延迟。
- **循环加载已收藏保留策略**:当前保留全部已收藏 ID 在 `allSeenIds` 中,若用户收藏量极大(>300`_buildSeenIdList` 会截断为最近 300 条,早期收藏可能再次出现。可考虑按收藏时间分页保留。
***
## [v6.143.0] - 2026-06-27
### 🐛 修复 lite 模式点赞/取消点赞后 like_count 不更新的问题
#### 问题现象
- 主页句子广场lite 模式limit=5点赞或取消点赞后like_count 不变化
- curl 实测unlike 后 lite list 返回 `msg=成功(cache)`like_count 仍为旧值
- 非 lite 模式limit=10/20正常仅 lite 模式 + limit=5 异常
#### 根因分析(三层 bug
1. **真正根因(核心)**`_clearFeedCache``$limits = [10, 20, 30, 50]` **不包含 5**,而客户端 lite 模式请求用 limit=5导致 `feed_list_hitokoto_newest_1_5_lite` 这个 cacheKey 从未被清理。互动操作后旧缓存仍然命中,返回过期数据。
2. **潜在 bug已兜底**`Cache::rm` 在某些环境opcache 缓存旧代码 / ThinkPHP complex 驱动异常)下不删除文件。诊断脚本中 `Cache::rm` 返回 true 但文件仍在。
3. **历史遗漏**`_clearFeedCache` 之前未覆盖 `_lite` 后缀的 cacheKeyv6.142.0 已加,但 limit=5 才是关键阻塞点)。
#### 服务端修复
**`docs/toolsapi/application/api/controller/Feed.php`**
- `_clearFeedCache``$limits``[10, 20, 30, 50]` 扩展为 `[5, 10, 15, 20, 25, 30, 40, 50]`,覆盖客户端所有可能的 limit 值(含 5。同步修复 `_clearWeightCache` 的同名数组。
- 新增 `_unlinkCacheFile($cacheKey)` 私有方法:在 `Cache::rm` 之后兜底,按 ThinkPHP 5 File 驱动路径规则(`CACHE_PATH/前2位md5/剩余30位md5.php`)直接 `unlink` 缓存文件,绕过 opcache 缓存旧代码 / complex 驱动异常。
- `_clearFeedCache` 所有 `Cache::rm` 调用后均追加 `$this->_unlinkCacheFile()` 兜底,覆盖 singleKeys / list 列表 / trending / 平台后缀 全部缓存 key。
- 移除 v6.142.0 排查期间遗留的 7 处临时调试代码(`file_put_contents` 写入 unlike_debug/clearcache_debug/feed_cache_debug 日志)。
#### 验证
自动化测试脚本 `/tmp/test_lite_cache.py` 全流程验证通过:
```
[stepA] 预热: msg='成功' feed_id=8812 like_count=3
[stepB] like: msg='操作成功'
[stepC] like后: msg='成功' like_count=4 期望=4 cache命中=False [OK]
[stepD] unlike: msg='操作成功'
[stepE] unlike后: msg='成功' like_count=3 期望=3 cache命中=False [OK]
=== 全部通过: lite 模式缓存清理修复成功 ===
```
关键验证点:
- stepC/stepE 的 `msg` 不再包含 `cache`,证明缓存已被清理、强制重新查库
- like_count 正确递增3→4和递减4→3
#### 排查过程经验
- 最初误判"unlike 不递减",实际是缓存竞态。加 sleep 2 秒重测发现 unlike 本身工作正常。
- 调试日志file_put_contents全部为空误导排查方向。根因是 opcache 缓存了旧 Feed.php不含调试日志的版本PHP-FPM 重启后 opcache 未彻底清理。通过 `opcache_invalidate(Feed.php, true)` + `opcache_reset()` 解决。
- 最终通过在 `_unlinkCacheFile` 中加 trace 日志,对比 `newest_1_5_lite``newest_1_10_lite` 的记录,发现 limit=5 的 key 从未被处理,定位到 `$limits` 数组遗漏。
#### 文档同步
- `API_FEED_DOC.md` 头部版本号 v2.4 → v2.5
- 修正 v6.142.0 中关于 unlike 的错误结论(标注为已修复,指向本版本)
#### 不足与建议
- **$limits 仍为枚举**:若未来客户端新增 limit=35 等值,需同步更新数组。建议后续改为 `range(5, 50, 5)` 或用 glob 扫描缓存文件前缀的方式彻底规避。
- **opcache 自动清理依赖**:当前依赖 `validate_timestamps=1` + `revalidate_freq=3`,文件更新后最多 3 秒延迟。若部署后 3 秒内有请求命中旧 opcache仍可能用旧代码。建议部署脚本中调用 `opcache_reset` 或重启 PHP-FPM。
- **Cache::rm 与 unlink 双写**:当前每次互动操作执行约 320 次 `Cache::rm + unlink`,性能可接受但略显冗余。可考虑用 `Cache::clear()` 清空整个缓存目录,但会影响其他模块缓存命中率。
***
## [v6.142.0] - 2026-06-27
### 🐛 修复主页句子广场点赞按钮显示 0 的问题
#### 根因分析(双重 bug
1. **服务端根因**`/api/feed/list` 接口 `_formatItem` 默认不返回 `like_count/favorite_count/comment_count/share_count` 字段,只有 `/api/feed/detail` 才附加。客户端 `FeedItem.fromJson` 解析 `null` → 默认 0 → 按钮显示 0。
2. **客户端根因**`toggleLike`/`toggleFavorite` 乐观更新时只翻转 `isLiked/isFavorited`,未递增/递减 `likeCount/favoriteCount`,导致即使点击点赞数字也不变。
#### 服务端修复
**`docs/toolsapi/application/api/controller/Feed.php`**
- 新增 `_batchAttachInteractionCounts(&$items)` 私有方法:
- 收集所有 `(feed_type, feed_id)`
- 按 feed_type 分组批量查询 `feed_interaction` 表,`GROUP BY feed_id, action` 一次性获取 like/favorite/comment/share 计数
- 回填到 items 的 `like_count/favorite_count/comment_count/share_count` 字段
- 避免 N+1 查询,列表接口性能影响极小
- `list()` 方法末尾调用,`_batchLoadFeedItems()` 末尾也调用(覆盖 favorites/likes/history/readlater 列表)
- 查询失败时降级填充 0保证字段始终存在客户端不再解析 null
#### 客户端修复
**`lib/features/home/providers/home_interaction_mixin.dart`**
- `toggleLike` 乐观更新:除翻转 `isLiked` 外,同步递增/递减 `likeCount`(点赞 +1取消点赞 -1下限 0
- `toggleFavorite` 乐观更新:除翻转 `isFavorited` 外,同步递增/递减 `favoriteCount`(举一反三,同类问题)
- `_persistLikeLocally` 兜底插入分支:调用新增的 `setLikesCount` 同步写入本地 DB 的 `likes` 字段
**`lib/core/storage/database/app_database.dart`**
- 新增 `setLikesCount(id, count)` 方法:
- 由于 `.g.dart` 未重新生成(`build_runner` 受 freezed/analyzer 版本冲突阻塞),`SentencesCompanion` 缺少 `likes` 命名参数
- 改用 `customStatement` 直接执行 SQL`UPDATE sentences SET likes = ?, updated_at = ? WHERE id = ?`
- 自动 clamp 负数为 0
**`lib/features/home/providers/home_sentence_model.dart`**
- `HomeSentence.fromDb` 添加注释说明 `likes` 字段未读取原因Sentence 类缺 `likes` getter.g.dart 未重新生成)
- 兜底场景保持 likeCount=0主场景由服务端返回真实数据
#### 验证
- curl 实测 `/api/feed/list` 修复前:`like_count= MISSING favorite_count= MISSING`
- curl 实测 `/api/feed/list` 修复后:`like_count= 4 favorite_count= 3 comment_count= 0`(真实数据)
- 端到端验证:登录 → 查询 detaillike_count=4, is_liked=True→ list 接口返回一致 like_count=4
- `flutter analyze` 三个修改文件零警告
#### 文档同步
- `API_FEED_DOC.md` 头部版本号 v2.3 → v2.4
- 3.4 节响应字段说明新增 v2.4 更新提示
#### 不足与建议
- ~~**服务端 unlike 潜在 bug**curl 实测 unlike 后 list 接口的 like_count 仍为 4没有递减。可能 feed_interaction 表的 unlike 操作未真正删除记录(待排查 action 方法逻辑)。~~ **[v6.143.0 已修复]** 实际根因是 `_clearFeedCache``$limits` 数组不含 5导致 lite 模式 limit=5 的缓存未被清理,详见 v6.143.0。
- **`build_runner` 阻塞**:导致 `SentencesCompanion`/`Sentence` 类缺少 `likes` 字段,本地兜底场景 likeCount 显示 0。建议解决 freezed 3.2.5/analyzer 12.1.0 版本冲突后重新生成。
- **可扩展**`_batchAttachInteractionCounts` 可进一步附加 `is_liked`/`is_favorited` 字段需登录用户ID让 list 接口也能返回用户个人互动状态,避免客户端依赖 detail 接口。
***
## [v6.141.0] - 2026-06-27
### ✨ LiquidGlassActionButton 深度升级 + 触觉音效 + 长按交互
#### 背景
v6.140.0 完成句子卡片按钮重设计与收藏 Bug 修复后,识别出 5 项不足。本版本逐一解决。
#### 改进清单
##### 1. 真正的 BackdropFilter 液态玻璃
**`lib/features/home/presentation/home_sentence_card.dart`**
- `LiquidGlassActionButton` 未激活态改用真正的 `BackdropFilter` + `ImageFilter.blur`
- sigma 计算:`GlassTokens.baseBlur * ext.glassBlurMultiplier`(按钮较小用 base 层 10.0
- 性能降级链路:
- 鸿蒙端 `OhosDeviceCapabilities.supportsBackdropFilter` 判断
- `PerformanceOptimizer.instance.shouldEnableBackdropFilter` 省电模式降级
- `PerformanceOptimizer.instance.suggestedBlurSigma()` 低端设备 sigma 缩放
- `RepaintBoundary` 隔离重绘,避免列表滚动重复模糊
- 激活态仍用渐变填充(无需模糊)
##### 2. 统一动画驱动
- 移除 `AnimatedScale` + `ScaleTransition` 双重叠加
- 改为单一 `AnimatedBuilder` + `Transform.scale` 统一缩放整个按钮icon + 文字)
- 动画曲线 `Curves.easeInOutBack`,时长 400ms
##### 3. 跨模块文案依赖修复
- `t.discover.base.favorite/favorited``t.home.sentenceDetail.favorite/favorited`
- home 模块不再依赖 discover 模块翻译,符合 AGENTS.md 架构约束
- `THomeBase` 无 favorite 字段,但 `TSentenceDetail`home.sentenceDetail已有完整字段
##### 4. 稍后读同类 Bug 排查(无需修复)
- 调查发现 `_persistReadLaterLocally` **不写 sentences 表**
- 稍后读状态通过 `ChatMessageService.sendReadLaterSentence` 写入 ChatMessage 表(会话 ID `'readlater'`
- 取消时调 `db.softDeleteChatMsgRecord` 软删除
- 已有 try-catch + `inserted = true` 兜底,逻辑鲁棒
- **结论**:稍后读不存在 UPDATE 静默失败问题,无需修复
##### 5. HapticService + SfxService 触觉音效接入
**`lib/features/home/presentation/home_sentence_card.dart`**
- 新增 `HapticType` 枚举like / favorite
- `LiquidGlassActionButton` 新增 `hapticType` 参数
- 激活态:`HapticService.medium()` + `SfxService.instance.play(SfxType.like/favorite)`
- 取消态:`HapticService.light()` + `SfxService.instance.play(SfxType.unlike/unfavorite)`
- 反馈在 `didUpdateWidget` 中根据状态变化触发,与动画同步
##### 6. 长按卡片触发重冲击
**`lib/core/utils/ui/interaction_animations.dart`**
- `PressableCard` 新增 `onLongPress` 参数
- 长按触发时自动回弹(避免停留在按压态)
**`lib/features/home/presentation/home_sentence_card.dart`**
- `SentenceCard` 接入长按:`HapticService.heavy()` + 触发详情面板
- 与点击效果一致,但提供差异化触觉反馈(重冲击 vs 无冲击)
##### 7. 服务端 /api/feed/favorites 验证
- 服务端代码审查:`Feed.php::_formatItem` 第 2186 行明确返回 `'feed_type' => $type`
- 客户端解析:`FeedItem.fromJson` 第 295 行 `feedType: json['feed_type'] as String? ?? ''`
- 接口在线验证curl 返回 401未登录非 404接口正常
- **结论**feed_type 字段链路完整,无需修复
#### 验证
- `flutter analyze` 通过0 error / 0 warning
- 2 个修改文件的 IDE 诊断全部通过
#### 改动文件
| 文件 | 改动 |
|------|------|
| [home_sentence_card.dart](file:///Users/wushu/Documents/trae_projects/project/xianyan/lib/features/home/presentation/home_sentence_card.dart) | LiquidGlassActionButton 重构 + HapticType + 长按接入 + 文案改用 home.sentenceDetail |
| [interaction_animations.dart](file:///Users/wushu/Documents/trae_projects/project/xianyan/lib/core/utils/ui/interaction_animations.dart) | PressableCard 新增 onLongPress 支持 |
---
## [v6.140.0] - 2026-06-27
### 🐛 修复句子广场收藏在「我的收藏」页面不显示的 Bug + 卡片按钮重设计
#### 背景
用户反馈主页「句子广场」的句子卡片点击收藏后,在「我的收藏」页面没显示收藏的句子。同时要求重新设计点赞/收藏按钮为长方形 icon+文字 样式,去掉分享 icon 按钮。
#### Bug 根因(多重故障叠加)
1. **本地数据库 UPDATE 静默失败**`home_interaction_mixin._persistFavoriteLocally` 直接调用 `interactionDb.toggleFavorite(id)` UPDATE若 sentences 表中不存在该 id 的记录Feed 列表未缓存/缓存被清UPDATE 静默失败,本地 isFavorite 字段从未被设置。
2. **Legacy API 硬编码 `targetType: 'article'`**`favorite_repository._loadLegacyFavorites` 中硬编码 `targetType: 'article'`,但句子广场的 feedType 是 `hitokoto`/`chengyu`/`hanzi`/`poetry` 等,降级 Legacy API 永远查不到句子收藏。
3. **`mergeWithLocalDb` 去重 key 不鲁棒**:仅用 `content+author` 作为去重 key空 content 时退化为 `|title`,多条空 content 条目互相误去重;且未利用 id 信息服务端和本地同一句子id 相同但 content 微小差异)会被当作两条。
4. **降级条件过激**`_loadFeedFavorites``result.list.isEmpty && result.total == 0 && page == 1` 触发立即降级 Legacy API在服务端 bug / 缓存延迟 / 新用户无收藏等场景下误降级。
#### 修复
| 文件 | 修复 |
|------|------|
| `lib/features/home/providers/home_interaction_mixin.dart` | `_persistFavoriteLocally` 改为先查再决定 `setFavoriteFlag``insertOrUpdateSentence` upsert 兜底;举一反三同步修复 `_persistLikeLocally` 相同问题 |
| `lib/core/storage/database/app_database.dart` | 新增 `setLikeFlag(id, value)` 方法(与 `setFavoriteFlag` 对称),避免 `toggleLike` 在记录不存在时静默失败 |
| `lib/features/home/favorite_repository.dart` | `_loadLegacyFavorites` 移除硬编码 `targetType: 'article'`,让服务端返回所有类型收藏 |
| `lib/features/home/favorite_repository.dart` | `mergeWithLocalDb` 改为双重去重策略:主 key 用 `targetType\|targetId`,副 key 用 `content\|title`(空 content 不参与去重) |
| `lib/features/home/favorite_repository.dart` | `_loadFeedFavorites` 空结果时先检查本地数据库,本地有则兜底返回,避免误降级 Legacy API |
#### 卡片按钮重设计(方案 C · 液态玻璃)
**`lib/features/home/presentation/home_sentence_card.dart`**
- 移除分享 icon 按钮(分享功能保留在左滑手势和详情面板入口)
- 移除 `_shareAsImage` / `_shareAsText` / `_isSharing` / `_repaintKey` 及相关 importdart:io / dart:ui / path_provider / share_plus / kIsWeb
- 新增 `LiquidGlassActionButton` 组件iOS 26 Liquid Glass 风格):
- 长方形 icon + 文字 按钮
- 未激活:半透明玻璃材质 + 微高光 + 中性色
- 已激活:渐变填充 + 内嵌高光 + 投射阴影 + 白色 icon/文字
- 点击触发弹跳动画AnimationController + easeInOutBack 曲线)
- 自动跟随 `AppThemeExtension` 切换明/暗/AMOLED/主色
- 点赞按钮:❤️ + 数字(用 `NumberFormatter.formatCount`),激活态 iOS systemRed 渐变
- 收藏按钮:★ + 文字"收藏"/"已藏"(用 `t.discover.base.favorite/favorited`),激活态 iOS systemOrange 渐变
- 新增 `_ToolIconButton` 抽出拼音/翻译/TTS 工具图标共用组件,添加无障碍语义标签
- HTML 设计原型:`docs/prototypes/sentence_card_redesign.html`4 种方案 + 动态主题切换预览)
#### 验证
- `flutter analyze` 通过0 error / 0 warning
- 4 个修改文件的 IDE 诊断全部通过
#### 不足与扩展建议
详见本次会话末尾的「审计验收」总结。
---
## [v6.139.0] - 2026-06-26
### 🧹 macOS 编译验证 + 静态分析清理

View File

@@ -5,7 +5,7 @@ import os
import glob
HOST = '123.207.67.197'
PORT = 22
PORT = 2026
USER = 'root'
PASS = '520Kiss123'
REMOTE_DIR = '/www/wwwroot/tools.wktyl.com/public/agreements/'

View File

@@ -1,5 +1,208 @@
# CHANGELOG
## v10.3.2 (2026-06-27)
### 🔐 隐私权管理增强 - 多方式登录/忘记密码注销/SMTP检测
**重要更新**完善隐私权管理页面登录与注销流程新增SMTP配置检测、4种登录方式、忘记密码注销回执码流程前端UI全面重构。
#### 新增功能
| 功能 | 说明 | 优先级 |
|------|------|--------|
| 🔍 SMTP配置检测 | 公开API检测SMTP主机/端口/加密/密码是否配置完整+TCP端口可达性 | ⭐⭐⭐ |
| 🔑 4种登录方式 | 密码/密保问题/邮箱验证码/回执号登录iOS分段控制器切换 | ⭐⭐⭐⭐⭐ |
| 📧 忘记密码注销 | 凭邮箱或密保答案验证身份→发送回执码→凭回执码提交注销(无需登录) | ⭐⭐⭐⭐⭐ |
| 👤 状态查询显示昵称 | accountLookup返回nickname字段(脱敏处理,非账号) | ⭐⭐⭐⭐ |
| 🌐 URL语言后缀 | 切换语言时URL同步更新`?lang=zh`/`?lang=en` | ⭐⭐⭐ |
| 📋 注销事件查询 | 原"设备Tab"改为"注销事件查询",展示注销申请记录+删除进度 | ⭐⭐⭐ |
| ✅ 注销协议勾选框 | 注销确认输入框下方增加协议勾选(可超链接),未勾选禁止提交 | ⭐⭐⭐⭐ |
#### 新增API接口
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| SMTP状态检测 | GET | `/api/user_security/checkSmtpStatus` | 返回配置完整度+端口可达性 |
| 密保问题登录 | POST | `/api/user_security/secQuestionLogin` | account+sec_answer |
| 邮箱验证码登录 | POST | `/api/user_security/emailCodeLogin` | email+captcha(未注册自动创建) |
| 发送注销回执码 | POST | `/api/user_security/sendDeletionCode` | 验证身份后发送6位码到邮箱 |
| 凭回执码注销 | POST | `/api/user_security/requestDeletionByReceipt` | 无需登录,凭回执码提交注销 |
#### 前端变更
| 区域 | 变更 |
|------|------|
| Tab结构 | 移除"其他协议"Tab"设备"Tab改为"📋 注销事件查询" |
| 登录表单 | 重构为4种方式(iOS SegmentedControl):密码/密保/邮箱验证码/回执号 |
| 忘记密码注销 | 新增可折叠区域:邮箱/密保两种验证→发送回执码→提交注销 |
| 注销确认 | 增加协议勾选框(超链接《账号使用协议》和《隐私政策》) |
| 状态查询 | 显示"👤 用户昵称: xxx" |
| URL语言 | `?lang=zh`/`?lang=en` 后缀同步(history.replaceState) |
| 多语言 | zh/en各新增约50个I18N key |
| 文件行数 | 1942行 → 2687行 |
#### 测试结果
```
✅ SMTP配置检测 配置完整+端口可达(host=free.mboxhosting.com:465/SSL)
✅ accountLookup昵称 返回nickname字段
✅ 密保问题登录 API正常(测试账号未设密保→正确返回错误)
✅ 发送注销回执码 回执码已发送到 pt*****@test.local
✅ 错误回执码拦截 错误回执码被正确拦截
✅ 前端5个URL HTTP 200 (中文/英文/注销Tab/事件Tab/偏好Tab)
通过率: 10/10 (100%)
```
---
## v10.3.1 (2026-06-27)
### 🛡️ 隐私权管理增强 - GDPR/PIPL/COPPA合规升级
**重要更新**完善隐私权管理功能新增6大能力(隐私偏好/设备管理/注销进度/未成年保护/邮件通知/数据最小化)修复跨域BUG达到GDPR/PIPL/COPPA合规要求。
#### 新增功能
| 功能 | 说明 | 合规依据 |
|------|------|----------|
| 🔧 隐私偏好管理 | 4类数据处理同意开关(analytics/marketing/personalization/third_party_share) | GDPR Art.7 |
| 📱 登录设备管理 | 设备列表/位置/最后活跃时间,支持远程登出 | 用户体验+安全 |
| 📊 数据删除进度可视化 | 注销后展示8类数据删除进度条(账号/Token/设备/收藏/笔记/签到/积分/文章) | 透明度要求 |
| 👨‍👩‍👧 未成年保护 | 注册时校验出生日期14岁以下禁止注册14-17岁开启家长控制(每日120分钟/内容过滤/禁消费) | COPPA/GDPR-K |
| 📧 注销邮件通知 | 用户提交注销后异步发送确认邮件到注册邮箱,防冒用 | 安全要求 |
| 🗑️ 数据最小化保留 | 注销记录保留期从2年→6个月username做SHA256哈希存储 | PIPL最小化原则 |
#### BUG修复
| BUG | 原因 | 修复 |
|-----|------|------|
| ❌ 注销提交后后台查不到 | `API_BASE='https://tools.wktyl.com'`从s2ss.com发起跨域请求`__token__`响应头不可读导致authToken=null | API_BASE改为相对路径`''`优先从JSON body读取token |
| ❌ 管理后台注销列表空 | admin/controller/Userdeletion.php中`$list`查询未带filter | 使用单一`$listQuery`统一过滤 |
| ❌ 英文模式下输入框仍显示中文 | `switchLang()`未更新placeholder | 新增`phLookupAccount`/`phLoginAccount`/`phLoginPassword`翻译 |
| ❌ SQL迁移ADD COLUMN IF NOT EXISTS失败 | 服务器MySQL版本不支持该语法 | 改用存储过程+INFORMATION_SCHEMA检查 |
#### 新增API接口
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 隐私偏好查询 | GET | `/api/user_security/getConsents` | 返回4类同意状态 |
| 隐私偏好更新 | POST | `/api/user_security/updateConsents` | 批量更新同意开关 |
| 登录设备列表 | GET | `/api/user_security/listDevices` | 返回设备列表含位置/最后活跃 |
| 远程登出设备 | POST | `/api/user_security/revokeDevice` | 通过id/device_id登出指定设备 |
| 注销进度查询 | GET | `/api/user_security/deletionProgress` | 返回8类数据删除状态+百分比 |
| 家长控制状态 | GET | `/api/user_security/parentalControlStatus` | 返回未成年状态+家长控制配置 |
| 更新出生日期 | POST | `/api/user_security/updateBirthday` | 补充/修改出生日期,自动更新未成年状态 |
| 清理旧注销记录 | GET | `/api/user_security/cleanupOldDeletionRecords` | 清理6个月以上已完成注销记录 |
#### 数据库变更
| 表 | 变更 | 说明 |
|----|------|------|
| `tool_user_consents` | 新建表 | 用户数据处理同意记录(user_id+consent_type唯一) |
| `tool_user_deletion` | 新增 `deletion_progress` text | 注销进度JSON |
| `tool_user_deletion` | 新增 `deletion_summary` varchar(500) | 注销数据摘要 |
| `tool_user` | 新增 `birthday` date | 出生日期 |
| `tool_user` | 新增 `is_minor` tinyint(1) | 是否未成年(0/1) |
#### 前端变更
| 文件 | 变更 |
|------|------|
| `privacy-rights.html` | 4→6 Tab(新增📱设备/⚙️偏好),注销进度条,浏览器语言自动检测,登录态联动 |
| `account-agreement.html` | V6.6→V6.7保留期2年→6个月+SHA256哈希 |
#### 测试结果
```
✅ register(带birthday) code=1 注册成功age=31 is_minor=0
✅ parentalControlStatus code=1 返回家长控制配置
✅ updateBirthday(未成年) code=1 改为2010年生is_minor=1
✅ getConsents code=1 返回4项同意配置
✅ updateConsents code=1 成功更新4项
✅ listDevices code=1 返回设备列表
✅ deletionProgress code=1 返回进度
✅ privacy-rights.html HTTP 200 (devices/preferences tab正常)
通过率: 8/8 (100%)
```
#### 部署清单
- 已上传7个文件到 `123.207.67.197:/www/wwwroot/tools.wktyl.com/`
- 备份目录: `backup_privacy_rights_20260627_021954`
- SQL迁移已执行(存储过程兼容MySQL 5.7)
---
## v10.3.0 (2026-06-27)
### 🛡️ 隐私权管理中心 + Google Play应用外账户管理
**重大更新**新增独立的隐私权管理网页满足Google Play数据安全合规要求支持应用外账号状态查询与注销。
#### 新增功能
| 功能 | 说明 |
|------|------|
| 🛡️ 隐私权管理页面 | 4-Tab界面状态查询/注销账号/隐私权利/其他协议Apple风格设计中英双语 |
| 🔍 公开账号状态查询 | `accountLookup` API无需登录即可查询5种状态正常/封锁/注销中/已注销/无记录) |
| 🔐 HMAC-SHA256防枚举 | 查询接口需回执签名,防止账号枚举攻击 |
| 📋 账号协议V6.6 | 新增长期未登录账号回收政策12/18/24个月三级、服务器数据删除时限、Google Play合规说明 |
| 🧪 全流程验证脚本 | `test_privacy_rights_flow.py`覆盖注册→登录→查询→注销→查询状态→撤销6个接口 |
#### 新增文件
| 文件 | 说明 |
|------|------|
| `public/agreements/privacy-rights.html` | 隐私权管理中心网页1488行含Web Crypto API回执生成 |
| `Scripts/upload_privacy_rights.py` | 服务器文件上传脚本含备份、SSH端口2026 |
| `Scripts/test_privacy_rights_flow.py` | 全流程接口验证脚本6/6通过 |
#### 修改文件
| 文件 | 修改内容 |
|------|----------|
| `application/api/controller/UserSecurity.php` | 新增 `accountLookup()` 方法1152行加入 `$noNeedLogin``$rateLimits` |
| `application/route.php` | 新增 `api/user_security/accountLookup` 路由第73行 |
| `public/agreements/account-agreement.html` | 升级V6.6新增3.4长期未登录账号回收政策、4.5服务器数据删除时限、4.6应用外账户管理支持 |
| `public/agreements/index.html` | 隐私与安全分区新增"隐私权管理"入口(中英文) |
| `Scripts/upload_agreements.py` | 修复SSH端口从22改为2026 |
#### API接口
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 账号状态查询 | POST | `/api/user_security/accountLookup` | 公开接口需回执验证限流20次/小时/IP |
#### 状态查询返回值
| status | status_text | 说明 |
|--------|-------------|------|
| `normal` | 正常 | 账号正常使用 |
| `blocked` | 封锁 | 账号被封禁 |
| `deleting` | 注销中 | 注销申请审核中 |
| `deleted` | 已注销 | 账号已注销 |
| `no_record` | 无记录 | 查无此账号 |
#### 测试结果
```
✅ register code=1 注册成功
✅ login code=1 登录成功
✅ accountLookup code=1 状态查询成功
✅ requestDeletion code=1 注销申请提交成功
✅ deletionStatus code=1 注销状态查询成功
✅ cancelDeletion code=1 撤销注销成功
通过率: 6/6 (100%)
```
#### 部署URL
- 隐私权管理页https://tools.wktyl.com/agreements/privacy-rights.html
- 协议列表页https://tools.wktyl.com/agreements/index.html
- 账号协议页https://tools.wktyl.com/agreements/account-agreement.html
---
## v10.2.1 (2026-06-25)
### 🛡️ 限流排队系统优化

View File

@@ -0,0 +1,63 @@
-- ============================================================
-- 迁移脚本: v10.3.1 隐私权管理增强
-- 创建时间: 2026-06-27
-- 描述: 新增用户同意管理表、注销进度字段、用户出生日期字段
-- 兼容: MySQL 5.7+ (不使用 ADD COLUMN IF NOT EXISTS 语法)
-- ============================================================
-- 1. 用户数据处理同意表 (GDPR/PIPL合规)
CREATE TABLE IF NOT EXISTS `tool_user_consents` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL DEFAULT '0' COMMENT '用户ID',
`consent_type` varchar(50) NOT NULL DEFAULT '' COMMENT '同意类型: analytics/marketing/personalization/third_party_share',
`consent_value` tinyint(1) NOT NULL DEFAULT '1' COMMENT '0=拒绝 1=同意',
`consent_source` varchar(20) NOT NULL DEFAULT 'web' COMMENT '来源: web/app/system',
`ip` varchar(50) DEFAULT '' COMMENT 'IP地址',
`createtime` int(11) DEFAULT NULL COMMENT '创建时间',
`updatetime` int(11) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_type` (`user_id`,`consent_type`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户数据处理同意记录';
-- 2. user_deletion表新增注销进度字段 (使用存储过程兼容MySQL 5.7)
DROP PROCEDURE IF EXISTS `migrate_v10_3_add_columns`;
DELIMITER $$
CREATE PROCEDURE `migrate_v10_3_add_columns`()
BEGIN
-- tool_user_deletion: deletion_progress
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'tool_user_deletion'
AND COLUMN_NAME = 'deletion_progress') THEN
ALTER TABLE `tool_user_deletion` ADD COLUMN `deletion_progress` text COMMENT '注销进度JSON' AFTER `admin_remark`;
END IF;
-- tool_user_deletion: deletion_summary
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'tool_user_deletion'
AND COLUMN_NAME = 'deletion_summary') THEN
ALTER TABLE `tool_user_deletion` ADD COLUMN `deletion_summary` varchar(500) DEFAULT '' COMMENT '注销数据摘要' AFTER `deletion_progress`;
END IF;
-- tool_user: birthday
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'tool_user'
AND COLUMN_NAME = 'birthday') THEN
ALTER TABLE `tool_user` ADD COLUMN `birthday` date DEFAULT NULL COMMENT '出生日期' AFTER `email`;
END IF;
-- tool_user: is_minor
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'tool_user'
AND COLUMN_NAME = 'is_minor') THEN
ALTER TABLE `tool_user` ADD COLUMN `is_minor` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否未成年: 0=成年 1=未成年(需家长控制)' AFTER `birthday`;
END IF;
END$$
DELIMITER ;
CALL `migrate_v10_3_add_columns`();
DROP PROCEDURE IF EXISTS `migrate_v10_3_add_columns`;

View File

@@ -34,6 +34,7 @@ class Userdeletion extends Backend
$filterJson = $this->request->get('filter', '{}');
$query = Db::name('user_deletion');
$listQuery = Db::name('user_deletion');
try {
$filter = json_decode($filterJson, true);
@@ -41,29 +42,18 @@ class Userdeletion extends Backend
foreach ($filter as $key => $val) {
if ($val !== '' && $val !== null) {
$query->where($key, '=', $val);
$listQuery->where($key, '=', $val);
}
}
}
} catch (\Exception $e) {}
$total = $query->count();
$list = Db::name('user_deletion')
$list = $listQuery
->order($sort ?: 'createtime', $order ?: 'desc')
->limit($offset, $limit)
->select();
if (!empty($filter) && is_array($filter)) {
$listQuery = Db::name('user_deletion');
foreach ($filter as $key => $val) {
if ($val !== '' && $val !== null) {
$listQuery->where($key, '=', $val);
}
}
$list = $listQuery->order($sort ?: 'createtime', $order ?: 'desc')
->limit($offset, $limit)
->select();
}
foreach ($list as &$item) {
$statusMap = [0 => '待审核', 1 => '已通过', 2 => '已拒绝', 3 => '已自动注销'];
$item['status_text'] = $statusMap[$item['status']] ?? '未知';
@@ -212,12 +202,17 @@ class Userdeletion extends Backend
Db::name('user')->where('id', $userId)->delete();
// 注销完成后对username做SHA256哈希(PIPL最小化保留原则)
// 保留6个月后由cleanupOldDeletionRecords接口自动清理
$hashedUsername = hash('sha256', $record['username'] ?? '');
Db::name('user_deletion')->where('id', $record['id'])->update([
'status' => $status,
'admin_id' => $adminId,
'admin_remark' => $remark,
'updatetime' => time(),
'deletetime' => time(),
'username' => $hashedUsername,
]);
Db::commit();

View File

@@ -276,6 +276,11 @@ class Feed extends Api
}
$cached = Cache::get($cacheKey);
if ($cached && !$hasSeenIds) {
// v2.6: 缓存命中后重新附加当前用户的 is_liked/is_favorited 状态
// 缓存中的 per-user 字段可能来自其他用户,不可信,必须按当前用户重查
if (isset($cached['list']) && !empty($cached['list'])) {
$this->_batchAttachInteractionCounts($cached['list'], $this->_getUserId());
}
$this->success('成功(cache)', $cached);
return;
}
@@ -421,6 +426,11 @@ class Feed extends Api
unset($item);
}
// 批量附加互动计数(like_count/favorite_count/comment_count/share_count)
// 修复: 原仅 /api/feed/detail 返回这些字段,列表接口缺失导致客户端按钮显示 0
// v2.6: 同时回填当前用户的 is_liked/is_favorited 状态,解决已收藏句子重复出现时状态丢失
$this->_batchAttachInteractionCounts($items, $this->_getUserId());
$result = [
'list' => $items,
'total' => $total,
@@ -432,7 +442,18 @@ class Feed extends Api
];
if (!$hasSeenIds) {
Cache::set($cacheKey, $result, 60);
// v2.6: 缓存前剥离 per-user 字段(is_liked/is_favorited)
// 这些字段是用户私有的,缓存后其他用户命中会串状态
// 缓存命中时由 _batchAttachInteractionCounts 重新按当前用户查询
$cacheResult = $result;
if (isset($cacheResult['list'])) {
foreach ($cacheResult['list'] as &$cacheItem) {
$cacheItem['is_liked'] = false;
$cacheItem['is_favorited'] = false;
}
unset($cacheItem);
}
Cache::set($cacheKey, $cacheResult, 60);
}
$this->success('成功', $result);
@@ -2682,41 +2703,102 @@ class Feed extends Api
/**
* @name 清除Feed相关缓存
* @desc 在互动操作写入后主动清除相关缓存,确保数据一致性
* @lastUpdate v2.5 修复 _lite 后缀缓存未清理 + Cache::rm 不生效的 bug
* 1) 增加 _lite 后缀变体循环
* 2) 在 Cache::rm 后调用 _unlinkCacheFile 兜底,直接删除缓存文件
* 绕过 opcache 缓存旧代码 / ThinkPHP complex 驱动异常
*/
private function _clearFeedCache($feedType, $feedId)
{
try {
Cache::rm('feed_stats');
Cache::rm('feed_channels');
Cache::rm('feed_weight_config');
Cache::rm('feed_weight_config_api');
Cache::rm("feed_count_{$feedType}");
Cache::rm("feed_recommend_guest_20");
// 单键缓存
$singleKeys = [
'feed_stats',
'feed_channels',
'feed_weight_config',
'feed_weight_config_api',
"feed_count_{$feedType}",
'feed_recommend_guest_20',
];
foreach ($singleKeys as $sk) {
Cache::rm($sk);
$this->_unlinkCacheFile($sk);
}
// 列表缓存:覆盖 newest/hottest × 5 页 × 4 种 limit × 普通与 _lite × 平台后缀
$sorts = ['newest', 'hottest'];
$limits = [10, 20, 30, 50];
// 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5否则 limit=5 的缓存不会被清理
$limits = [5, 10, 15, 20, 25, 30, 40, 50];
$liteVariants = ['', '_lite']; // lite 模式变体
for ($p = 1; $p <= 5; $p++) {
foreach ($sorts as $sort) {
foreach ($limits as $lim) {
Cache::rm("feed_list_{$feedType}_{$sort}_{$p}_{$lim}");
Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}");
// 带平台后缀
foreach (self::$platforms as $pKey => $pName) {
Cache::rm("feed_list_{$feedType}_{$sort}_{$p}_{$lim}_plat_{$pKey}");
Cache::rm("feed_list_all_{$sort}_{$p}_{$lim}_plat_{$pKey}");
foreach ($liteVariants as $liteSuffix) {
$k1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}";
$k2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}";
Cache::rm($k1);
Cache::rm($k2);
$this->_unlinkCacheFile($k1);
$this->_unlinkCacheFile($k2);
// 带平台后缀
foreach (self::$platforms as $pKey => $pName) {
$pk1 = "feed_list_{$feedType}_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}";
$pk2 = "feed_list_all_{$sort}_{$p}_{$lim}{$liteSuffix}_plat_{$pKey}";
Cache::rm($pk1);
Cache::rm($pk2);
$this->_unlinkCacheFile($pk1);
$this->_unlinkCacheFile($pk2);
}
}
}
}
}
Cache::rm("feed_trending_{$feedType}_20");
Cache::rm("feed_trending_all_20");
// trending 缓存
$trendingKeys = [
"feed_trending_{$feedType}_20",
"feed_trending_all_20",
];
foreach ($trendingKeys as $tk) {
Cache::rm($tk);
$this->_unlinkCacheFile($tk);
}
foreach (self::$platforms as $pKey => $pName) {
Cache::rm("feed_trending_{$feedType}_20_{$pKey}");
Cache::rm("feed_trending_all_20_{$pKey}");
Cache::rm("feed_recommend_guest_20_plat_{$pKey}");
$platKeys = [
"feed_trending_{$feedType}_20_{$pKey}",
"feed_trending_all_20_{$pKey}",
"feed_recommend_guest_20_plat_{$pKey}",
];
foreach ($platKeys as $pk) {
Cache::rm($pk);
$this->_unlinkCacheFile($pk);
}
}
} catch (\Exception $e) {}
}
/**
* @name 兜底删除缓存文件
* @desc Cache::rm 在某些环境opcache 缓存旧代码 / complex 驱动异常)下不删除文件,
* 本方法直接按 ThinkPHP 5 File 驱动的路径规则计算缓存文件路径并 unlink
* @param string $cacheKey 缓存 key与 Cache::rm 入参一致)
* @return bool 文件已删除返回 true文件不存在或删除失败返回 false
*/
private function _unlinkCacheFile($cacheKey)
{
if (!defined('CACHE_PATH') || empty($cacheKey)) {
return false;
}
try {
$hash = md5($cacheKey);
$file = CACHE_PATH . substr($hash, 0, 2) . '/' . substr($hash, 2) . '.php';
if (is_file($file) && @unlink($file)) {
return true;
}
} catch (\Exception $e) {}
return false;
}
/**
* @name 清除权重配置缓存
* @desc 在install/sync操作后清除权重相关缓存
@@ -2730,7 +2812,8 @@ class Feed extends Api
Cache::rm('feed_stats');
Cache::rm('feed_channels');
$sorts = ['newest', 'hottest'];
$limits = [10, 20, 30, 50];
// 修复 v2.5: 必须覆盖客户端所有可能的 limit 值(含 5否则 limit=5 的缓存不会被清理
$limits = [5, 10, 15, 20, 25, 30, 40, 50];
for ($p = 1; $p <= 5; $p++) {
foreach ($sorts as $sort) {
foreach ($limits as $lim) {
@@ -2946,9 +3029,136 @@ class Feed extends Api
}
}
// 批量附加互动计数(like_count/favorite_count/comment_count/share_count)
// 修复: favorites/likes/history/readlater 列表也需返回真实计数
// v2.6: 同时回填当前用户的 is_liked/is_favorited 状态
$this->_batchAttachInteractionCounts($items, $this->_getUserId());
return $items;
}
/**
* @name 批量附加互动计数
* @desc 一次性查询 feed_interaction 表,按 (feed_type, feed_id, action) 分组 COUNT
* 回填到 items 的 like_count/favorite_count/comment_count/share_count 字段。
* 避免 N+1 查询,列表接口(list/favorites/likes/history/readlater)统一调用。
* @param array &$items 待附加计数的 items 数组(引用传递)
* @lastUpdate v2.4 新增; 修复客户端点赞按钮显示 0 的问题
*/
private function _batchAttachInteractionCounts(&$items, $userId = 0)
{
if (empty($items) || !is_array($items)) {
return;
}
// 收集所有 (feed_type, feed_id) 对
$pairs = [];
foreach ($items as $item) {
$feedType = $item['feed_type'] ?? '';
$feedId = intval($item['id'] ?? 0);
if (empty($feedType) || $feedId <= 0) {
continue;
}
$pairs[$feedType][] = $feedId;
}
if (empty($pairs)) {
// 无有效对,仍填充 0 防止客户端解析 null
foreach ($items as &$item) {
if (!isset($item['like_count'])) $item['like_count'] = 0;
if (!isset($item['favorite_count'])) $item['favorite_count'] = 0;
if (!isset($item['comment_count'])) $item['comment_count'] = 0;
if (!isset($item['share_count'])) $item['share_count'] = 0;
// v2.6: 回填当前用户互动状态(未登录默认 false
if (!isset($item['is_liked'])) $item['is_liked'] = false;
if (!isset($item['is_favorited'])) $item['is_favorited'] = false;
}
unset($item);
return;
}
// 构造 OR 查询条件: ((feed_type='poetry' AND feed_id IN (...)) OR (feed_type='wisdom' AND feed_id IN (...)) ...)
// 仅查询 like/favorite/comment/share 四种 action
$countMap = []; // key: "feed_type_feed_id" => ["like"=>n, "favorite"=>n, ...]
try {
foreach ($pairs as $feedType => $ids) {
$ids = array_unique($ids);
$rows = Db::name('feed_interaction')
->field('feed_id, action, COUNT(*) AS cnt')
->where('feed_type', $feedType)
->where('feed_id', 'in', $ids)
->where('action', 'in', ['like', 'favorite', 'comment', 'share'])
->group('feed_id, action')
->select();
foreach ($rows as $row) {
$fid = intval($row['feed_id']);
$action = $row['action'];
$cnt = intval($row['cnt']);
$key = $feedType . '_' . $fid;
if (!isset($countMap[$key])) {
$countMap[$key] = [
'like' => 0,
'favorite' => 0,
'comment' => 0,
'share' => 0,
];
}
$countMap[$key][$action] = $cnt;
}
}
} catch (\Exception $e) {
// 查询失败时降级为 0
}
// v2.6 新增:批量查询当前用户的互动状态,回填 is_liked/is_favorited
// 解决list 接口不返回用户个人状态,导致已收藏句子重复出现时显示"未收藏"
$userActionMap = []; // key: "feed_type_feed_id" => ["like"=>bool, "favorite"=>bool]
if (!empty($userId)) {
try {
foreach ($pairs as $feedType => $ids) {
$ids = array_unique($ids);
$userRows = Db::name('feed_interaction')
->field('feed_id, action')
->where('user_id', $userId)
->where('feed_type', $feedType)
->where('feed_id', 'in', $ids)
->where('action', 'in', ['like', 'favorite'])
->select();
foreach ($userRows as $row) {
$fid = intval($row['feed_id']);
$action = $row['action'];
$key = $feedType . '_' . $fid;
if (!isset($userActionMap[$key])) {
$userActionMap[$key] = [
'like' => false,
'favorite' => false,
];
}
$userActionMap[$key][$action] = true;
}
}
} catch (\Exception $e) {
// 查询失败时降级为 false
}
}
// 回填到 items
foreach ($items as &$item) {
$feedType = $item['feed_type'] ?? '';
$feedId = intval($item['id'] ?? 0);
$key = $feedType . '_' . $feedId;
$counts = $countMap[$key] ?? null;
$item['like_count'] = $counts ? $counts['like'] : 0;
$item['favorite_count'] = $counts ? $counts['favorite'] : 0;
$item['comment_count'] = $counts ? $counts['comment'] : 0;
$item['share_count'] = $counts ? $counts['share'] : 0;
// v2.6: 回填当前用户互动状态
$userActions = $userActionMap[$key] ?? null;
$item['is_liked'] = $userActions ? $userActions['like'] : false;
$item['is_favorited'] = $userActions ? $userActions['favorite'] : false;
}
unset($item);
}
/**
* @name 获取权重配置
* @desc 从数据库读取管理员设置的推荐权重配置,带缓存

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,21 @@ Route::rule([
'api/user_security/requestDeletion' => 'api/UserSecurity/requestDeletion',
'api/user_security/deletionStatus' => 'api/UserSecurity/deletionStatus',
'api/user_security/cancelDeletion' => 'api/UserSecurity/cancelDeletion',
'api/user_security/accountLookup' => 'api/UserSecurity/accountLookup',
'api/user_security/cleanupOldDeletionRecords' => 'api/UserSecurity/cleanupOldDeletionRecords',
'api/user_security/getConsents' => 'api/UserSecurity/getConsents',
'api/user_security/updateConsents' => 'api/UserSecurity/updateConsents',
'api/user_security/listDevices' => 'api/UserSecurity/listDevices',
'api/user_security/revokeDevice' => 'api/UserSecurity/revokeDevice',
'api/user_security/deletionProgress'=> 'api/UserSecurity/deletionProgress',
'api/user_security/parentalControlStatus' => 'api/UserSecurity/parentalControlStatus',
'api/user_security/updateBirthday' => 'api/UserSecurity/updateBirthday',
// v10.3.2 新增
'api/user_security/checkSmtpStatus' => 'api/UserSecurity/checkSmtpStatus',
'api/user_security/secQuestionLogin' => 'api/UserSecurity/secQuestionLogin',
'api/user_security/emailCodeLogin' => 'api/UserSecurity/emailCodeLogin',
'api/user_security/sendDeletionCode' => 'api/UserSecurity/sendDeletionCode',
'api/user_security/requestDeletionByReceipt'=> 'api/UserSecurity/requestDeletionByReceipt',
]);
// OAuth社交登录

View File

@@ -3,7 +3,7 @@
> @File: API_FEED_DOC.md
> @Time: 2026-05-13
> @Description: 信息流Feed系统API文档聚合44种内容表为统一信息流
> @LastUpdate: v2.3 新增刷新获取新内容接口(/api/feed/refresh_content); 新增用户偏好设置接口(/api/feed/preferences)
> @LastUpdate: v2.6 list 接口返回 is_liked/is_favorited 当前用户互动状态;缓存命中后按当前用户重查 per-user 字段,缓存写入前剥离 per-user 字段避免跨用户串状态
---
@@ -158,6 +158,12 @@ curl -s "https://tools.wktyl.com/api/feed/channels" | python -m json.tool
| sort | string | 当前排序 |
| lite | bool | 是否轻量模式 |
> **v2.4 更新**: list 接口的每个 item 现在都包含 `like_count`、`favorite_count`、`comment_count`、`share_count` 字段(批量查询 feed_interaction 表回填),无需再单独调用 detail 接口获取计数。
> **v2.5 更新**: 修复 lite 模式(limit=5)点赞/取消点赞后 like_count 不更新的问题。根因是 `_clearFeedCache` 的 `$limits` 数组不含 5导致 `feed_list_*_newest_1_5_lite` 缓存从未被清理。修复后 `$limits = [5, 10, 15, 20, 25, 30, 40, 50]`,并在 `Cache::rm` 后追加 `_unlinkCacheFile` 兜底直接删除缓存文件。互动操作后 lite 模式 list 接口立即返回最新计数。
> **v2.6 更新**: list 接口的每个 item 现在包含 `is_liked`、`is_favorited` 字段,表示**当前登录用户**是否已点赞/已收藏。未登录时始终返回 `false`。`_batchAttachInteractionCounts` 新增 `$userId` 参数,批量查询 `feed_interaction` 表中当前用户的 like/favorite 记录。**缓存隔离**:缓存命中后按当前用户重新查询 per-user 字段(缓存中的 is_liked/is_favorited 不可信),缓存写入前剥离为 false 避免跨用户串状态。客户端额外合并本地 DB 状态OR 策略:服务端 true 或本地 true 均为 true覆盖未登录场景和离线操作。
### 3.5 curl测试
```bash

View File

@@ -261,9 +261,9 @@
<div class="content-card">
<div id="content-zh" class="lang-content">
<p><strong>闲言APP</strong> 账号使用协议</p>
<p>版本号V6.5</p>
<p>更新日期2026年5月20</p>
<p>生效日期2026年5月21</p>
<p>版本号V6.6</p>
<p>更新日期2026年6月17</p>
<p>生效日期2026年6月17</p>
<p>本协议是您与<strong>弥勒市朋普镇微风暴网络科技工作室</strong>关于<strong>闲言APP</strong>账号注册、使用、安全及注销等事项的约定。请您仔细阅读并充分理解本协议。</p>
<h2>一、账号注册</h2>
<h3>1.1 注册资格</h3>
@@ -317,6 +317,20 @@
<li>涉嫌违法违规活动</li>
<li>长期未登录(超过<span class="highlight">2年</span></li>
</ul>
<h3>3.4 长期未登录账号回收政策</h3>
<p>为合理利用服务器资源并保护账号安全,我们制定了以下长期未登录账号回收规则:</p>
<ul>
<li><span class="highlight">连续12个月未登录</span>:账号将进入"休眠状态"我们将通过App内消息、邮件或短信方式向您发送提醒通知</li>
<li><span class="highlight">连续18个月未登录</span>:账号下的虚拟财产(积分、金币等)将停止产生,但不被清零;账号仍可正常登录恢复使用</li>
<li><span class="highlight">连续24个月2年未登录</span>:我们将对账号进行<span class="highlight">回收处理</span>,具体处理方式如下:</li>
<li>— 账号将被永久冻结,无法登录</li>
<li>— 个人资料、收藏、笔记、签到记录等数据将被<span class="highlight">永久删除且不可恢复</span></li>
<li>— 账号关联的邮箱、手机号将被解绑,可重新注册新账号</li>
<li>— 在回收处理前30天我们将通过注册邮箱/手机号发送最终通知</li>
<li>回收处理后,您将无法找回该账号及其数据</li>
<li>为避免账号被回收,请至少每<span class="highlight">12个月</span>登录一次</li>
</ul>
<p class="note">💡 提示:账号回收处理与主动注销的后果一致,均会导致数据永久删除。建议您定期登录以保留账号和数据。</p>
<h2>四、账号注销</h2>
<h3>4.1 注销条件</h3>
<p>您有权随时申请注销账号,注销前请确保:</p>
@@ -351,6 +365,30 @@
<li>会员权益、积分、金币等虚拟财产将清零且<span class="highlight">不可恢复</span></li>
<li>注销后<span class="highlight">无法恢复</span>,请谨慎操作</li>
</ul>
<h3>4.5 服务器数据删除时限</h3>
<p>关于注销后服务器数据的删除时间,我们承诺以下规则:</p>
<ul>
<li><span class="highlight">管理员审核通过后立即删除</span>:管理员通过注销申请后,服务器将在<span class="highlight">5分钟内</span>完成以下数据的硬删除:</li>
<li>— 用户主表tool_user记录</li>
<li>— 用户Token、登录设备、在线状态等会话数据</li>
<li>— 用户收藏、笔记、点赞等互动数据</li>
<li>— 用户签到记录、积分流水、金币流水</li>
<li>— 用户文章、评论及关联内容(作者字段匿名化)</li>
<li><span class="highlight">审核期超时自动注销</span>:提交申请后<span class="highlight">3天</span>内若管理员未审核,系统将自动执行注销,删除范围同上</li>
<li><span class="highlight">注销记录保留</span>:为满足法律法规和审计要求,注销操作记录将保留<span class="highlight">6个月</span>且用户名在注销完成后立即进行SHA256哈希处理仅保留不可逆的哈希值用于审计</li>
<li>数据库备份遵循<span class="highlight">30天滚动覆盖</span>策略即最新备份仅保留30天超期自动清除</li>
</ul>
<p class="note">💡 重要说明注销完成后您将无法通过任何方式App内、网页、客服查询查找到该账号的任何信息状态查询将返回"已注销"或"无记录"。</p>
<h3>4.6 应用外账户管理支持Google Play合规</h3>
<p>为满足Google Play数据安全合规要求我们提供独立的<span class="highlight">应用外账户管理网页</span>无需安装App即可进行账号管理操作</p>
<ul>
<li><span class="highlight">账号状态查询</span>:访问 <a href="https://tools.wktyl.com/agreements/privacy-rights.html" style="color:var(--primary);">隐私权管理页面</a>,输入账号即可查询状态(正常/封锁/注销中/已注销/无记录)</li>
<li><span class="highlight">应用外注销</span>在隐私权管理页面登录后即可发起注销申请流程与App内一致</li>
<li><span class="highlight">注销状态查询</span>:随时查询注销申请的处理进度</li>
<li><span class="highlight">撤销注销</span>:审核期内可随时撤销注销申请</li>
<li>该页面同时提供其他软件协议的查看入口,方便您了解权利与义务</li>
</ul>
<p class="note">🔐 安全提示应用外账户管理页面采用HMAC-SHA256回执验证机制防止账号枚举攻击。查询账号状态需提供有效的回执签名。</p>
<h2>五、账号冻结与解冻</h2>
<h3>5.1 冻结情形</h3>
<p>以下情形我们有权冻结您的账号:</p>
@@ -387,38 +425,109 @@
</div>
<div id="content-en" class="lang-content" style="display:none;">
<p><strong>Xianyan APP</strong> Account Usage Agreement</p>
<p>Version: V6.5</p>
<p>Updated: May 20, 2026</p>
<p>Effective: May 21, 2026</p>
<p>Version: V6.6</p>
<p>Updated: June 17, 2026</p>
<p>Effective: June 17, 2026</p>
<p>This agreement is between you and <strong>Mile City Pengpu Town Weifengbao Network Technology Studio</strong> regarding the registration, use, security, and deletion of your Xianyan APP account. Please read and fully understand this agreement.</p>
<h2>I. Account Registration</h2>
<h3>1.1 You must be at least 14 years old to register an account</h3>
<h3>1.2 You need to provide a valid phone number or email for registration</h3>
<h3>1.3 The information you provide must be true, accurate, and complete</h3>
<h3>1.4 Each person may only register one account</h3>
<h3>1.1 Registration Eligibility</h3>
<ul>
<li>You must meet the minimum age requirement of your region (14 in China, 16 in EU, 13 in US)</li>
<li>Minors under 14 are not allowed to register</li>
<li>Each user may only register one account; accounts cannot be bought, transferred, or lent</li>
</ul>
<h3>1.2 Registration Methods</h3>
<ul>
<li>Phone number registration via SMS verification</li>
<li>Email registration via verification code</li>
<li>Third-party login via authorized accounts</li>
</ul>
<h2>II. Account Security</h2>
<h3>2.1 You are responsible for keeping your account password secure</h3>
<h3>2.2 Do not use easily guessable passwords</h3>
<h3>2.3 If you discover your account has been used without authorization, please contact us immediately</h3>
<h3>2.4 We are not responsible for losses caused by your failure to keep your account secure</h3>
<h3>2.1 Password Security</h3>
<ul>
<li>Set a strong password and change it regularly</li>
<li>Never share your password with others</li>
<li>Notify us immediately if you suspect unauthorized access</li>
</ul>
<h2>III. Account Usage</h2>
<h3>3.1 You may not lend, transfer, or sell your account</h3>
<h3>3.2 You may not use your account for illegal activities</h3>
<h3>3.3 You may not use technical means to obtain other users' account information</h3>
<h3>3.1 Account Restrictions</h3>
<p>We may restrict your account in the following cases:</p>
<ul>
<li>Security risk detected</li>
<li>Violation of User Service Agreement</li>
<li>Suspected illegal activities</li>
<li>Inactive for over 2 years</li>
</ul>
<h3>3.2 Long-term Inactive Account Recycling Policy</h3>
<p>To optimize server resources and protect account security, we apply the following recycling rules:</p>
<ul>
<li><span class="highlight">12 months inactive</span>: Account enters "dormant state"; we will notify you via app, email, or SMS</li>
<li><span class="highlight">18 months inactive</span>: Virtual assets stop accumulating but are not cleared; account can still be reactivated</li>
<li><span class="highlight">24 months (2 years) inactive</span>: Account will be <span class="highlight">recycled</span>:</li>
<li>— Account permanently frozen, login disabled</li>
<li>— Personal data, favorites, notes, check-ins <span class="highlight">permanently deleted</span></li>
<li>— Linked email/phone unbound, available for new registrations</li>
<li>— Final notification sent 30 days before recycling</li>
<li>To avoid recycling, please log in at least once every <span class="highlight">12 months</span></li>
</ul>
<h2>IV. Account Deletion</h2>
<h3>4.1 You can apply for account deletion at: My → Settings → Account Settings → Delete Account</h3>
<h3>4.2 Deletion process:</h3>
<h3>4.1 Deletion Conditions</h3>
<p>You may apply for account deletion at any time. Before deletion, please ensure:</p>
<ul>
<li>Submit a deletion request and undergo security verification</li>
<li>3-day review period (countdown displayed)</li>
<li>During the review period, you can cancel the deletion request</li>
<li>After the review is complete, the account and related data will be permanently deleted</li>
<li>No pending disputes or complaints</li>
<li>No incomplete transactions or services</li>
<li>You have backed up data you wish to keep</li>
</ul>
<h3>4.3 After account deletion:</h3>
<h3>4.2 Deletion Process</h3>
<ul>
<li>All personal information will be deleted or anonymized</li>
<li>Published content will be handled according to your choice</li>
<li>Account cannot be recovered after deletion</li>
<li>Apply via "Settings → Account Settings → Delete Account"</li>
<li>Step 1: Read deletion warning and confirm understanding</li>
<li>Step 2: Security verification - type "DELETE" to confirm; reason is optional</li>
<li>Step 3: Enter a <span class="highlight">3-day review period</span></li>
<li>Step 4: After admin approval or review timeout, account is <span class="highlight">permanently deleted</span></li>
<li>You may cancel the deletion request anytime during the review period</li>
</ul>
<h3>4.3 Deletion Status Query</h3>
<ul>
<li>View current status (Pending/Approved/Rejected/Cancelled) on the deletion page</li>
<li>Application time, reason, and estimated auto-deletion time are displayed</li>
<li>Countdown timer is shown during the review period</li>
</ul>
<h3>4.4 Deletion Consequences</h3>
<ul>
<li>Account cannot be logged in or used after deletion</li>
<li>The following data will be <span class="highlight">permanently deleted and unrecoverable</span>:</li>
<li>All favorites and notes</li>
<li>Check-in records and points</li>
<li>Personal profile and settings</li>
<li>Articles and interaction data</li>
<li>Membership benefits, points, and coins cleared</li>
<li>Deletion is <span class="highlight">irreversible</span> - proceed with caution</li>
</ul>
<h3>4.5 Server Data Deletion Timeline</h3>
<p>We commit to the following data deletion rules:</p>
<ul>
<li><span class="highlight">Immediate deletion upon admin approval</span>: Within <span class="highlight">5 minutes</span> of approval, the following data is hard-deleted:</li>
<li>— User main table (tool_user) record</li>
<li>— User tokens, login devices, online status</li>
<li>— User favorites, notes, likes</li>
<li>— Check-in records, points/coins transactions</li>
<li>— User articles, comments (author field anonymized)</li>
<li><span class="highlight">Auto-deletion on timeout</span>: If admin does not review within <span class="highlight">3 days</span>, the system auto-executes deletion</li>
<li><span class="highlight">Deletion record retention</span>: Deletion operation records are retained for <span class="highlight">6 months</span>, with username immediately SHA256-hashed upon deletion completion. Only irreversible hash values are retained for audit purposes</li>
<li>Database backups follow a <span class="highlight">30-day rolling overwrite</span> policy</li>
</ul>
<p class="note">💡 Important: After deletion is complete, the account cannot be queried via any channel (app, web, customer service). Status query will return "Deleted" or "No Record".</p>
<h3>4.6 Out-of-App Account Management (Google Play Compliance)</h3>
<p>To comply with Google Play Data Safety requirements, we provide an independent <span class="highlight">out-of-app account management webpage</span> for managing accounts without installing the app:</p>
<ul>
<li><span class="highlight">Account Status Query</span>: Visit <a href="https://tools.wktyl.com/agreements/privacy-rights.html" style="color:var(--primary);">Privacy Rights Management Page</a> to query status (Normal/Blocked/Deleting/Deleted/No Record)</li>
<li><span class="highlight">Out-of-app deletion</span>: Log in and submit deletion requests via the privacy rights page</li>
<li><span class="highlight">Deletion status query</span>: Track deletion progress anytime</li>
<li><span class="highlight">Cancel deletion</span>: Cancel deletion requests during the review period</li>
<li>The page also provides access to other software agreements</li>
</ul>
<p class="note">🔐 Security: The out-of-app management page uses HMAC-SHA256 receipt verification to prevent account enumeration attacks.</p>
<h2>V. Account Freeze</h2>
<h3>5.1 We have the right to freeze accounts in the following circumstances:</h3>
<ul>

View File

@@ -194,6 +194,14 @@
</div>
<span class="item-arrow"></span>
</a>
<a href="privacy-rights.html" class="agreement-item">
<span class="item-icon">🛡️</span>
<div class="item-text">
<div class="item-title">隐私权管理</div>
<div class="item-subtitle">账号状态查询、注销申请、隐私权利选择Google Play合规</div>
</div>
<span class="item-arrow"></span>
</a>
</div>
<div class="section-title">📋 服务条款</div>
<div class="agreement-list">
@@ -293,6 +301,14 @@
</div>
<span class="item-arrow"></span>
</a>
<a href="privacy-rights.html?lang=en" class="agreement-item">
<span class="item-icon">🛡️</span>
<div class="item-text">
<div class="item-title">Privacy Rights Management</div>
<div class="item-subtitle">Account status query, deletion request, privacy choices (Google Play compliance)</div>
</div>
<span class="item-arrow"></span>
</a>
</div>
<div class="section-title">📋 Service Terms</div>
<div class="agreement-list">

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@ PODS:
- Flutter
- flutter_webrtc (1.4.0):
- Flutter
- WebRTC-SDK (= 144.7559.01)
- WebRTC-SDK (= 144.7559.09)
- fluttertoast (0.0.2):
- Flutter
- gal (1.0.0):
@@ -97,7 +97,6 @@ PODS:
- Flutter
- pro_image_editor (12.0.8):
- Flutter
- FlutterMacOS
- quick_actions_ios (0.0.1):
- Flutter
- quill_native_bridge_ios (0.0.1):
@@ -137,7 +136,7 @@ PODS:
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (144.7559.01)
- WebRTC-SDK (144.7559.09)
- wifi_iot (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
@@ -175,7 +174,7 @@ DEPENDENCIES:
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pro_image_editor (from `.symlinks/plugins/pro_image_editor/darwin`)
- pro_image_editor (from `.symlinks/plugins/pro_image_editor/ios`)
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
- quill_native_bridge_ios (from `.symlinks/plugins/quill_native_bridge_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
@@ -268,7 +267,7 @@ EXTERNAL SOURCES:
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
pro_image_editor:
:path: ".symlinks/plugins/pro_image_editor/darwin"
:path: ".symlinks/plugins/pro_image_editor/ios"
quick_actions_ios:
:path: ".symlinks/plugins/quick_actions_ios/ios"
quill_native_bridge_ios:
@@ -324,7 +323,7 @@ SPEC CHECKSUMS:
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
flutter_tts: 35ac3c7d42412733e795ea96ad2d7e05d0a75113
flutter_vibrate: 207bbbeb62dd5638b479846c8e46168d7229f14a
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
flutter_webrtc: 3ee86aa10e0313c38c93fdb215dd86e77014f96a
fluttertoast: fe6790210fdba20801685be946e3a2124b72eef5
gal: baecd024ebfd13c441269ca7404792a7152fde89
home_widget: 54b4f6b36ed8d64cfee594a476225c35c3e45091
@@ -339,7 +338,7 @@ SPEC CHECKSUMS:
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
pro_image_editor: e57a76b305fd8c2e06d57f92aec82b9c6a6e8d9f
pro_image_editor: 3dedac450f82a389877286fa9eb08852cefb04ea
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
quill_native_bridge_ios: f47af4b14e7757968486641656c5d23250cee521
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
@@ -356,7 +355,7 @@ SPEC CHECKSUMS:
video_compress: f2133a07762889d67f0711ac831faa26f956980e
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
WebRTC-SDK: e6006119cd730d6315d875e4a421b6cc8bb88833
wifi_iot: f645260a2be8608517b2a9bf4c39b98e97003acc
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778

View File

@@ -1499,6 +1499,40 @@ class AppDatabase extends _$AppDatabase {
}, 'setFavoriteFlag');
}
/// 设置指定记录的点赞状态(不依赖当前值,直接设定)
///
/// 举一反三:与 setFavoriteFlag 同类需求。
/// 原 toggleLike 在记录不存在时静默失败,导致本地点赞状态无法持久化。
Future<void> setLikeFlag(String id, bool value) async {
await _safeDbVoid(() async {
await (update(sentences)..where((t) => t.id.equals(id))).write(
SentencesCompanion(
isLiked: Value(value),
updatedAt: Value(DateTime.now()),
),
);
}, 'setLikeFlag');
}
/// 设置指定记录的点赞数(likes 字段)
///
/// 修复 v6.142.0: 点赞按钮显示 0 问题。
/// 客户端 toggleLike 乐观更新 likeCount 后,同步写入本地 DB 的 likes 字段,
/// 保证 fromDb 读取时也能拿到正确的 likeCount。
///
/// 注: 由于 .g.dart 未重新生成(build_runner 阻塞), SentencesCompanion 缺少 likes 字段,
/// 改用 customStatement 直接执行 SQL。
Future<void> setLikesCount(String id, int count) async {
await _safeDbVoid(() async {
final safeCount = count < 0 ? 0 : count;
final ts = DateTime.now().millisecondsSinceEpoch;
await customStatement(
'UPDATE sentences SET likes = ?, updated_at = ? WHERE id = ?',
[safeCount, ts, id],
);
}, 'setLikesCount');
}
/// 设置收藏状态按targetType+targetId匹配兼容多种ID格式
///
/// sentences表中ID可能是以下格式
@@ -1907,6 +1941,19 @@ class AppDatabase extends _$AppDatabase {
);
}
/// 批量查询句子记录(仅命中本地已缓存的 id
///
/// 用途:句子广场刷新时合并本地 DB 的 isFavorite/isLiked 状态,
/// 解决"已收藏句子重复出现但未显示收藏状态"的问题。
/// 返回 Map: id -> Sentence未命中的 id 不会出现在 Map 中。
Future<Map<String, Sentence>> getSentencesByIds(List<String> ids) {
if (ids.isEmpty) return Future.value(<String, Sentence>{});
return _safeDbList(
() => (select(sentences)..where((t) => t.id.isIn(ids))).get(),
'getSentencesByIds',
).then((rows) => {for (final r in rows) r.id: r});
}
Future<void> insertOrUpdateSentence(SentencesCompanion entry) {
return _safeDbVoid(
() => into(sentences).insert(entry, mode: InsertMode.insertOrReplace),

View File

@@ -88,11 +88,13 @@ class _BounceButtonState extends State<BounceButton>
/// 可按压卡片
///
/// 按压时缩小 + 降低亮度,松开弹回,增强触觉反馈。
/// 2026-06-27: 新增 onLongPress 长按回调支持。
class PressableCard extends StatefulWidget {
const PressableCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.scaleDown = 0.97,
this.borderRadius,
this.padding,
@@ -102,6 +104,9 @@ class PressableCard extends StatefulWidget {
final Widget child;
final VoidCallback? onTap;
/// 长按回调(触发后自动回弹,不进入按压态)
final VoidCallback? onLongPress;
final double scaleDown;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? padding;
@@ -140,6 +145,12 @@ class _PressableCardState extends State<PressableCard>
void _onTapUp(TapUpDetails _) => _controller.reverse();
void _onTapCancel() => _controller.reverse();
void _onLongPress() {
// 长按触发时回弹,避免卡片停留在按压态
_controller.reverse();
widget.onLongPress?.call();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
@@ -148,6 +159,7 @@ class _PressableCardState extends State<PressableCard>
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTap: widget.onTap,
onLongPress: widget.onLongPress == null ? null : _onLongPress,
child: ScaleTransition(
scale: _scaleAnimation,
child: Container(

View File

@@ -154,6 +154,15 @@ class FavoriteRepository {
}
/// Feed API 加载收藏
///
/// 修复:原降级条件 `result.list.isEmpty && result.total == 0 && page == 1`
/// 在服务端 bug / 缓存延迟 / 新用户无收藏等场景下会误降级到 Legacy API
/// 而 Legacy API 又硬编码 targetType='article'(已在 _loadLegacyFavorites 修复),
/// 双重故障导致句子收藏完全查不到。
///
/// 现在Feed API 返回空时,**先检查本地数据库是否有收藏**
/// - 本地有直接走本地兜底loadLocalDbFavorites不降级 Legacy
/// - 本地无:再降级 Legacy API已修复 targetType
Future<FavoriteLoadResult> _loadFeedFavorites({
int page = 1,
bool refresh = false,
@@ -163,9 +172,22 @@ class FavoriteRepository {
try {
final result = await FeedService.fetchFavorites(page: page);
// 空结果降级旧接口
// 空结果处理:先本地兜底,再考虑降级 Legacy
if (result.list.isEmpty && result.total == 0 && page == 1) {
Log.w('Feed收藏API返回空结果(total=0),降级旧接口');
// 先看本地数据库是否有收藏记录(如刚收藏但服务端尚未同步/缓存延迟)
final localDbItems = await loadLocalDbFavorites();
if (localDbItems.isNotEmpty) {
Log.i('Feed收藏API空结果本地数据库兜底: ${localDbItems.length}');
return FavoriteLoadResult(
items: localDbItems,
feedItems: const [],
total: localDbItems.length,
nextPage: 2,
useFeedApi: true,
);
}
// 本地也无,降级 Legacy API已修复 targetType 硬编码问题)
Log.w('Feed收藏API空结果且本地无记录降级旧接口');
return _loadLegacyFavorites(
page: page,
refresh: refresh,
@@ -196,6 +218,10 @@ class FavoriteRepository {
}
/// Legacy API 加载收藏
///
/// 修复:原实现硬编码 `targetType: 'article'`,但句子广场的 feedType 是
/// 'hitokoto'/'chengyu'/'hanzi'/'poetry' 等,永远查不到。
/// 现在:不传 targetType让服务端返回所有类型收藏与 Feed API 行为一致)。
Future<FavoriteLoadResult> _loadLegacyFavorites({
int page = 1,
bool refresh = false,
@@ -204,7 +230,8 @@ class FavoriteRepository {
try {
final result = await UserCenterService.favorite(
action: FavoriteAction.list,
targetType: 'article',
// 故意不传 targetType:让服务端返回所有类型收藏,
// 避免硬编码 'article' 导致句子类收藏被漏掉
page: page,
);
final total = SafeJson.parseInt(result['total']);
@@ -385,7 +412,16 @@ class FavoriteRepository {
}
}
/// 合并服务端和本地数据库收藏,去重(以 content+author 为联合key
/// 合并服务端和本地数据库收藏,去重
///
/// 修复:原实现仅用 `content+author` 作为去重 key当 content 为空时
/// 退化为 `|title`,多条空 content 条目互相误去重;且未利用 id 信息,
/// 服务端和本地同一句子id 相同但 content 微小差异)会被当作两条。
///
/// 现在:双重去重策略
/// - 主 key`targetType|targetId`(精确,服务端与本地通过 _resolveServerId 可对齐)
/// - 副 key`content|title`(兜底,处理 id 缺失或异常的情况)
/// 任一 key 命中即视为已存在。
///
/// 本地条目追加到尾部(而非插入头部),避免取消收藏后本地缓存条目
/// 仍然显示在列表顶部导致"取消收藏后数据仍在"的问题。
@@ -393,15 +429,32 @@ class FavoriteRepository {
List<FavoriteItem> serverItems,
List<FavoriteItem> localItems,
) {
// 使用 content + author 联合去重,避免不同作者的同内容句子被误去重
final mergeKey = (FavoriteItem item) =>
'${item.content.trim().toLowerCase()}|${item.title.trim().toLowerCase()}';
final existingKeys = serverItems.map(mergeKey).toSet();
String idKey(FavoriteItem item) =>
'${item.targetType}|${item.targetId}';
String contentKey(FavoriteItem item) {
final c = item.content.trim().toLowerCase();
final t = item.title.trim().toLowerCase();
// 空 content 不参与去重,避免误合并不同条目
if (c.isEmpty) return '';
return '$c|$t';
}
final existingIdKeys = serverItems.map(idKey).toSet();
final existingContentKeys = serverItems
.map(contentKey)
.where((k) => k.isNotEmpty)
.toSet();
final merged = List<FavoriteItem>.from(serverItems);
for (final local in localItems) {
if (!existingKeys.contains(mergeKey(local))) {
merged.add(local);
}
final idK = idKey(local);
final cK = contentKey(local);
final dupById = existingIdKeys.contains(idK);
final dupByContent = cK.isNotEmpty && existingContentKeys.contains(cK);
if (dupById || dupByContent) continue;
merged.add(local);
existingIdKeys.add(idK);
if (cK.isNotEmpty) existingContentKeys.add(cK);
}
return merged;
}

View File

@@ -1,29 +1,31 @@
// ============================================================
// 闲言APP — 首页句子卡片
// 创建时间: 2026-04-27
// 更新时间: 2026-06-12
// 更新时间: 2026-06-27
// 作用: 句子广场列表中的句子卡片Feed数据适配 + 互动操作
// 上次更新: AppIcon添加语义标签(点赞/收藏/评论/浏览量),增强无障碍
// 上次更新: v6.144.0 — 未激活态按钮边框加粗 (0.5→1.2px) + 背景加深,
// 让用户清晰辨识点赞/收藏按钮的可点击区域
// ============================================================
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart' show Colors;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/l10n/translations.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/theme/glass_tokens.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart'
show isOhos, OhosDeviceCapabilities;
import 'package:xianyan/core/utils/performance_optimizer.dart';
import 'package:xianyan/core/utils/ui/interaction_animations.dart';
import 'package:xianyan/core/utils/data/number_formatter.dart';
import 'package:xianyan/core/services/device/haptic_service.dart';
import 'package:xianyan/core/services/audio/sfx_service.dart';
import 'package:xianyan/features/home/feed_model.dart';
import 'package:xianyan/features/home/providers/home_provider.dart';
import 'package:xianyan/features/home/presentation/providers/sentence_detail_sheet.dart';
@@ -32,7 +34,6 @@ import 'package:xianyan/features/settings/providers/plugin_provider.dart';
import 'package:xianyan/features/settings/presentation/plugin_widgets/translate_sheet.dart';
import 'package:xianyan/features/settings/presentation/plugin_widgets/tts_player_sheet.dart';
import 'package:xianyan/shared/widgets/plugin/pinyin_annotation_text.dart';
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
import 'package:xianyan/shared/widgets/display/app_icon.dart';
/// 句子卡片 — 列表项
@@ -70,8 +71,6 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
AppThemeExtension get ext => widget.ext;
bool _showPinyin = false;
bool _isSharing = false;
final GlobalKey _repaintKey = GlobalKey();
/// 根据 feedType 生成主题色
Color _generateAccentColor() {
@@ -94,12 +93,17 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
widget.onTap?.call();
_showDetail(context);
},
// 长按卡片:重冲击触觉 + 触发详情面板(与点击效果一致,但提供差异化触觉反馈)
onLongPress: () {
HapticService.heavy();
widget.onTap?.call();
_showDetail(context);
},
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
child: RepaintBoundary(
key: _repaintKey,
child: Container(
decoration: BoxDecoration(
gradient: _buildGradient(shaderEnabled, accentColor),
@@ -387,7 +391,13 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
);
}
/// 操作行 (作者 + 收藏 + 点赞)
/// 操作行 (作者 + 工具图标 + 液态玻璃点赞/收藏按钮)
///
/// 重设计 2026-06-27:
/// - 移除分享 icon 按钮(分享改为左滑手势 + 详情面板入口)
/// - 拼音/翻译/TTS 保留为小图标,置于按钮左侧
/// - 点赞/收藏改为液态玻璃长方形 icon+文字 按钮,显示激活态
/// - 支持动态主题(明/暗/AMOLED + 主色切换 + 圆角风格 + 字号缩放)
Widget _buildActionRow() {
final translateEnabled = ref.watch(
pluginProvider.select((s) => s.translateEnabled),
@@ -396,77 +406,89 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
final pinyinEnabled = ref.watch(
pluginProvider.select((s) => s.pinyinEnabled),
);
final t = ref.watch(translationsProvider);
return Row(
children: [
// 左侧:作者名(占满剩余空间)
if (sentence.author != null)
Expanded(
child: Text(
'${ref.watch(translationsProvider).home.base.authorPrefix}${sentence.author!}',
'${t.home.base.authorPrefix}${sentence.author!}',
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
else
const Spacer(),
// 中间:工具图标组(拼音/翻译/TTS保留为小图标
if (pinyinEnabled)
Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: GestureDetector(
onTap: _handlePinyinToggle,
child: Icon(
_showPinyin
? CupertinoIcons.textformat_alt
: CupertinoIcons.textformat,
size: 18,
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
),
),
_ToolIconButton(
icon: _showPinyin
? CupertinoIcons.textformat_alt
: CupertinoIcons.textformat,
color: _showPinyin ? const Color(0xFF34C759) : ext.textHint,
semanticLabel: '拼音',
onTap: _handlePinyinToggle,
),
if (translateEnabled)
Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: GestureDetector(
onTap: () => _handleTranslate(),
child: Icon(CupertinoIcons.globe, size: 18, color: ext.accent),
),
_ToolIconButton(
icon: CupertinoIcons.globe,
color: ext.accent,
semanticLabel: '翻译',
onTap: _handleTranslate,
),
if (ttsEnabled)
Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: GestureDetector(
onTap: () => _handleTts(),
child: const Icon(
CupertinoIcons.speaker_2_fill,
size: 18,
color: Color(0xFFAF52DE),
),
),
_ToolIconButton(
icon: CupertinoIcons.speaker_2_fill,
color: const Color(0xFFAF52DE),
semanticLabel: '朗读',
onTap: _handleTts,
),
Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: GestureDetector(
onTap: _isSharing ? null : _shareAsImage,
child: Icon(
CupertinoIcons.share,
size: 18,
color: _isSharing ? ext.textHint : ext.accent,
),
),
),
FavoriteBounceAnimation(
isFavorited: sentence.isFavorited,
onToggle: () {
if (mounted) widget.onFavorite?.call();
},
size: 18,
),
const SizedBox(width: AppSpacing.sm),
LikeAnimation(
isLiked: sentence.isLiked,
onToggle: (_) {
// 右侧液态玻璃点赞按钮icon + 数字)
LiquidGlassActionButton(
icon: sentence.isLiked
? CupertinoIcons.heart_fill
: CupertinoIcons.heart,
label: NumberFormatter.formatCount(sentence.likeCount),
isActive: sentence.isLiked,
activeColor: const Color(0xFFFF3B30), // iOS systemRed
activeColorLight: const Color(0xFFFF6B6B),
ext: ext,
hapticType: HapticType.like,
semanticLabel: sentence.isLiked
? '已点赞 ${sentence.likeCount}'
: '点赞 ${sentence.likeCount}',
onTap: () {
if (mounted) widget.onLike();
},
),
const SizedBox(width: AppSpacing.sm),
// 右侧液态玻璃收藏按钮icon + 文字"收藏"/"已藏"
LiquidGlassActionButton(
icon: sentence.isFavorited
? CupertinoIcons.star_fill
: CupertinoIcons.star,
label: sentence.isFavorited
? t.home.sentenceDetail.favorited
: t.home.sentenceDetail.favorite,
isActive: sentence.isFavorited,
activeColor: const Color(0xFFFF9F0A), // iOS systemOrange
activeColorLight: const Color(0xFFFFB84D),
ext: ext,
hapticType: HapticType.favorite,
semanticLabel: sentence.isFavorited ? '已收藏' : '收藏',
onTap: () {
if (mounted) widget.onFavorite?.call();
},
),
],
);
}
@@ -504,81 +526,316 @@ class _SentenceCardState extends ConsumerState<SentenceCard> {
ref.read(pluginProvider.notifier).incrementPinyinCount();
}
}
}
/// 截图分享 — RepaintBoundary截取卡片 → PNG → share_plus系统分享
Future<void> _shareAsImage() async {
if (_isSharing) return;
if (kIsWeb) {
_shareAsText();
return;
}
setState(() => _isSharing = true);
// ============================================================
// 工具图标按钮 — 拼音/翻译/TTS 共用的小尺寸图标按钮
// ============================================================
try {
await Future<void>.delayed(const Duration(milliseconds: 100));
/// 工具图标按钮(无背景,仅图标 + 间距)
class _ToolIconButton extends StatelessWidget {
const _ToolIconButton({
required this.icon,
required this.color,
required this.semanticLabel,
required this.onTap,
});
final boundary =
_repaintKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null || !boundary.hasSize) {
_shareAsText();
return;
}
final IconData icon;
final Color color;
final String semanticLabel;
final VoidCallback onTap;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
_shareAsText();
return;
}
final buffer = byteData.buffer;
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final filePath = '${tempDir.path}/sentence_$timestamp.png';
final file = File(filePath);
await file.writeAsBytes(
buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes),
);
final authorPart = sentence.author != null
? ' —— ${sentence.author}'
: '';
final t = ref.read(translationsProvider);
final shareText =
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
if (mounted) {
await SharePlus.instance.share(
ShareParams(files: [XFile(filePath)], text: shareText),
);
}
Log.i('分享句子卡片图片: ${sentence.id}');
} catch (e) {
Log.e('句子卡片截图分享失败', e);
_shareAsText();
} finally {
if (mounted) {
setState(() => _isSharing = false);
}
}
}
/// 文本分享降级方案
void _shareAsText() {
final t = ref.read(translationsProvider);
final authorPart = sentence.author != null
? ' ${t.home.base.authorPrefix}${sentence.author}'
: '';
final shareText =
'📝 ${sentence.text}$authorPart\n\n${t.home.base.shareAppSignature}';
SharePlus.instance.share(ShareParams(text: shareText)).catchError((
Object e,
) {
Log.e('文本分享失败', e);
AppToast.showError(t.home.base.shareFailed);
return const ShareResult('', ShareResultStatus.unavailable);
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Semantics(
label: semanticLabel,
button: true,
child: Icon(icon, size: 18, color: color),
),
),
);
}
}
// ============================================================
// 液态玻璃操作按钮 (iOS 26 Liquid Glass 风格)
// ============================================================
/// 按钮触觉类型 — 决定激活/取消时的触觉强度与音效
enum HapticType {
/// 点赞按钮:激活中等冲击 + like_pop 音效;取消轻冲击 + unlike_soft
like,
/// 收藏按钮:激活中等冲击 + favorite_star 音效;取消轻冲击 + unfavorite
favorite,
}
/// 液态玻璃操作按钮
///
/// 长方形 icon + 文字 按钮,支持激活态切换:
/// - 未激活:真正 BackdropFilter 毛玻璃 + 微高光 + 中性色 icon/文字
/// - 已激活:渐变填充 + 内嵌高光 + 投射阴影 + 白色 icon/文字
///
/// 2026-06-27 升级:
/// - 真正使用 BackdropFilter参考 GlassContainer 实践,含性能降级)
/// - 单一 AnimatedBuilder 统一驱动弹跳动画(移除 AnimatedScale + ScaleTransition 双重叠加)
/// - 接入 HapticService + SfxService 差异化触觉与音效
///
/// 设计参考: docs/prototypes/sentence_card_redesign.html 方案C
/// 动态主题: 自动跟随 AppThemeExtension 切换明/暗/AMOLED/主色
class LiquidGlassActionButton extends StatefulWidget {
const LiquidGlassActionButton({
super.key,
required this.icon,
required this.label,
required this.isActive,
required this.activeColor,
required this.activeColorLight,
required this.ext,
required this.onTap,
this.hapticType,
this.semanticLabel,
});
final IconData icon;
final String label;
final bool isActive;
/// 激活态主色(如点赞红、收藏金)
final Color activeColor;
final Color activeColorLight;
/// 主题扩展,用于读取动态主题色
final AppThemeExtension ext;
/// 点击回调
final VoidCallback onTap;
/// 触觉与音效类型null 表示不触发触觉/音效)
final HapticType? hapticType;
/// 无障碍标签
final String? semanticLabel;
@override
State<LiquidGlassActionButton> createState() =>
_LiquidGlassActionButtonState();
}
class _LiquidGlassActionButtonState extends State<LiquidGlassActionButton>
with SingleTickerProviderStateMixin {
late final AnimationController _popController;
late final Animation<double> _popAnimation;
@override
void initState() {
super.initState();
_popController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_popAnimation = Tween<double>(begin: 1.0, end: 1.35).animate(
CurvedAnimation(
parent: _popController,
curve: Curves.easeInOutBack,
),
);
}
@override
void didUpdateWidget(LiquidGlassActionButton oldWidget) {
super.didUpdateWidget(oldWidget);
// 从未激活变为激活时,触发弹跳动画 + 触觉 + 音效
if (!oldWidget.isActive && widget.isActive) {
_popController.forward(from: 0.0);
_fireActivateFeedback();
} else if (oldWidget.isActive && !widget.isActive) {
// 取消激活:轻冲击 + 取消音效
_fireDeactivateFeedback();
}
}
@override
void dispose() {
_popController.dispose();
super.dispose();
}
/// 触发激活态反馈:中等冲击 + 激活音效
void _fireActivateFeedback() {
final type = widget.hapticType;
if (type == null) return;
HapticService.medium();
switch (type) {
case HapticType.like:
SfxService.instance.play(SfxType.like);
case HapticType.favorite:
SfxService.instance.play(SfxType.favorite);
}
}
/// 触发取消态反馈:轻冲击 + 取消音效
void _fireDeactivateFeedback() {
final type = widget.hapticType;
if (type == null) return;
HapticService.light();
switch (type) {
case HapticType.like:
SfxService.instance.play(SfxType.unlike);
case HapticType.favorite:
SfxService.instance.play(SfxType.unfavorite);
}
}
void _handleTap() {
// 点击瞬间先触发反向缩放反馈(动画由 didUpdateWidget 在状态变化时驱动)
_popController.forward(from: 0.0);
widget.onTap();
}
/// 计算有效的 BackdropFilter sigma 值
///
/// 参考 GlassContainer 实践:
/// - 基于 GlassTokens.baseBlur按钮较小用 base 层 10.0
/// - 乘以 ext.glassBlurMultiplier用户可调节的模糊强度倍数
/// - 通过 PerformanceOptimizer 在低端设备/省电模式降级
/// - 鸿蒙端通过 OhosDeviceCapabilities.supportsBackdropFilter 判断是否支持
double? _effectiveBlurSigma() {
// 鸿蒙端不支持 BackdropFilter 时降级
if (isOhos && !OhosDeviceCapabilities.supportsBackdropFilter) return null;
if (!PerformanceOptimizer.instance.shouldEnableBackdropFilter) return null;
final rawSigma = GlassTokens.baseBlur * widget.ext.glassBlurMultiplier;
final sigma =
PerformanceOptimizer.instance.suggestedBlurSigma(rawSigma);
return sigma > 0 ? sigma : null;
}
@override
Widget build(BuildContext context) {
final ext = widget.ext;
final isDark = ext.isDark;
// 未激活态背景色:浅色/深色自适应半透明
// v6.144.0: 加深背景与边框,让用户在未点赞/未收藏时能清晰看到按钮轮廓和点击位置
final inactiveBg = isDark
? const Color(0x26FFFFFF) // 15% white (原 8%)
: const Color(0x1FFFFFFF).withValues(alpha: 0.28); // 28% white (原 18%)
final inactiveBorder = isDark
? const Color(0x4DFFFFFF) // 30% white (原 12%)
: const Color(0x80FFFFFF); // 50% white (原 25%)
final inactiveFg = ext.textSecondary;
// 激活态:渐变填充
final activeBg = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
widget.activeColor.withValues(alpha: 0.92),
widget.activeColorLight.withValues(alpha: 0.88),
],
);
const activeBorder = Color(0x66FFFFFF); // 40% white
const activeFg = Colors.white;
final bgColor = widget.isActive ? null : inactiveBg;
final gradient = widget.isActive ? activeBg : null;
final borderColor = widget.isActive ? activeBorder : inactiveBorder;
final fgColor = widget.isActive ? activeFg : inactiveFg;
// 阴影:激活态有彩色投射
final shadowColor = widget.isActive
? widget.activeColor.withValues(alpha: 0.55)
: Colors.transparent;
final activeShadow = widget.isActive
? [
BoxShadow(
color: shadowColor,
offset: const Offset(0, 4),
blurRadius: 14,
spreadRadius: -2,
),
// 内嵌高光(顶部白线)
const BoxShadow(
color: Color(0x4DFFFFFF),
offset: Offset(0, 1),
),
]
: null;
// 按钮内容
final buttonContent = Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
decoration: BoxDecoration(
color: bgColor,
gradient: gradient,
borderRadius: BorderRadius.circular(999), // pill
// v6.144.0: 未激活态边框加粗 (0.5→1.2),让用户清晰看到按钮可点击区域
border: Border.all(
color: borderColor,
width: widget.isActive ? 0.5 : 1.2,
),
boxShadow: activeShadow,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, size: 14, color: fgColor),
const SizedBox(width: 6),
Text(
widget.label,
style: AppTypography.caption2.copyWith(
color: fgColor,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
// 真正的液态玻璃BackdropFilter 包裹(仅未激活态使用,激活态用渐变填充无需模糊)
// 用 RepaintBoundary 隔离重绘,避免列表滚动时重复模糊计算
final blurSigma = widget.isActive ? null : _effectiveBlurSigma();
final glassChild = blurSigma != null
? RepaintBoundary(
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: buttonContent,
),
),
)
: buttonContent;
return Semantics(
label: widget.semanticLabel,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTap,
// 统一动画驱动:单一 AnimatedBuilder 同时缩放整个按钮(含 icon + 文字)
// 移除原 AnimatedScale + ScaleTransition 双重叠加
child: AnimatedBuilder(
animation: _popAnimation,
builder: (context, child) {
return Transform.scale(
scale: _popAnimation.value,
child: child,
);
},
child: glassChild,
),
),
);
}
}

View File

@@ -1,9 +1,10 @@
/// ============================================================
/// 闲言APP — 首页Feed数据拉取Mixin
/// 创建时间: 2026-05-12
/// 更新时间: 2026-06-23
/// 更新时间: 2026-06-27
/// 作用: 频道/每日推荐/列表/降级/缓存的拉取逻辑
/// 上次更新: 任务6修复 — 去重耗尽时重置seenIds实现循环加载新增resetAndReload方法
/// 上次更新: v6.144.0 — 新增 _mergeLocalInteractionState 合并本地DB互动状态
/// 解决已收藏/已点赞句子重复出现未显示状态;循环加载时保留已收藏句子去重
/// ============================================================
import 'dart:convert';
@@ -74,6 +75,50 @@ mixin HomeFeedMixin on Notifier<HomeState> {
return all.sublist(all.length - _maxSeenHashesForApi);
}
/// 合并本地 DB 的互动状态isFavorite/isLiked
///
/// v6.144.0: 解决"已收藏/已点赞句子重复出现但未显示状态"的问题。
/// 合并策略local true winsOR 合并)
/// - 服务端 true → true已登录用户的服务端权威状态
/// - 服务端 false 但本地 true → true未登录或未同步的本地操作
/// - 两者都 false → false
///
/// 未登录场景:服务端始终返回 false本地 DB 是唯一状态来源。
/// 登录场景:服务端返回权威状态,本地 DB 兜底未同步的离线操作。
Future<List<HomeSentence>> _mergeLocalInteractionState(
List<HomeSentence> sentences,
) async {
if (sentences.isEmpty) return sentences;
try {
final ids = sentences.map((s) => s.id).toList();
final localMap = await feedDb.getSentencesByIds(ids);
if (localMap.isEmpty) return sentences;
bool changed = false;
final merged = sentences.map((s) {
final local = localMap[s.id];
if (local == null) return s;
final mergedLiked = s.isLiked || local.isLiked;
final mergedFavorited = s.isFavorited || local.isFavorite;
if (mergedLiked == s.isLiked && mergedFavorited == s.isFavorited) {
return s;
}
changed = true;
return s.copyWith(
isLiked: mergedLiked,
isFavorited: mergedFavorited,
);
}).toList();
if (changed) {
Log.i('_mergeLocalInteractionState: 合并 ${sentences.length} 条, '
'命中本地 ${localMap.length}');
}
return merged;
} catch (e) {
Log.w('_mergeLocalInteractionState: 合并失败, 保留服务端状态: $e');
return sentences;
}
}
Future<void> fetchRefreshSentences() async {
try {
final seenIds = _buildSeenIdList();
@@ -105,7 +150,7 @@ mixin HomeFeedMixin on Notifier<HomeState> {
// 当选择了具体分类时,只保留该分类的数据
final selectedType = state.selectedType;
final newSentences = result.list
var newSentences = result.list
.map(HomeSentence.fromFeedItem)
.where((s) => s.text.isNotEmpty)
.where((s) {
@@ -119,6 +164,9 @@ mixin HomeFeedMixin on Notifier<HomeState> {
})
.toList();
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
newSentences = await _mergeLocalInteractionState(newSentences);
final unique = newSentences
.where((s) => !allSeenIds.contains(s.id))
.toList();
@@ -623,10 +671,14 @@ mixin HomeFeedMixin on Notifier<HomeState> {
})
.toList();
// v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态
// 注意:此处不使用 var 重赋值,直接生成 mergedSentences 供后续使用
final mergedSentences = await _mergeLocalInteractionState(newSentences);
final selfDeduped = <HomeSentence>[];
final selfSeen = <String>{};
final selfSeenTexts = <String>{};
for (final s in newSentences) {
for (final s in mergedSentences) {
if (!selfSeen.contains(s.id)) {
selfSeen.add(s.id);
if (deduplicateContent && s.text.isNotEmpty) {
@@ -669,33 +721,57 @@ mixin HomeFeedMixin on Notifier<HomeState> {
// 避免骨架屏卡死 — 重置后用本次返回的数据继续填充列表
if (state.cycleRound >= 3) {
Log.w('fetchNewSentences: 去重3轮后仍无新数据, 重置已见集合进入循环加载');
// v6.144.0: 保留已收藏句子的 ID 和文本,避免循环加载时已收藏内容重复出现
// 用户已收藏的句子在"我的收藏"页面可见,无需在广场再次展示
final favoritedIds = state.sentences
.where((s) => s.isFavorited)
.map((s) => s.id)
.toSet();
final favoritedTexts = deduplicateContent
? state.sentences
.where((s) => s.isFavorited && s.text.isNotEmpty)
.map((s) => s.text.trim())
.toSet()
: <String>{};
// 重置已见集合,允许相同内容再次出现(循环加载)
allSeenIds.clear();
if (deduplicateContent) {
allSeenTexts.clear();
}
// 保留最近一批id避免立即重复
final recentIds = state.sentences.length > 20
? state.sentences.sublist(state.sentences.length - 20).map((s) => s.id).toSet()
: <String>{};
final recentTexts = deduplicateContent && state.sentences.length > 20
? state.sentences.sublist(state.sentences.length - 20)
// 恢复已收藏句子的去重记录,确保循环加载时跳过已收藏内容
allSeenIds.addAll(favoritedIds);
if (deduplicateContent) {
allSeenTexts.addAll(favoritedTexts);
}
// 保留最近一批id避免立即重复 + v6.144.0: 已收藏句子 ID 合并排除
final recentIds = <String>{
if (state.sentences.length > 20)
...state.sentences
.sublist(state.sentences.length - 20)
.map((s) => s.id),
...favoritedIds,
};
final recentTexts = <String>{
if (deduplicateContent && state.sentences.length > 20)
...state.sentences
.sublist(state.sentences.length - 20)
.where((s) => s.text.isNotEmpty)
.map((s) => s.text.trim()).toSet()
: <String>{};
.map((s) => s.text.trim()),
...favoritedTexts,
};
// 重新处理本次返回的数据仅排除最近20条
final cycledSentences = newSentences.where((s) {
// 重新处理本次返回的数据仅排除最近20条 + 已收藏内容
final cycledSentences = mergedSentences.where((s) {
if (recentIds.contains(s.id)) return false;
if (deduplicateContent && s.text.isNotEmpty &&
recentTexts.contains(s.text.trim())) return false;
return true;
}).toList();
// 如果排除最近20条后仍为空,直接用全部数据
// 如果排除后仍为空,直接用全部数据(兜底防止空白)
final finalBatch = cycledSentences.isNotEmpty
? cycledSentences
: newSentences;
: mergedSentences;
if (finalBatch.isNotEmpty) {
await saveToDb(finalBatch);

View File

@@ -8,6 +8,7 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -55,8 +56,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
final oldValue = sentence.isLiked;
if (!mounted) return;
// Step 1: 乐观更新UI
updateSentence(id, (s) => s.copyWith(isLiked: !s.isLiked));
// Step 1: 乐观更新UI — 同步翻转 isLiked 和递增/递减 likeCount
// 修复: 原仅翻转 isLikedlikeCount 不变,导致点赞后数字不增加
final newLikeCount = oldValue
? (sentence.likeCount > 0 ? sentence.likeCount - 1 : 0)
: sentence.likeCount + 1;
updateSentence(id, (s) => s.copyWith(
isLiked: !s.isLiked,
likeCount: newLikeCount,
));
SfxService.instance.play(oldValue ? SfxType.unlike : SfxType.like);
ref
.read(characterMoodProvider.notifier)
@@ -77,10 +85,53 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
}
/// 本地点赞持久化:写入本地数据库
///
/// 举一反三:与 _persistFavoriteLocally 同类问题——
/// 若 sentences 表中不存在该 id 的记录,原 toggleLike 的 UPDATE 会静默失败,
/// 导致本地 isLiked 字段未设置,重启 APP 后点赞状态丢失。
/// 修复:先查再决定 set 或 upsert 兜底插入。
Future<void> _persistLikeLocally(String id, bool oldValue) async {
try {
await interactionDb.toggleLike(id);
Log.i('本地点赞已${!oldValue ? "添加" : "取消"}: $id');
final newValue = !oldValue;
final existing = await interactionDb.getSentencesById(id);
if (existing != null) {
await interactionDb.setLikeFlag(id, newValue);
// 同步更新本地 likes 字段,保证 fromDb 读取时 likeCount 正确
final s = findSentence(id);
if (s != null) {
final newLikeCount = oldValue
? (s.likeCount > 0 ? s.likeCount - 1 : 0)
: s.likeCount + 1;
await interactionDb.setLikesCount(id, newLikeCount);
}
} else {
final s = findSentence(id);
await interactionDb.insertOrUpdateSentence(
SentencesCompanion(
id: Value(id),
content: Value(s?.text ?? ''),
author: Value(s?.author ?? ''),
source: Value(s?.source ?? ''),
tags: Value(s?.type ?? ''),
feedType: Value(s?.feedType ?? ''),
feedName: Value(s?.feedName ?? ''),
feedIcon: Value(s?.feedIcon ?? ''),
views: Value(s?.views ?? 0),
imageUrl: const Value(''),
isFavorite: Value(s?.isFavorited ?? false),
isLiked: Value(newValue),
isRead: const Value(false),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
),
);
// 兜底插入后单独写入 likes 字段(SentencesCompanion 缺少 likes 命名参数)
if (s != null) {
await interactionDb.setLikesCount(id, s.likeCount);
}
Log.w('本地点赞 upsert 兜底插入: $id (原记录不存在)');
}
Log.i('本地点赞已${newValue ? "添加" : "取消"}: $id');
} catch (e) {
Log.e('本地点赞同步失败', e);
}
@@ -130,8 +181,15 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
final oldValue = sentence.isFavorited;
if (!mounted) return;
// Step 1: 乐观更新UI
updateSentence(id, (s) => s.copyWith(isFavorited: !s.isFavorited));
// Step 1: 乐观更新UI — 同步翻转 isFavorited 和递增/递减 favoriteCount
// 修复: 与 toggleLike 同类问题,原仅翻转 isFavoritedfavoriteCount 不变
final newFavCount = oldValue
? (sentence.favoriteCount > 0 ? sentence.favoriteCount - 1 : 0)
: sentence.favoriteCount + 1;
updateSentence(id, (s) => s.copyWith(
isFavorited: !s.isFavorited,
favoriteCount: newFavCount,
));
SfxService.instance.play(
oldValue ? SfxType.unfavorite : SfxType.favorite,
);
@@ -157,10 +215,47 @@ mixin HomeInteractionMixin on Notifier<HomeState> {
}
/// 本地收藏持久化:写入本地数据库
///
/// 修复:原实现仅调用 `toggleFavorite(id)` UPDATE若 sentences 表中
/// 不存在该 id 的记录(如 Feed 列表未缓存/缓存被清UPDATE 会静默失败,
/// 导致本地 isFavorite 字段从未被设置 → "我的收藏"页面查不到刚收藏的句子。
///
/// 现在:先查询记录是否存在,
/// - 存在:用 `setFavoriteFlag(id, !oldValue)` 精确设置(不用 toggle 防止并发抖动)
/// - 不存在插入一条最小化记录isFavorite 设为目标值,保证后续可被查询到
Future<void> _persistFavoriteLocally(String id, bool oldValue) async {
try {
await interactionDb.toggleFavorite(id);
Log.i('本地收藏已${!oldValue ? "添加" : "取消"}: $id');
final newValue = !oldValue;
final existing = await interactionDb.getSentencesById(id);
if (existing != null) {
// 记录存在,精确设置(避免 toggle 在并发/重试时翻转两次回到原值)
await interactionDb.setFavoriteFlag(id, newValue);
} else {
// 记录不存在,插入一条最小化兜底记录
// 内容字段从当前内存中的 sentence 取,保证收藏列表可显示
final s = findSentence(id);
await interactionDb.insertOrUpdateSentence(
SentencesCompanion(
id: Value(id),
content: Value(s?.text ?? ''),
author: Value(s?.author ?? ''),
source: Value(s?.source ?? ''),
tags: Value(s?.type ?? ''),
feedType: Value(s?.feedType ?? ''),
feedName: Value(s?.feedName ?? ''),
feedIcon: Value(s?.feedIcon ?? ''),
views: Value(s?.views ?? 0),
imageUrl: const Value(''),
isFavorite: Value(newValue),
isLiked: Value(s?.isLiked ?? false),
isRead: const Value(false),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
),
);
Log.w('本地收藏 upsert 兜底插入: $id (原记录不存在)');
}
Log.i('本地收藏已${newValue ? "添加" : "取消"}: $id');
} catch (e) {
Log.e('本地收藏同步失败', e);
}

View File

@@ -182,6 +182,8 @@ class HomeSentence {
feedName: row.feedName.isEmpty ? null : row.feedName,
feedIcon: row.feedIcon.isEmpty ? null : row.feedIcon,
views: row.views,
// likes 字段: .g.dart 未重新生成(build_runner 阻塞)Sentence 类缺 likes getter
// 此处保持默认 0下次 Feed 列表刷新会从服务端获取真实 like_count
);
}