diff --git a/CHANGELOG.md b/CHANGELOG.md index 849983e3..a06bff20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ids)` 批量查询方法,返回 `Map`,用于合并本地 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` 后缀的 cacheKey(v6.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`(真实数据) +- 端到端验证:登录 → 查询 detail(like_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` 及相关 import(dart: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 编译验证 + 静态分析清理 diff --git a/Scripts/upload_agreements.py b/Scripts/upload_agreements.py index 394e8527..f9e005d0 100644 --- a/Scripts/upload_agreements.py +++ b/Scripts/upload_agreements.py @@ -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/' diff --git a/docs/toolsapi/CHANGELOG.md b/docs/toolsapi/CHANGELOG.md index f3be4840..9facc454 100644 --- a/docs/toolsapi/CHANGELOG.md +++ b/docs/toolsapi/CHANGELOG.md @@ -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) ### 🛡️ 限流排队系统优化 diff --git a/docs/toolsapi/application/admin/command/Install/migrate_v10_3.sql b/docs/toolsapi/application/admin/command/Install/migrate_v10_3.sql new file mode 100644 index 00000000..f593ba82 --- /dev/null +++ b/docs/toolsapi/application/admin/command/Install/migrate_v10_3.sql @@ -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`; diff --git a/docs/toolsapi/application/admin/controller/Userdeletion.php b/docs/toolsapi/application/admin/controller/Userdeletion.php index ec70ac88..7d0240f9 100644 --- a/docs/toolsapi/application/admin/controller/Userdeletion.php +++ b/docs/toolsapi/application/admin/controller/Userdeletion.php @@ -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(); diff --git a/docs/toolsapi/application/api/controller/Feed.php b/docs/toolsapi/application/api/controller/Feed.php index be5ad201..70e20515 100644 --- a/docs/toolsapi/application/api/controller/Feed.php +++ b/docs/toolsapi/application/api/controller/Feed.php @@ -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 从数据库读取管理员设置的推荐权重配置,带缓存 diff --git a/docs/toolsapi/application/api/controller/UserSecurity.php b/docs/toolsapi/application/api/controller/UserSecurity.php index 4a551db3..9f1a2e2d 100644 --- a/docs/toolsapi/application/api/controller/UserSecurity.php +++ b/docs/toolsapi/application/api/controller/UserSecurity.php @@ -15,11 +15,11 @@ use think\Validate; * @time 2026-04-29 * @name UserSecurity * @description 用户安全相关API,含注册/登录/改密/改邮箱/重置密码/回执登录/二维码登录/密保问题等 - * @lastUpdate v10.2.0 register注册赠送50积分+50金币; 补签积分不足提醒增强 + * @lastUpdate v10.3.2 新增SMTP检测/密保登录/邮箱验证码登录/回执码注销(忘记密码场景); accountLookup返回昵称 */ class UserSecurity extends Api { - protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel', 'secQuestions']; + protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel', 'secQuestions', 'accountLookup', 'checkSmtpStatus', 'secQuestionLogin', 'emailCodeLogin', 'sendDeletionCode', 'requestDeletionByReceipt']; protected $noNeedRight = '*'; private static $rateLimitKey = 'api_rate_limit:'; @@ -37,6 +37,12 @@ class UserSecurity extends Api 'cancelDeletion' => ['max' => 10, 'window' => 3600], 'deletionStatus' => ['max' => 60, 'window' => 60], 'changeSecQuestion'=> ['max' => 20, 'window' => 3600], + 'accountLookup' => ['max' => 20, 'window' => 3600], + 'secQuestionLogin' => ['max' => 30, 'window' => 3600], + 'emailCodeLogin' => ['max' => 30, 'window' => 3600], + 'sendDeletionCode' => ['max' => 5, 'window' => 3600], + 'requestDeletionByReceipt' => ['max' => 5, 'window' => 3600], + 'checkSmtpStatus' => ['max' => 30, 'window' => 60], ]; private static $testMode = false; @@ -381,8 +387,8 @@ class UserSecurity extends Api /** * @name 用户注册 - * @desc 注册新用户,需回执验证(客户端已验证邮箱),可选填密保问题,注册赠送50积分+50金币 - * @lastUpdate v10.2.1 修复: 密保问题单独更新避免字段不存在导致500; gold字段容错; 整体try-catch + * @desc 注册新用户,需回执验证(客户端已验证邮箱),可选填密保问题/出生日期,注册赠送50积分+50金币 + * @lastUpdate v10.3.1 新增birthday字段,自动判定未成年状态(COPPA/GDPR-K合规) */ public function register() { @@ -394,10 +400,29 @@ class UserSecurity extends Api $mobileCode = $this->request->post('mobile_code', '', 'trim'); $secQuestion = $this->request->post('sec_question', 0, 'intval'); $secAnswer = $this->request->post('sec_answer', '', 'trim'); + $birthday = $this->request->post('birthday', '', 'trim'); // 格式: YYYY-MM-DD if (!$username || !$password || !$email) { $this->error('用户名、密码和邮箱为必填项'); } + + // 出生日期校验 (feat6 - COPPA/GDPR-K) + $isMinor = 0; + $ageData = null; + if ($birthday) { + $ageData = $this->calculateAge($birthday); + if (!$ageData['valid']) { + $this->error('出生日期格式无效,应为 YYYY-MM-DD'); + } + if ($ageData['age'] < 0 || $ageData['age'] > 120) { + $this->error('出生日期不合理'); + } + // COPPA: 13岁以下禁止注册; GDPR-K: 16岁以下需家长同意 + if ($ageData['age'] < 14) { + $this->error('根据《儿童个人信息网络保护规定》及COPPA要求,14岁以下用户不得注册'); + } + $isMinor = ($ageData['age'] < 18) ? 1 : 0; + } $this->validateLength($username, '用户名', 3, 30); $this->validateLength($password, '密码', 6, 30); $this->detectMaliciousInput($username); @@ -465,6 +490,19 @@ class UserSecurity extends Api } } + // 2.5 保存出生日期与未成年状态 (feat6 - COPPA/GDPR-K) + if ($birthday && $ageData) { + try { + db('user')->where('id', $userId)->update([ + 'birthday' => $birthday, + 'is_minor' => $isMinor, + ]); + } catch (\Exception $e) { + // birthday/is_minor字段可能不存在(未执行迁移),静默跳过 + \think\Log::record('保存birthday失败: ' . $e->getMessage(), 'debug'); + } + } + // 3. 注册赠送50积分 $registerScore = 50; $registerGold = 50; @@ -760,7 +798,7 @@ class UserSecurity extends Api $this->error(__('Email is incorrect')); } - $allowedEvents = ['register', 'changepwd', 'resetpwd', 'changeemail']; + $allowedEvents = ['register', 'changepwd', 'resetpwd', 'changeemail', 'emaillogin']; if (!in_array($event, $allowedEvents)) { $this->error('无效的事件类型'); } @@ -775,8 +813,11 @@ class UserSecurity extends Api $this->error(__('已被注册')); } elseif ($event == 'changeemail' && $userinfo) { $this->error(__('已被占用')); - } elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo) { - $this->error(__('未注册')); + } elseif (in_array($event, ['changepwd', 'resetpwd', 'emaillogin']) && !$userinfo) { + // emaillogin允许未注册(会自动注册),此处不阻止 + if ($event != 'emaillogin') { + $this->error(__('未注册')); + } } $ret = Ems::send($email, null, $event); @@ -1045,6 +1086,9 @@ class UserSecurity extends Api $hours = floor(($remain % 86400) / 3600); $countdown = "{$days}天{$hours}小时后自动注销"; + // 异步发送注销确认邮件 (防止他人冒用账号注销) + $this->sendDeletionNotificationAsync($user, $result, $threeDaysLater, $reason); + $this->success('注销申请已提交', [ 'id' => $result, 'user_id' => $user->id, @@ -1060,6 +1104,79 @@ class UserSecurity extends Api } } + /** + * @name 异步发送注销确认邮件 + * @desc 使用register_shutdown_function在响应发送后执行,不阻塞API响应 + * @param object $user 用户对象 + * @param int $deletionId 注销记录ID + * @param int $autoDeleteTime 自动注销时间戳 + * @param string $reason 注销原因 + * @lastUpdate v10.3.1 新增邮件通知功能 + */ + private function sendDeletionNotificationAsync($user, $deletionId, $autoDeleteTime, $reason) + { + $email = $user->email ?? ''; + if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return; + } + + // 捕获必要变量,在请求结束后异步发送 + $username = $user->username ?? $user->nickname ?? '用户'; + $autoDeleteText = date('Y-m-d H:i:s', $autoDeleteTime); + $requestTime = date('Y-m-d H:i:s', time()); + $ip = $this->request->ip(); + $reasonText = $reason ?: '用户主动申请注销'; + $cancelUrl = Config::get('site.sitename') ? 'https://s2ss.com/agreements/privacy-rights.html?tab=delete' : ''; + + register_shutdown_function(function () use ($email, $username, $deletionId, $autoDeleteText, $requestTime, $ip, $reasonText, $cancelUrl) { + try { + $subject = '【闲言APP】账号注销申请已提交 - 请确认是否本人操作'; + $body = << + +

⚠️ 账号注销申请确认

+

您好 {$username}

+

我们收到了针对您账号的注销申请。如果这是您本人的操作,请忽略此邮件。

+

如果不是您本人操作,请立即登录App或访问以下链接取消注销:

+

取消注销申请

+ + + + + +
申请时间{$requestTime}
注销原因{$reasonText}
IP地址{$ip}
自动注销时间{$autoDeleteText}(3天后)
+
+

⚠️ 注销后果:所有收藏、笔记、签到记录、积分、文章、会员权益等数据将被永久删除且不可恢复

+
+
+

此邮件由系统自动发送,请勿回复。如有疑问请联系 ad@avefs.com

+

© 2026 闲言APP · 弥勒市朋普镇微风暴网络科技工作室

+ +HTML; + + $emailLib = \app\common\library\Email::instance(); + $emailLib->to($email)->subject($subject)->message($body, true)->send(); + } catch (\Exception $e) { + \think\Log::error('注销确认邮件发送失败: ' . $e->getMessage() . ' email=' . $email); + } + }); + } + + /** + * @name 清理过期注销记录 + * @desc 删除6个月前的已完成注销记录(PIPL最小化保留原则),username已哈希存储 + * @lastUpdate v10.3.1 新增 - 注销记录保留期从2年缩短为6个月 + */ + public function cleanupOldDeletionRecords() + { + $sixMonthsAgo = time() - 6 * 30 * 24 * 3600; + $count = db('user_deletion') + ->where('status', 'in', [1, 3]) // 已通过或已自动注销 + ->where('deletetime', '<', $sixMonthsAgo) + ->delete(); + $this->success("已清理 {$count} 条过期注销记录"); + } + /** * @name 查询注销状态 * @desc 查询当前用户的注销申请状态 @@ -1141,6 +1258,167 @@ class UserSecurity extends Api } } + /** + * @name 账号状态查询(应用外) + * @desc 无需登录的公开账号状态查询,满足GooglePlay应用外删除账号要求 + * 返回5种状态: normal(正常)/blocked(封锁)/deleting(注销中)/deleted(已注销)/no_record(无记录) + * 使用回执验证(action=account_lookup, payload=account)防止账号枚举攻击 + * @lastUpdate v10.3.0 新增 - 满足GooglePlay应用外账户管理要求 + */ + public function accountLookup() + { + $this->checkRateLimit('accountLookup'); + $account = $this->request->post('account', '', 'trim'); + + if (!$account) { + $this->error('账号不能为空'); + } + $this->validateLength($account, '账号', 2, 100); + $this->detectMaliciousInput($account); + + // 回执验证 - 防止账号枚举 + $this->verifyReceipt('account_lookup', $account); + + // 自动识别账号类型: 邮箱/手机号/用户名 + $isEmail = filter_var($account, FILTER_VALIDATE_EMAIL) !== false; + $isMobile = preg_match('/^1[3-9]\d{9}$/', $account); + + // 1. 查询user表 + $userQuery = db('user'); + if ($isEmail) { + $userQuery->where('email', $account); + } elseif ($isMobile) { + $userQuery->where('mobile', $account); + } else { + $userQuery->where('username', $account); + } + $user = $userQuery->find(); + + $statusMap = [ + 'normal' => '正常', + 'blocked' => '封锁', + 'deleting' => '注销中', + 'deleted' => '已注销', + 'no_record'=> '无记录', + ]; + + // 2. 用户存在 - 检查是否在注销流程中 + // 注意: 返回nickname(昵称)而非username(账号),保护隐私 + $nickname = isset($user['nickname']) && $user['nickname'] ? $user['nickname'] : ''; + // 若昵称为空,使用username首尾字符+星号脱敏 (如 w***u) + $displayNickname = $nickname; + if (!$displayNickname && isset($user['username']) && $user['username']) { + $uname = $user['username']; + $len = mb_strlen($uname); + if ($len <= 2) { + $displayNickname = $uname[0] . '*'; + } else { + $displayNickname = $uname[0] . str_repeat('*', min($len - 2, 5)) . $uname[$len - 1]; + } + } + + if ($user) { + $deletion = db('user_deletion') + ->where('user_id', $user['id']) + ->order('createtime desc') + ->find(); + + if ($deletion) { + // 注销中(待审核) + if ($deletion['status'] == 0) { + $remain = $deletion['auto_delete_time'] - time(); + $days = floor(max(0, $remain) / 86400); + $hours = floor(max(0, $remain) % 86400 / 3600); + $this->success('查询成功', [ + 'status' => 'deleting', + 'status_text' => $statusMap['deleting'], + 'nickname' => $displayNickname, + 'has_pending_deletion' => true, + 'auto_delete_time' => $deletion['auto_delete_time'], + 'auto_delete_time_text' => date('Y-m-d H:i:s', $deletion['auto_delete_time']), + 'countdown' => $remain > 0 ? "{$days}天{$hours}小时后自动注销" : '即将自动注销', + 'createtime_text' => date('Y-m-d H:i:s', $deletion['createtime']), + 'can_cancel' => true, + ]); + } + // 已通过/已自动注销 - 但用户记录还在(理论上不应发生) + if (in_array($deletion['status'], [1, 3])) { + $this->success('查询成功', [ + 'status' => 'deleted', + 'status_text' => $statusMap['deleted'], + 'nickname' => $displayNickname, + 'has_pending_deletion' => false, + 'deletetime_text' => $deletion['deletetime'] ? date('Y-m-d H:i:s', $deletion['deletetime']) : date('Y-m-d H:i:s', $deletion['updatetime']), + ]); + } + } + + // 检查用户状态: normal/hidden + $userStatus = isset($user['status']) ? $user['status'] : 'normal'; + if ($userStatus === 'hidden') { + $this->success('查询成功', [ + 'status' => 'blocked', + 'status_text' => $statusMap['blocked'], + 'nickname' => $displayNickname, + 'has_pending_deletion' => false, + ]); + } + + $this->success('查询成功', [ + 'status' => 'normal', + 'status_text' => $statusMap['normal'], + 'nickname' => $displayNickname, + 'has_pending_deletion' => false, + ]); + } + + // 3. 用户不存在 - 查询user_deletion表(可能已注销) + $deletionQuery = db('user_deletion'); + if ($isEmail) { + // user_deletion表只存username, 邮箱无法直接匹配 + $deletion = null; + } elseif ($isMobile) { + $deletion = null; + } else { + $deletion = $deletionQuery->where('username', $account) + ->order('createtime desc') + ->find(); + } + + if ($deletion) { + // 注销中(用户已无法登录但记录还在等待审核) + if ($deletion['status'] == 0) { + $remain = $deletion['auto_delete_time'] - time(); + $days = floor(max(0, $remain) / 86400); + $hours = floor(max(0, $remain) % 86400 / 3600); + $this->success('查询成功', [ + 'status' => 'deleting', + 'status_text' => $statusMap['deleting'], + 'has_pending_deletion' => true, + 'auto_delete_time_text' => date('Y-m-d H:i:s', $deletion['auto_delete_time']), + 'countdown' => $remain > 0 ? "{$days}天{$hours}小时后自动注销" : '即将自动注销', + 'can_cancel' => false, + ]); + } + // 已注销 + if (in_array($deletion['status'], [1, 3])) { + $this->success('查询成功', [ + 'status' => 'deleted', + 'status_text' => $statusMap['deleted'], + 'has_pending_deletion' => false, + 'deletetime_text' => $deletion['deletetime'] ? date('Y-m-d H:i:s', $deletion['deletetime']) : date('Y-m-d H:i:s', $deletion['updatetime']), + ]); + } + } + + // 4. 真正无记录 + $this->success('查询成功', [ + 'status' => 'no_record', + 'status_text' => $statusMap['no_record'], + 'has_pending_deletion' => false, + ]); + } + /** * @name 取消二维码 * @desc 取消二维码登录 @@ -1310,4 +1588,772 @@ class UserSecurity extends Api // 静默失败,不影响主流程 } } + + // ================================================================ + // 隐私权管理增强 API (v10.3.1) + // ================================================================ + + /** + * @name 获取用户隐私偏好设置 + * @desc 查询用户对各类数据处理的同意状态 + * @lastUpdate v10.3.1 新增 + */ + public function getConsents() + { + $this->checkRateLimit('deletionStatus'); + $user = $this->auth->getUser(); + + $consentTypes = ['analytics', 'marketing', 'personalization', 'third_party_share']; + $defaults = ['analytics' => 1, 'marketing' => 1, 'personalization' => 1, 'third_party_share' => 0]; + + $records = db('user_consents')->where('user_id', $user->id)->select(); + $map = []; + foreach ($records as $r) { + $map[$r['consent_type']] = intval($r['consent_value']); + } + + $result = []; + foreach ($consentTypes as $type) { + $result[] = [ + 'type' => $type, + 'value' => isset($map[$type]) ? $map[$type] : $defaults[$type], + 'default' => $defaults[$type], + ]; + } + + $this->success('', ['consents' => $result]); + } + + /** + * @name 更新用户隐私偏好设置 + * @desc 批量更新用户对各类数据处理的同意状态 + * @lastUpdate v10.3.1 新增 + */ + public function updateConsents() + { + $this->checkRateLimit('changeemail'); + $user = $this->auth->getUser(); + + $consentsJson = $this->request->post('consents', '', 'trim'); + if (!$consentsJson) { + $this->error('参数不能为空'); + } + + $consents = json_decode($consentsJson, true); + if (!is_array($consents)) { + $this->error('参数格式错误'); + } + + $allowedTypes = ['analytics', 'marketing', 'personalization', 'third_party_share']; + $now = time(); + $ip = $this->request->ip(); + $updated = 0; + + foreach ($consents as $item) { + if (!isset($item['type']) || !in_array($item['type'], $allowedTypes)) { + continue; + } + $value = isset($item['value']) ? (intval($item['value']) ? 1 : 0) : 1; + + $exists = db('user_consents') + ->where('user_id', $user->id) + ->where('consent_type', $item['type']) + ->find(); + + if ($exists) { + db('user_consents')->where('id', $exists['id'])->update([ + 'consent_value' => $value, + 'consent_source' => 'web', + 'ip' => $ip, + 'updatetime' => $now, + ]); + } else { + db('user_consents')->insert([ + 'user_id' => $user->id, + 'consent_type' => $item['type'], + 'consent_value' => $value, + 'consent_source'=> 'web', + 'ip' => $ip, + 'createtime' => $now, + 'updatetime' => $now, + ]); + } + $updated++; + } + + $this->success("已更新 {$updated} 项隐私偏好设置"); + } + + /** + * @name 获取用户登录设备列表 + * @desc 查询用户所有登录设备,含最后活跃时间和位置 + * @lastUpdate v10.3.1 新增 + */ + public function listDevices() + { + $this->checkRateLimit('deletionStatus'); + $user = $this->auth->getUser(); + + $devices = db('user_device') + ->where('user_id', $user->id) + ->order('last_active_time desc') + ->limit(20) + ->select(); + + $result = []; + foreach ($devices as &$d) { + $d['last_active_text'] = $d['last_active_time'] ? date('Y-m-d H:i:s', $d['last_active_time']) : '-'; + $d['is_current'] = ($d['ip'] === $this->request->ip()) ? 1 : 0; + $d['is_online_text'] = $d['is_online'] ? '在线' : '离线'; + $result[] = [ + 'id' => $d['id'], + 'device_name' => $d['device_name'] ?: '未知设备', + 'device_model' => $d['device_model'] ?: '', + 'platform' => $d['platform'] ?: '', + 'app_name' => $d['app_name'] ?: '', + 'ip' => $d['ip'] ?: '', + 'ip_city' => $d['ip_city'] ?? '', + 'device_id' => $d['device_id'] ?: '', + 'last_active_time'=> $d['last_active_text'], + 'is_online' => $d['is_online'], + 'is_online_text' => $d['is_online_text'], + 'is_current' => $d['is_current'], + ]; + } + + $this->success('', ['devices' => $result, 'total' => count($result)]); + } + + /** + * @name 远程登出设备 + * @desc 撤销指定设备的登录状态,清除该设备的Token + * @lastUpdate v10.3.1 新增 + */ + public function revokeDevice() + { + $this->checkRateLimit('cancelDeletion'); + $user = $this->auth->getUser(); + + $deviceId = $this->request->post('device_id', '', 'trim'); + $deviceDbId = $this->request->post('id', 0, 'intval'); + + if (!$deviceId && !$deviceDbId) { + $this->error('参数错误'); + } + + $query = db('user_device')->where('user_id', $user->id); + if ($deviceDbId) { + $query->where('id', $deviceDbId); + } else { + $query->where('device_id', $deviceId); + } + + $device = $query->find(); + if (!$device) { + $this->error('设备不存在'); + } + + // 标记设备为离线 + db('user_device')->where('id', $device['id'])->update([ + 'is_online' => 0, + 'updatetime' => time(), + ]); + + // 删除该设备关联的Token + try { + db('user_token') + ->where('user_id', $user->id) + ->whereLike('token', '%' . substr($device['device_id'] ?? '', 0, 8) . '%') + ->delete(); + } catch (\Exception $e) {} + + $this->success('设备已登出'); + } + + /** + * @name 查询注销数据删除进度 + * @desc 返回注销过程中各类数据的删除状态 + * @lastUpdate v10.3.1 新增 + */ + public function deletionProgress() + { + $this->checkRateLimit('deletionStatus'); + $user = $this->auth->getUser(); + + $record = db('user_deletion') + ->where('user_id', $user->id) + ->order('createtime desc') + ->find(); + + if (!$record) { + $this->success('', ['has_record' => false]); + } + + $progress = []; + if (!empty($record['deletion_progress'])) { + $progress = json_decode($record['deletion_progress'], true) ?: []; + } + + // 如果没有进度记录但状态已是已完成,生成默认进度 + if (empty($progress) && in_array($record['status'], [1, 3])) { + $progress = [ + ['table' => 'user', 'name' => '账号主数据', 'status' => 'done'], + ['table' => 'user_token', 'name' => '登录凭证', 'status' => 'done'], + ['table' => 'user_device', 'name' => '设备记录', 'status' => 'done'], + ['table' => 'user_favorite', 'name' => '收藏数据', 'status' => 'done'], + ['table' => 'user_note', 'name' => '笔记数据', 'status' => 'done'], + ['table' => 'user_signin', 'name' => '签到记录', 'status' => 'done'], + ['table' => 'user_score_log', 'name' => '积分流水', 'status' => 'done'], + ['table' => 'article', 'name' => '文章数据', 'status' => 'done'], + ]; + } elseif (empty($progress) && $record['status'] == 0) { + $progress = [ + ['table' => 'user', 'name' => '账号主数据', 'status' => 'pending'], + ['table' => 'user_token', 'name' => '登录凭证', 'status' => 'pending'], + ['table' => 'user_device', 'name' => '设备记录', 'status' => 'pending'], + ['table' => 'user_favorite', 'name' => '收藏数据', 'status' => 'pending'], + ['table' => 'user_note', 'name' => '笔记数据', 'status' => 'pending'], + ['table' => 'user_signin', 'name' => '签到记录', 'status' => 'pending'], + ['table' => 'user_score_log', 'name' => '积分流水', 'status' => 'pending'], + ['table' => 'article', 'name' => '文章数据', 'status' => 'pending'], + ]; + } + + $total = count($progress); + $done = count(array_filter($progress, function($p) { return ($p['status'] ?? '') === 'done'; })); + $percentage = $total > 0 ? round($done / $total * 100) : 0; + + $statusMap = [0 => '待审核', 1 => '已通过(已注销)', 2 => '已拒绝', 3 => '已自动注销']; + + $this->success('', [ + 'has_record' => true, + 'status' => $record['status'], + 'status_text' => $statusMap[$record['status']] ?? '未知', + 'progress' => $progress, + 'percentage' => $percentage, + 'done_count' => $done, + 'total_count' => $total, + 'summary' => $record['deletion_summary'] ?? '', + 'createtime_text' => date('Y-m-d H:i:s', $record['createtime']), + 'deletetime_text' => $record['deletetime'] ? date('Y-m-d H:i:s', $record['deletetime']) : null, + ]); + } + + /** + * @name 计算年龄 + * @desc 根据出生日期(YYYY-MM-DD)计算当前周岁年龄 + * @lastUpdate v10.3.1 新增 (feat6) + * @param string $birthday 出生日期 YYYY-MM-DD + * @return array ['valid' => bool, 'age' => int] + */ + private function calculateAge($birthday) + { + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthday, $m)) { + return ['valid' => false, 'age' => 0]; + } + $year = intval($m[1]); + $month = intval($m[2]); + $day = intval($m[3]); + if (!checkdate($month, $day, $year)) { + return ['valid' => false, 'age' => 0]; + } + $now = getdate(); + $age = $now['year'] - $year; + if ($now['mon'] < $month || ($now['mon'] == $month && $now['mday'] < $day)) { + $age--; + } + return ['valid' => true, 'age' => $age]; + } + + /** + * @name 查询家长控制状态 + * @desc 返回当前用户的未成年状态、年龄、家长控制配置(COPPA/GDPR-K合规) + * @lastUpdate v10.3.1 新增 (feat6) + */ + public function parentalControlStatus() + { + $user = $this->auth->getUser(); + $userRow = db('user')->where('id', $user->id)->find(); + + $birthday = $userRow['birthday'] ?? ''; + $isMinor = intval($userRow['is_minor'] ?? 0); + $age = null; + if ($birthday) { + $ageData = $this->calculateAge($birthday); + if ($ageData['valid']) { + $age = $ageData['age']; + // 自动更新is_minor状态(若已成年需同步) + $newIsMinor = ($age < 18) ? 1 : 0; + if ($newIsMinor !== $isMinor) { + try { + db('user')->where('id', $user->id)->update(['is_minor' => $newIsMinor]); + $isMinor = $newIsMinor; + } catch (\Exception $e) {} + } + } + } + + // 家长控制配置(默认值,后续可扩展为独立表存储) + $parentalConfig = [ + 'daily_limit_minutes' => $isMinor ? 120 : 0, // 未成年每日120分钟,成年无限制 + 'content_filter_level' => $isMinor ? 'strict' : 'off', // 内容过滤级别 + 'night_mode_enabled' => $isMinor ? true : false, // 夜间模式(22:00-06:00禁用) + 'purchase_blocked' => $isMinor ? true : false, // 禁止消费 + ]; + + $this->success('', [ + 'has_birthday' => !empty($birthday), + 'birthday' => $birthday, + 'age' => $age, + 'is_minor' => $isMinor, + 'is_adult' => $isMinor ? 0 : 1, + 'parental_control' => $parentalConfig, + 'policy' => [ + 'min_age' => 14, + 'adult_age' => 18, + 'consent_age' => 16, // GDPR-K家长同意年龄 + 'description' => '14岁以下禁止注册,14-17岁需家长同意,18岁及以上为完全民事行为能力人', + ], + ]); + } + + /** + * @name 更新出生日期 + * @desc 已注册用户补充出生日期信息,自动更新未成年状态 + * @lastUpdate v10.3.1 新增 (feat6) + */ + public function updateBirthday() + { + $this->checkRateLimit('changeemail'); + $user = $this->auth->getUser(); + $birthday = $this->request->post('birthday', '', 'trim'); + + if (!$birthday) { + $this->error('出生日期不能为空'); + } + $ageData = $this->calculateAge($birthday); + if (!$ageData['valid']) { + $this->error('出生日期格式无效,应为 YYYY-MM-DD'); + } + if ($ageData['age'] < 0 || $ageData['age'] > 120) { + $this->error('出生日期不合理'); + } + if ($ageData['age'] < 14) { + $this->error('根据COPPA/GDPR-K要求,14岁以下用户不得使用本服务'); + } + + $isMinor = ($ageData['age'] < 18) ? 1 : 0; + try { + db('user')->where('id', $user->id)->update([ + 'birthday' => $birthday, + 'is_minor' => $isMinor, + 'updatetime'=> time(), + ]); + } catch (\Exception $e) { + $this->error('保存失败,可能未执行数据库迁移: ' . $e->getMessage()); + } + + $this->success('出生日期已更新', [ + 'birthday' => $birthday, + 'age' => $ageData['age'], + 'is_minor' => $isMinor, + ]); + } + + // ============================================================ + // v10.3.2 新增功能 + // ============================================================ + + /** + * @name SMTP配置状态检测 + * @desc 检测服务器SMTP邮件配置是否完整可用(不暴露敏感信息) + * @lastUpdate v10.3.2 新增 + */ + public function checkSmtpStatus() + { + $this->checkRateLimit('checkSmtpStatus'); + $site = Config::get('site') ?: []; + + // 检查必要配置项 + $host = isset($site['mail_smtp_host']) ? $site['mail_smtp_host'] : ''; + $port = isset($site['mail_smtp_port']) ? $site['mail_smtp_port'] : ''; + $user = isset($site['mail_smtp_user']) ? $site['mail_smtp_user'] : ''; + $pass = isset($site['mail_smtp_pass']) ? $site['mail_smtp_pass'] : ''; + $from = isset($site['mail_from']) ? $site['mail_from'] : ''; + $mailType = isset($site['mail_type']) ? intval($site['mail_type']) : 0; + $verifyType = isset($site['mail_verify_type']) ? intval($site['mail_verify_type']) : 0; + + $missing = []; + if (!$host) $missing[] = 'SMTP主机'; + if (!$port) $missing[] = 'SMTP端口'; + if (!$user) $missing[] = 'SMTP用户名'; + if (!$pass) $missing[] = 'SMTP密码'; + if (!$from) $missing[] = '发件邮箱'; + if ($mailType == 0) $missing[] = '邮件功能未开启(mail_type=0)'; + + $isConfigured = empty($missing); + $secureMap = [0 => '无加密', 1 => 'TLS', 2 => 'SSL']; + $secure = $secureMap[$verifyType] ?? '未知'; + + // 尝试TCP连接检测(不发送邮件,只检测端口可达性) + $portReachable = false; + $connectError = ''; + if ($isConfigured && $host && $port) { + // 使用fsockopen非阻塞检测 + $fp = @fsockopen($host, intval($port), $errno, $errstr, 3); + if ($fp) { + $portReachable = true; + fclose($fp); + } else { + $connectError = $errstr ?: '连接超时'; + } + } + + $this->success('', [ + 'is_configured' => $isConfigured, + 'is_enabled' => $mailType != 0, + 'host' => $host ?: '(未配置)', + 'port' => $port ?: '(未配置)', + 'secure' => $secure, + 'from_email' => $from ?: '(未配置)', + // 不返回密码等敏感信息 + 'has_password' => !empty($pass), + 'has_username' => !empty($user), + 'port_reachable' => $portReachable, + 'connect_error' => $connectError, + 'missing_items' => $missing, + 'summary' => $isConfigured + ? ($portReachable ? '✅ SMTP配置完整且端口可达' : '⚠️ SMTP配置完整但端口不可达: ' . $connectError) + : '❌ SMTP配置不完整, 缺失: ' . implode(', ', $missing), + ]); + } + + /** + * @name 密保问题登录 + * @desc 用户通过账号+密保问题+答案登录(无需密码) + * @lastUpdate v10.3.2 新增 (支持忘记密码场景) + */ + public function secQuestionLogin() + { + $this->checkRateLimit('secQuestionLogin'); + $account = $this->request->post('account', '', 'trim'); + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + + if (!$account || !$secAnswer) { + $this->error('账号和密保答案不能为空'); + } + $this->validateLength($account, '账号', 2, 100); + $this->validateLength($secAnswer, '密保答案', 1, 100); + $this->detectMaliciousInput($account); + + // 查找用户 + $user = $this->findUserByAccount($account); + if (!$user) { + $this->error(__('User not found')); + } + if ($user->status != 'normal') { + $this->error(__('Account is locked')); + } + + // 验证密保答案 + if (empty($user->sec_question) || empty($user->sec_answer)) { + $this->error('该用户未设置密保问题,无法使用此登录方式'); + } + $inputHash = $this->hashSecAnswer($secAnswer); + if ($inputHash !== $user->sec_answer) { + $this->error('密保答案不正确'); + } + + $ret = $this->auth->direct($user->id); + if ($ret) { + $this->recordLoginDevice($user->id); + $this->updateOnlineStatus($user->id); + $token = $this->auth->getToken(); + $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; + $this->success(__('Logged in successful'), $data); + } else { + $this->error($this->auth->getError()); + } + } + + /** + * @name 邮件验证码登录 + * @desc 用户通过邮箱+验证码登录(无需密码),未注册自动创建账号 + * @lastUpdate v10.3.2 新增 + */ + public function emailCodeLogin() + { + $this->checkRateLimit('emailCodeLogin'); + $email = $this->request->post('email', '', 'trim'); + $captcha = $this->request->post('captcha', '', 'trim'); + + if (!$email || !$captcha) { + $this->error('邮箱和验证码不能为空'); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('邮箱格式不正确'); + } + $this->validateLength($captcha, '验证码', 4, 8); + + // 校验验证码 + if (self::$testMode && $captcha === '888888') { + $verified = true; + } else { + $verified = false; + try { + $verified = Ems::check($email, $captcha, 'emaillogin'); + } catch (\Exception $e) { + $this->error('验证码服务暂不可用'); + } + } + if (!$verified) { + $this->error('验证码不正确或已过期'); + } + + $user = \app\common\model\User::getByEmail($email); + if ($user) { + if ($user->status != 'normal') { + $this->error(__('Account is locked')); + } + $ret = $this->auth->direct($user->id); + } else { + // 邮箱未注册 - 自动注册 + $username = 'user_' . substr(md5($email . time()), 0, 10); + $ret = $this->auth->register($username, Random::alnum(10), $email, '', []); + } + if ($ret) { + try { Ems::flush($email, 'emaillogin'); } catch (\Exception $e) {} + $this->recordLoginDevice($this->auth->id); + $this->updateOnlineStatus($this->auth->id); + $token = $this->auth->getToken(); + $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; + $this->success(__('Logged in successful'), $data); + } else { + $this->error($this->auth->getError()); + } + } + + /** + * @name 发送注销回执码(忘记密码注销) + * @desc 用户填写账号+邮箱/密保答案验证身份后,系统生成注销回执码发送到注册邮箱 + * 用户凭回执码可提交注销(无需登录),用于忘记密码场景 + * @lastUpdate v10.3.2 新增 + */ + public function sendDeletionCode() + { + $this->checkRateLimit('sendDeletionCode'); + $account = $this->request->post('account', '', 'trim'); + $verifyMethod = $this->request->post('verify_method', 'email', 'trim'); // email/sec_question + $emailInput = $this->request->post('email', '', 'trim'); + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + + if (!$account) { + $this->error('账号不能为空'); + } + $this->validateLength($account, '账号', 2, 100); + $this->detectMaliciousInput($account); + + $user = $this->findUserByAccount($account); + if (!$user) { + $this->error(__('User not found')); + } + + // 验证身份 + $userEmail = $user->email ?: ''; + if ($verifyMethod === 'email') { + // 邮箱验证: 用户填写的邮箱必须与注册邮箱一致 + if (!$emailInput) { + $this->error('请填写注册邮箱'); + } + if (strtolower($emailInput) !== strtolower($userEmail)) { + $this->error('邮箱与账号不匹配'); + } + } elseif ($verifyMethod === 'sec_question') { + // 密保验证: 校验密保答案 + if (!$secAnswer) { + $this->error('请填写密保答案'); + } + if (empty($user->sec_question) || empty($user->sec_answer)) { + $this->error('该用户未设置密保问题'); + } + if ($this->hashSecAnswer($secAnswer) !== $user->sec_answer) { + $this->error('密保答案不正确'); + } + } else { + $this->error('不支持的验证方式'); + } + + if (!$userEmail || !filter_var($userEmail, FILTER_VALIDATE_EMAIL)) { + $this->error('该账号未绑定有效邮箱,无法发送回执码,请联系客服'); + } + + // 检查是否已有进行中的注销申请 + $existingDeletion = db('user_deletion') + ->where('user_id', $user->id) + ->where('status', 0) + ->find(); + if ($existingDeletion) { + $this->error('该账号已有进行中的注销申请,无需重复提交'); + } + + // 生成6位注销回执码(有效期30分钟) + $deletionCode = sprintf('%06d', mt_rand(100000, 999999)); + $cacheKey = 'deletion_code:' . $user->id; + cache($cacheKey, json_encode([ + 'user_id' => $user->id, + 'account' => $account, + 'email' => $userEmail, + 'code' => $deletionCode, + 'createtime' => time(), + ]), 1800); // 30分钟过期 + + // 异步发送邮件 + $username = $user->username ?? $user->nickname ?? '用户'; + register_shutdown_function(function () use ($userEmail, $username, $deletionCode) { + try { + $subject = '【闲言APP】账号注销回执码 - ' . $deletionCode; + $body = << + + +
+

🔐 账号注销回执码

+

亲爱的 {$username}

+

您正在进行账号注销操作。请使用以下回执码在注销页面完成注销提交:

+
+{$deletionCode} +
+

⚠️ 回执码有效期为30分钟,请尽快使用。

+

如非本人操作,请忽略此邮件,您的账号安全不受影响。

+
+

闲言APP隐私安全团队 · 此邮件由系统自动发送

+
+ + +HTML; + $emailLib = \app\common\library\Email::instance(); + $emailLib->to($userEmail)->subject($subject)->message($body, true)->send(); + } catch (\Exception $e) { + \think\Log::error('注销回执码邮件发送失败: ' . $e->getMessage()); + } + }); + + $this->success('注销回执码已发送到注册邮箱', [ + 'email_masked' => $this->maskEmail($userEmail), + 'expires_in' => 1800, + ]); + } + + /** + * @name 凭回执码提交注销(无需登录) + * @desc 用户使用收到的注销回执码提交注销申请,用于忘记密码场景 + * @lastUpdate v10.3.2 新增 + */ + public function requestDeletionByReceipt() + { + $this->checkRateLimit('requestDeletionByReceipt'); + $account = $this->request->post('account', '', 'trim'); + $deletionCode = $this->request->post('deletion_code', '', 'trim'); + $reason = $this->request->post('reason', '', 'trim'); + + if (!$account || !$deletionCode) { + $this->error('账号和注销回执码不能为空'); + } + $this->validateLength($account, '账号', 2, 100); + $this->validateLength($deletionCode, '回执码', 6, 8); + $this->detectMaliciousInput($account); + + $user = $this->findUserByAccount($account); + if (!$user) { + $this->error(__('User not found')); + } + + // 校验回执码 + $cacheKey = 'deletion_code:' . $user->id; + $cachedData = cache($cacheKey); + if (!$cachedData) { + $this->error('回执码已过期或不存在,请重新申请'); + } + $codeData = json_decode($cachedData, true); + if (!$codeData || !isset($codeData['code']) || $codeData['code'] !== $deletionCode) { + $this->error('回执码不正确'); + } + if (strtolower($codeData['email']) !== strtolower($user->email)) { + $this->error('账号信息不匹配'); + } + + // 检查是否已有进行中的注销申请 + $existing = db('user_deletion') + ->where('user_id', $user->id) + ->where('status', 0) + ->find(); + if ($existing) { + $this->error('该账号已有进行中的注销申请'); + } + + // 创建注销申请 + $autoDeleteDays = 15; + $autoDeleteTime = time() + $autoDeleteDays * 86400; + $deletionId = db('user_deletion')->insertGetId([ + 'user_id' => $user->id, + 'username' => $user->username ?: $account, + 'email' => $user->email ?: '', + 'reason' => $this->sanitizeString($reason, 500), + 'status' => 0, + 'auto_delete_time' => $autoDeleteTime, + 'createtime' => time(), + 'updatetime' => time(), + 'source' => 'web_receipt', + ]); + + // 清除回执码缓存 + cache($cacheKey, null); + + // 异步发送确认邮件 + $this->sendDeletionNotificationAsync($user, $deletionId, $autoDeleteTime, $reason); + + $this->success('注销申请已提交', [ + 'deletion_id' => $deletionId, + 'auto_delete_time_text'=> date('Y-m-d H:i:s', $autoDeleteTime), + 'countdown_days' => $autoDeleteDays, + 'can_cancel' => true, + ]); + } + + /** + * @name 根据账号查找用户(内部辅助方法) + * @desc 支持用户名/邮箱/手机号查找 + * @lastUpdate v10.3.2 新增 + */ + private function findUserByAccount($account) + { + if (Validate::is($account, 'email')) { + return \app\common\model\User::getByEmail($account); + } elseif (Validate::regex($account, '^1[3-9]\\d{9}$')) { + return \app\common\model\User::getByMobile($account); + } else { + return \app\common\model\User::getByUsername($account); + } + } + + /** + * @name 邮箱脱敏 + * @desc 将邮箱地址中间部分替换为星号 (如 a***@example.com) + * @lastUpdate v10.3.2 新增 + */ + private function maskEmail($email) + { + if (!$email || strpos($email, '@') === false) { + return $email; + } + list($local, $domain) = explode('@', $email, 2); + $localLen = strlen($local); + if ($localLen <= 1) { + return $local . '***@' . $domain; + } elseif ($localLen <= 3) { + return $local[0] . '***@' . $domain; + } else { + return substr($local, 0, 2) . str_repeat('*', min($localLen - 2, 5)) . '@' . $domain; + } + } } diff --git a/docs/toolsapi/application/route.php b/docs/toolsapi/application/route.php index 86bbac03..6c9c4aff 100644 --- a/docs/toolsapi/application/route.php +++ b/docs/toolsapi/application/route.php @@ -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社交登录 diff --git a/docs/toolsapi/docs/API_FEED_DOC.md b/docs/toolsapi/docs/API_FEED_DOC.md index d70335dc..445af8b4 100644 --- a/docs/toolsapi/docs/API_FEED_DOC.md +++ b/docs/toolsapi/docs/API_FEED_DOC.md @@ -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 diff --git a/docs/toolsapi/public/agreements/account-agreement.html b/docs/toolsapi/public/agreements/account-agreement.html index dd741cad..40f8aa93 100644 --- a/docs/toolsapi/public/agreements/account-agreement.html +++ b/docs/toolsapi/public/agreements/account-agreement.html @@ -261,9 +261,9 @@

闲言APP 账号使用协议

-

版本号:V6.5

-

更新日期:2026年5月20日

-

生效日期:2026年5月21日

+

版本号:V6.6

+

更新日期:2026年6月17日

+

生效日期:2026年6月17日

本协议是您与弥勒市朋普镇微风暴网络科技工作室关于闲言APP账号注册、使用、安全及注销等事项的约定。请您仔细阅读并充分理解本协议。

一、账号注册

1.1 注册资格

@@ -317,6 +317,20 @@
  • 涉嫌违法违规活动
  • 长期未登录(超过2年
  • +

    3.4 长期未登录账号回收政策

    +

    为合理利用服务器资源并保护账号安全,我们制定了以下长期未登录账号回收规则:

    +
      +
    • 连续12个月未登录:账号将进入"休眠状态",我们将通过App内消息、邮件或短信方式向您发送提醒通知
    • +
    • 连续18个月未登录:账号下的虚拟财产(积分、金币等)将停止产生,但不被清零;账号仍可正常登录恢复使用
    • +
    • 连续24个月(2年)未登录:我们将对账号进行回收处理,具体处理方式如下:
    • +
    • — 账号将被永久冻结,无法登录
    • +
    • — 个人资料、收藏、笔记、签到记录等数据将被永久删除且不可恢复
    • +
    • — 账号关联的邮箱、手机号将被解绑,可重新注册新账号
    • +
    • — 在回收处理前30天,我们将通过注册邮箱/手机号发送最终通知
    • +
    • 回收处理后,您将无法找回该账号及其数据
    • +
    • 为避免账号被回收,请至少每12个月登录一次
    • +
    +

    💡 提示:账号回收处理与主动注销的后果一致,均会导致数据永久删除。建议您定期登录以保留账号和数据。

    四、账号注销

    4.1 注销条件

    您有权随时申请注销账号,注销前请确保:

    @@ -351,6 +365,30 @@
  • 会员权益、积分、金币等虚拟财产将清零且不可恢复
  • 注销后无法恢复,请谨慎操作
  • +

    4.5 服务器数据删除时限

    +

    关于注销后服务器数据的删除时间,我们承诺以下规则:

    +
      +
    • 管理员审核通过后立即删除:管理员通过注销申请后,服务器将在5分钟内完成以下数据的硬删除:
    • +
    • — 用户主表(tool_user)记录
    • +
    • — 用户Token、登录设备、在线状态等会话数据
    • +
    • — 用户收藏、笔记、点赞等互动数据
    • +
    • — 用户签到记录、积分流水、金币流水
    • +
    • — 用户文章、评论及关联内容(作者字段匿名化)
    • +
    • 审核期超时自动注销:提交申请后3天内若管理员未审核,系统将自动执行注销,删除范围同上
    • +
    • 注销记录保留:为满足法律法规和审计要求,注销操作记录将保留6个月,且用户名在注销完成后立即进行SHA256哈希处理,仅保留不可逆的哈希值用于审计
    • +
    • 数据库备份遵循30天滚动覆盖策略,即最新备份仅保留30天,超期自动清除
    • +
    +

    💡 重要说明:注销完成后,您将无法通过任何方式(App内、网页、客服查询)查找到该账号的任何信息,状态查询将返回"已注销"或"无记录"。

    +

    4.6 应用外账户管理支持(Google Play合规)

    +

    为满足Google Play数据安全合规要求,我们提供独立的应用外账户管理网页,无需安装App即可进行账号管理操作:

    +
      +
    • 账号状态查询:访问 隐私权管理页面,输入账号即可查询状态(正常/封锁/注销中/已注销/无记录)
    • +
    • 应用外注销:在隐私权管理页面登录后即可发起注销申请,流程与App内一致
    • +
    • 注销状态查询:随时查询注销申请的处理进度
    • +
    • 撤销注销:审核期内可随时撤销注销申请
    • +
    • 该页面同时提供其他软件协议的查看入口,方便您了解权利与义务
    • +
    +

    🔐 安全提示:应用外账户管理页面采用HMAC-SHA256回执验证机制,防止账号枚举攻击。查询账号状态需提供有效的回执签名。

    五、账号冻结与解冻

    5.1 冻结情形

    以下情形我们有权冻结您的账号:

    @@ -387,38 +425,109 @@
    + + 🛡️ +
    +
    隐私权管理
    +
    账号状态查询、注销申请、隐私权利选择(Google Play合规)
    +
    + +
    📋 服务条款
    @@ -293,6 +301,14 @@
    + + 🛡️ +
    +
    Privacy Rights Management
    +
    Account status query, deletion request, privacy choices (Google Play compliance)
    +
    + +
    📋 Service Terms
    diff --git a/docs/toolsapi/public/agreements/privacy-rights.html b/docs/toolsapi/public/agreements/privacy-rights.html new file mode 100644 index 00000000..ee813d06 --- /dev/null +++ b/docs/toolsapi/public/agreements/privacy-rights.html @@ -0,0 +1,2687 @@ + + + + + + 隐私权管理 - 闲言APP + + + + +
    + + +
    + +
    + 🛡️ +

    隐私权管理中心

    +

    管理您的账号、行使隐私权利

    +
    + +
    + +
    + + + + + +
    + + +
    +
    +
    + 🔍 + 账号状态查询 +
    +

    + 无需登录即可查询账号当前状态。请输入您的用户名、邮箱或手机号,系统将返回账号的实时状态。 +

    +
    + 💡 支持查询的状态:正常 / 封锁 / 注销中 / 已注销 / 无记录 +
    +
    + + +
    支持用户名、邮箱(如 user@example.com)或手机号(如 13800138000)
    +
    + +
    +
    +
    +
    + + +
    +
    +
    + 🗑️ + 账号注销申请 +
    +

    + 在此页面您可以申请注销账号。注销申请提交后进入3天审核期,审核通过或超时后账号及所有关联数据将被永久删除。 +

    +
    + ⚠️ 注销后果不可逆:所有收藏、笔记、签到记录、积分、文章、会员权益等数据将被永久删除且无法恢复! +
    + + +
    + + + + + + + + + + + + + + + + +
    +
    + + +
    + + 🔑 忘记密码?凭回执码注销 + + +
    + + +
    + + 已登录 + 退出 +
    + + +
    +
    + + +
    +
    +
    + 📋 + 注销事件记录 +
    +

    查看您的账号注销申请记录和数据处理进度

    +
    + 💡 请先在"注销账号"标签登录后查看注销事件记录 +
    + +
    +
    + + +
    +
    +
    + ⚙️ + 隐私偏好设置 +
    +

    管理您对各类数据处理的同意状态。您可以随时撤回同意,撤回不影响基础功能使用。

    +
    + 💡 请先在"注销账号"标签登录后管理隐私偏好 +
    + +
    +
    + + +
    +
    +
    + ⚖️ + 您的隐私权利 +
    +

    + 根据《中华人民共和国个人信息保护法》《GDPR》《CCPA》等法律法规,您享有以下隐私权利。我们尊重并保护您的个人信息。 +

    +
      +
    • +
      🔍
      +
      +
      知情权与访问权
      +
      您有权了解我们如何收集、使用、存储您的个人信息,并可在App内"我的→数据面板"查看您的账号数据。
      +
      +
    • +
    • +
      📋
      +
      +
      更正权
      +
      您有权更正不准确的个人信息。可在App内"我的→编辑资料"修改昵称、头像、简介等信息。
      +
      +
    • +
    • +
      🗑️
      +
      +
      删除权与注销权
      +
      您有权删除个人信息或注销账号。可通过本页面"注销账号"标签或App内"设置→账户设置→注销账号"发起申请。
      +
      +
    • +
    • +
      📦
      +
      +
      数据可携带权
      +
      您有权获取您的个人数据副本。可通过App内"设置→数据管理→导出数据"导出您的收藏、笔记、文章等数据。
      +
      +
    • +
    • +
      🚫
      +
      +
      拒绝权与限制处理权
      +
      您有权拒绝我们基于商业营销目的处理您的个人信息。可在App内"设置→通知设置"关闭营销推送。
      +
      +
    • +
    • +
      🔒
      +
      +
      隐私偏好选择权
      +
      您可选择是否授权位置、相机、相册、麦克风等敏感权限。所有权限均为可选,拒绝不影响基础功能使用。
      +
      +
    • +
    • +
      👶
      +
      +
      未成年人特殊保护
      +
      14周岁以下未成年人不得注册使用。监护人发现误收集未成年人信息可联系 ad@avefs.com 申请删除。
      +
      +
    • +
    • +
      📨
      +
      +
      投诉与申诉权
      +
      如认为我们侵犯您的个人信息权益,可向 ad@avefs.com 投诉,或向网信办、市场监管部门举报。
      +
      +
    • +
    +
    + +
    +
    + 📞 + 行使权利的方式 +
    +

    您可通过以下方式行使上述权利,我们将在15个工作日内回复您的请求:

    +
      +
    • +
      📱
      +
      +
      App内自助
      +
      大多数权利可在App内直接行使:设置→账户设置→数据管理。
      +
      +
    • +
    • +
      🌐
      +
      +
      本网页
      +
      无需安装App,可通过本页面查询账号状态、申请注销账号。
      +
      +
    • +
    • +
      📧
      +
      +
      邮件申请
      +
      发送邮件至 ad@avefs.com,注明您的账号信息和具体诉求。
      +
      +
    • +
    +
    +
    + + ← 返回协议列表 +
    + + + + + + + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c37828b3..889b0d80 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/lib/core/storage/database/app_database.dart b/lib/core/storage/database/app_database.dart index e36a4260..72c9dac9 100644 --- a/lib/core/storage/database/app_database.dart +++ b/lib/core/storage/database/app_database.dart @@ -1499,6 +1499,40 @@ class AppDatabase extends _$AppDatabase { }, 'setFavoriteFlag'); } + /// 设置指定记录的点赞状态(不依赖当前值,直接设定) + /// + /// 举一反三:与 setFavoriteFlag 同类需求。 + /// 原 toggleLike 在记录不存在时静默失败,导致本地点赞状态无法持久化。 + Future 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 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> getSentencesByIds(List ids) { + if (ids.isEmpty) return Future.value({}); + return _safeDbList( + () => (select(sentences)..where((t) => t.id.isIn(ids))).get(), + 'getSentencesByIds', + ).then((rows) => {for (final r in rows) r.id: r}); + } + Future insertOrUpdateSentence(SentencesCompanion entry) { return _safeDbVoid( () => into(sentences).insert(entry, mode: InsertMode.insertOrReplace), diff --git a/lib/core/utils/ui/interaction_animations.dart b/lib/core/utils/ui/interaction_animations.dart index 2850a8ee..2c63596a 100644 --- a/lib/core/utils/ui/interaction_animations.dart +++ b/lib/core/utils/ui/interaction_animations.dart @@ -88,11 +88,13 @@ class _BounceButtonState extends State /// 可按压卡片 /// /// 按压时缩小 + 降低亮度,松开弹回,增强触觉反馈。 +/// 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 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 onTapUp: _onTapUp, onTapCancel: _onTapCancel, onTap: widget.onTap, + onLongPress: widget.onLongPress == null ? null : _onLongPress, child: ScaleTransition( scale: _scaleAnimation, child: Container( diff --git a/lib/features/home/favorite_repository.dart b/lib/features/home/favorite_repository.dart index e1b85d60..cb575148 100644 --- a/lib/features/home/favorite_repository.dart +++ b/lib/features/home/favorite_repository.dart @@ -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 _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 _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 serverItems, List 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.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; } diff --git a/lib/features/home/presentation/home_sentence_card.dart b/lib/features/home/presentation/home_sentence_card.dart index 4fa8d994..ae9087a1 100644 --- a/lib/features/home/presentation/home_sentence_card.dart +++ b/lib/features/home/presentation/home_sentence_card.dart @@ -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 { 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 { 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 { ); } - /// 操作行 (作者 + 收藏 + 点赞) + /// 操作行 (作者 + 工具图标 + 液态玻璃点赞/收藏按钮) + /// + /// 重设计 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 { 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 { ref.read(pluginProvider.notifier).incrementPinyinCount(); } } +} - /// 截图分享 — RepaintBoundary截取卡片 → PNG → share_plus系统分享 - Future _shareAsImage() async { - if (_isSharing) return; - if (kIsWeb) { - _shareAsText(); - return; - } - setState(() => _isSharing = true); +// ============================================================ +// 工具图标按钮 — 拼音/翻译/TTS 共用的小尺寸图标按钮 +// ============================================================ - try { - await Future.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 createState() => + _LiquidGlassActionButtonState(); +} + +class _LiquidGlassActionButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _popController; + late final Animation _popAnimation; + + @override + void initState() { + super.initState(); + _popController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _popAnimation = Tween(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, + ), + ), + ); } } diff --git a/lib/features/home/providers/home_feed_mixin.dart b/lib/features/home/providers/home_feed_mixin.dart index e055d910..8415fb83 100644 --- a/lib/features/home/providers/home_feed_mixin.dart +++ b/lib/features/home/providers/home_feed_mixin.dart @@ -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 { return all.sublist(all.length - _maxSeenHashesForApi); } + /// 合并本地 DB 的互动状态(isFavorite/isLiked) + /// + /// v6.144.0: 解决"已收藏/已点赞句子重复出现但未显示状态"的问题。 + /// 合并策略:local true wins(OR 合并) + /// - 服务端 true → true(已登录用户的服务端权威状态) + /// - 服务端 false 但本地 true → true(未登录或未同步的本地操作) + /// - 两者都 false → false + /// + /// 未登录场景:服务端始终返回 false,本地 DB 是唯一状态来源。 + /// 登录场景:服务端返回权威状态,本地 DB 兜底未同步的离线操作。 + Future> _mergeLocalInteractionState( + List 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 fetchRefreshSentences() async { try { final seenIds = _buildSeenIdList(); @@ -105,7 +150,7 @@ mixin HomeFeedMixin on Notifier { // 当选择了具体分类时,只保留该分类的数据 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 { }) .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 { }) .toList(); + // v6.144.0: 合并本地 DB 互动状态,确保已收藏/已点赞句子显示正确状态 + // 注意:此处不使用 var 重赋值,直接生成 mergedSentences 供后续使用 + final mergedSentences = await _mergeLocalInteractionState(newSentences); + final selfDeduped = []; final selfSeen = {}; final selfSeenTexts = {}; - 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 { // 避免骨架屏卡死 — 重置后用本次返回的数据继续填充列表 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() + : {}; // 重置已见集合,允许相同内容再次出现(循环加载) 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() - : {}; - 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 = { + if (state.sentences.length > 20) + ...state.sentences + .sublist(state.sentences.length - 20) + .map((s) => s.id), + ...favoritedIds, + }; + final recentTexts = { + if (deduplicateContent && state.sentences.length > 20) + ...state.sentences + .sublist(state.sentences.length - 20) .where((s) => s.text.isNotEmpty) - .map((s) => s.text.trim()).toSet() - : {}; + .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); diff --git a/lib/features/home/providers/home_interaction_mixin.dart b/lib/features/home/providers/home_interaction_mixin.dart index 9ad5fe66..f80d8b43 100644 --- a/lib/features/home/providers/home_interaction_mixin.dart +++ b/lib/features/home/providers/home_interaction_mixin.dart @@ -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 { final oldValue = sentence.isLiked; if (!mounted) return; - // Step 1: 乐观更新UI - updateSentence(id, (s) => s.copyWith(isLiked: !s.isLiked)); + // Step 1: 乐观更新UI — 同步翻转 isLiked 和递增/递减 likeCount + // 修复: 原仅翻转 isLiked,likeCount 不变,导致点赞后数字不增加 + 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 { } /// 本地点赞持久化:写入本地数据库 + /// + /// 举一反三:与 _persistFavoriteLocally 同类问题—— + /// 若 sentences 表中不存在该 id 的记录,原 toggleLike 的 UPDATE 会静默失败, + /// 导致本地 isLiked 字段未设置,重启 APP 后点赞状态丢失。 + /// 修复:先查再决定 set 或 upsert 兜底插入。 Future _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 { final oldValue = sentence.isFavorited; if (!mounted) return; - // Step 1: 乐观更新UI - updateSentence(id, (s) => s.copyWith(isFavorited: !s.isFavorited)); + // Step 1: 乐观更新UI — 同步翻转 isFavorited 和递增/递减 favoriteCount + // 修复: 与 toggleLike 同类问题,原仅翻转 isFavorited,favoriteCount 不变 + 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 { } /// 本地收藏持久化:写入本地数据库 + /// + /// 修复:原实现仅调用 `toggleFavorite(id)` UPDATE,若 sentences 表中 + /// 不存在该 id 的记录(如 Feed 列表未缓存/缓存被清),UPDATE 会静默失败, + /// 导致本地 isFavorite 字段从未被设置 → "我的收藏"页面查不到刚收藏的句子。 + /// + /// 现在:先查询记录是否存在, + /// - 存在:用 `setFavoriteFlag(id, !oldValue)` 精确设置(不用 toggle 防止并发抖动) + /// - 不存在:插入一条最小化记录,isFavorite 设为目标值,保证后续可被查询到 Future _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); } diff --git a/lib/features/home/providers/home_sentence_model.dart b/lib/features/home/providers/home_sentence_model.dart index cf30cc20..f2073739 100644 --- a/lib/features/home/providers/home_sentence_model.dart +++ b/lib/features/home/providers/home_sentence_model.dart @@ -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 ); }