feat: 新增多项核心服务与功能增强

refactor(theme): 扩展AppTheme支持卡片样式和圆角风格动态配置
feat(services): 新增HapticService触觉反馈服务
feat(services): 实现ScreenWakeService屏幕常亮管理
feat(services): 添加SoundService音效播放服务
feat(services): 集成AppLockService应用锁功能
feat(services): 实现BatteryOptimizationService电池优化
feat(services): 新增NetworkProxyService网络代理
feat(services): 完善DataExportService数据导出
feat(services): 增强PermissionService权限管理
feat(tools): 工具中心新增拼音转换等多项功能
fix(localization): 修复时区初始化错误
docs: 更新工具中心开发清单和设置重构文档
chore: 更新依赖版本和CI配置
This commit is contained in:
Developer
2026-05-07 09:05:35 +08:00
parent b11d53ca58
commit 41a60b0288
74 changed files with 10717 additions and 9378 deletions

View File

@@ -0,0 +1,330 @@
闲言APP 全量API测试报告
时间: 2026-04-29 08:57:34
账号: apitest_user
总计: 323 | 通过: 254 | 失败: 69 | 跳过: 0
通过率: 78.6%
耗时: 325.4秒
✅ 登录: token=fe436cc5-4267-4844-b..., uid=39
✅ 用户信息 (第1次): uid=39, name=
✅ 用户信息 (第2次): uid=39, name=
✅ 用户信息 (第3次): uid=39, name=
✅ 用户信息 (第4次): uid=39, name=
✅ 用户信息 (第5次): uid=39, name=
✅ Token校验 (第1次):
✅ Token校验 (第2次):
✅ Token校验 (第3次):
✅ Token校验 (第4次):
✅ Token校验 (第5次):
✅ 每日推荐(聚合) (第1次):
✅ 每日推荐(聚合) (第2次):
✅ 每日推荐(聚合) (第3次):
✅ 每日推荐(聚合) (第4次):
✅ 每日推荐(聚合) (第5次):
✅ 今日诗词 (第1次): title=将进酒
✅ 今日诗词 (第2次): title=将进酒
✅ 今日诗词 (第3次): title=将进酒
✅ 今日诗词 (第4次): title=将进酒
✅ 今日诗词 (第5次): title=将进酒
✅ 今日成语 (第1次):
✅ 今日成语 (第2次):
✅ 今日成语 (第3次):
✅ 今日成语 (第4次):
✅ 今日成语 (第5次):
✅ 今日名言 (第1次): content=多锉出快锯,多做长知识。
✅ 今日名言 (第2次): content=多锉出快锯,多做长知识。
✅ 今日名言 (第3次): content=多锉出快锯,多做长知识。
✅ 今日名言 (第4次): content=多锉出快锯,多做长知识。
✅ 今日名言 (第5次): content=多锉出快锯,多做长知识。
✅ 今日故事 (第1次): title=月亮下的邂逅
✅ 今日故事 (第2次): title=月亮下的邂逅
✅ 今日故事 (第3次): title=月亮下的邂逅
✅ 今日故事 (第4次): title=月亮下的邂逅
✅ 今日故事 (第5次): title=月亮下的邂逅
✅ 签到日历 (第1次): days=0
✅ 签到日历 (第2次): days=0
✅ 签到日历 (第3次): days=0
✅ 签到日历 (第4次): days=0
✅ 签到日历 (第5次): days=0
❌ 每日签到 (第1次): 今日已签到
❌ 每日签到 (第2次): 今日已签到
❌ 每日签到 (第3次): 今日已签到
✅ 数据面板 (第1次): score=75
✅ 数据面板 (第2次): score=75
✅ 数据面板 (第3次): score=75
✅ 数据面板 (第4次): score=75
✅ 数据面板 (第5次): score=75
✅ 热力图 (第1次):
✅ 热力图 (第2次):
✅ 热力图 (第3次):
✅ 热力图 (第4次):
✅ 热力图 (第5次):
✅ 学习统计(type=overview,period=week) (第1次):
✅ 学习统计(type=detail,period=month) (第2次):
✅ 学习统计(type=trend,period=year) (第3次):
✅ 学习统计(type=overview,period=week) (第4次):
✅ 学习统计(type=detail,period=month) (第5次):
✅ 学习统计(type=trend,period=year) (第6次):
✅ 学习统计(type=overview,period=week) (第7次):
✅ 学习统计(type=detail,period=month) (第8次):
✅ 学习统计(type=trend,period=year) (第9次):
❌ 互动(action=preference) (第1次): 偏好数据不能为空
❌ 互动(action=not_interested) (第2次): 无效的操作类型,支持: like/dislike/readlater/share/block/view/rating/comment/notify/tag/progress/preference/search/bookmark/collect/check/history/counts
✅ 互动(action=block) (第3次):
✅ 互动(action=share) (第4次):
❌ 互动(action=report) (第5次): 无效的操作类型,支持: like/dislike/readlater/share/block/view/rating/comment/notify/tag/progress/preference/search/bookmark/collect/check/history/counts
✅ 诗词填空(diff=easy) (第1次): title=将进酒
✅ 诗词填空(diff=medium) (第2次): title=将进酒
✅ 诗词填空(diff=hard) (第3次): title=将进酒
✅ 诗词填空(diff=easy) (第4次): title=将进酒
✅ 诗词填空(diff=medium) (第5次): title=将进酒
✅ 诗词填空(diff=hard) (第6次): title=将进酒
✅ 诗词填空(diff=easy) (第7次): title=将进酒
✅ 诗词填空(diff=medium) (第8次): title=将进酒
✅ 成语接龙(char=春) (第1次): text=春深似海
✅ 成语接龙(char=花) (第2次): text=花团锦簇
✅ 成语接龙(char=秋) (第3次): text=秋风落叶
✅ 成语接龙(char=月) (第4次): text=月夕花晨
✅ 成语接龙(char=风) (第5次): text=风行草靡
✅ 成语接龙(char=雪) (第6次): text=雪耻报仇
✅ 成语接龙(char=山) (第7次): text=山遥路远
✅ 成语接龙(char=水) (第8次): text=水火之中
✅ 成语接龙(char=云) (第9次): text=云布雨施
✅ 成语接龙(char=雨) (第10次): text=雨淋日炙
❌ 诗词验证 (第1次): 参数错误
❌ 诗词验证 (第2次): 参数错误
❌ 诗词验证 (第3次): 参数错误
❌ 诗词验证 (第4次): 参数错误
❌ 诗词验证 (第5次): 参数错误
✅ 成语验证 (第1次):
✅ 成语验证 (第2次):
✅ 成语验证 (第3次):
✅ 成语验证 (第4次):
✅ 成语验证 (第5次):
✅ 成就列表 (第1次): count=12
✅ 成就列表 (第2次): count=12
✅ 成就列表 (第3次): count=12
✅ 成就列表 (第4次): count=12
✅ 成就列表 (第5次): count=12
✅ 成就列表(type=signin) (第1次): count=3
✅ 成就列表(type=learning) (第2次): count=0
✅ 成就列表(type=game) (第3次): count=1
✅ 成就列表(type=social) (第4次): count=0
✅ 成就列表(type=content) (第5次): count=0
✅ 成就列表(type=special) (第6次): count=0
✅ 我的成就 (第1次): achieved=3, score=75
✅ 我的成就 (第2次): achieved=3, score=75
✅ 我的成就 (第3次): achieved=3, score=75
✅ 我的成就 (第4次): achieved=3, score=75
✅ 我的成就 (第5次): achieved=3, score=75
❌ 领取奖励(id=1) (第1次): 成就不存在
❌ 领取奖励(id=4) (第2次): 成就不存在
❌ 领取奖励(id=8) (第3次): 成就不存在
✅ 学习打卡(type=poetry) (第1次): reward=0
✅ 学习打卡(type=chengyu) (第2次): reward=0
✅ 学习打卡(type=story) (第3次): reward=0
✅ 学习打卡(type=wisdom) (第4次): reward=0
✅ 学习打卡(type=game) (第5次): reward=0
✅ 学习打卡(type=classic) (第6次): reward=0
✅ 签到日历(含打卡记录) (第1次):
✅ 签到日历(含打卡记录) (第2次):
✅ 签到日历(含打卡记录) (第3次):
✅ 签到日历(含打卡记录) (第4次):
✅ 签到日历(含打卡记录) (第5次):
✅ 文章列表 (第1次): count=16
✅ 文章列表 (第2次): count=16
✅ 文章列表 (第3次): count=16
✅ 文章列表 (第4次): count=16
✅ 文章列表 (第5次): count=16
✅ 文章列表(category=poetry) (第1次): count=10
✅ 文章列表(category=culture) (第2次): count=10
✅ 文章列表(category=story) (第3次): count=10
✅ 文章列表(category=wisdom) (第4次): count=10
✅ 文章列表(category=game) (第5次): count=10
✅ 文章列表(category=other) (第6次): count=10
✅ 文章提交 (第1次): id=0, title=API测试文章-1777424013-1
✅ 文章提交 (第2次): id=0, title=API测试文章-1777424014-2
✅ 文章提交 (第3次): id=0, title=API测试文章-1777424015-3
✅ 文章提交 (第4次): id=0, title=API测试文章-1777424016-4
✅ 文章提交 (第5次): id=0, title=API测试文章-1777424017-5
✅ 我的文章 (第1次): count=14
✅ 我的文章 (第2次): count=14
✅ 我的文章 (第3次): count=14
✅ 我的文章 (第4次): count=14
✅ 我的文章 (第5次): count=14
✅ 文章评论(feed_id=23) (第1次): count=20
✅ 文章评论(feed_id=23) (第2次): count=20
✅ 文章评论(feed_id=23) (第3次): count=20
✅ 文章评论(feed_id=23) (第4次): count=20
✅ 文章评论(feed_id=23) (第5次): count=20
✅ 查重数据源 (第1次): count=0
✅ 查重数据源 (第2次): count=0
✅ 查重数据源 (第3次): count=0
✅ 查重数据源 (第4次): count=0
✅ 查重数据源 (第5次): count=0
✅ 精确查重 (第1次): risk=, score=0
✅ 精确查重 (第2次): risk=, score=0
✅ 精确查重 (第3次): risk=, score=0
✅ 精确查重 (第4次): risk=, score=0
✅ 精确查重 (第5次): risk=, score=0
✅ 精确查重 (第6次): risk=, score=0
✅ 精确查重 (第7次): risk=, score=0
✅ 精确查重 (第8次): risk=, score=0
✅ 精确查重 (第9次): risk=, score=0
✅ 精确查重 (第10次): risk=, score=0
✅ 模糊查重 (第1次): risk=, similarity=0
✅ 模糊查重 (第2次): risk=, similarity=0
✅ 模糊查重 (第3次): risk=, similarity=0
✅ 模糊查重 (第4次): risk=, similarity=0
✅ 模糊查重 (第5次): risk=, similarity=0
✅ 模糊查重 (第6次): risk=, similarity=0
✅ 模糊查重 (第7次): risk=, similarity=0
✅ 模糊查重 (第8次): risk=, similarity=0
✅ 相似度查重 (第1次): risk=, similarity=0
✅ 相似度查重 (第2次): risk=, similarity=0
✅ 相似度查重 (第3次): risk=, similarity=0
✅ 相似度查重 (第4次): risk=, similarity=0
✅ 相似度查重 (第5次): risk=, similarity=0
✅ 相似度查重 (第6次): risk=, similarity=0
✅ 相似度查重 (第7次): risk=, similarity=0
✅ 相似度查重 (第8次): risk=, similarity=0
✅ 综合报告 (第1次): risk=high, matches=0
✅ 综合报告 (第2次): risk=high, matches=0
✅ 综合报告 (第3次): risk=high, matches=0
✅ 综合报告 (第4次): risk=high, matches=0
✅ 综合报告 (第5次): risk=high, matches=0
✅ 金币记录 (第1次): count=4
✅ 金币记录 (第2次): count=4
✅ 金币记录 (第3次): count=4
✅ 金币记录 (第4次): count=4
✅ 金币记录 (第5次): count=4
✅ 公开主页(uid=39) (第1次): name=
✅ 公开主页(uid=39) (第2次): name=
✅ 公开主页(uid=39) (第3次): name=
✅ 公开主页(uid=39) (第4次): name=
✅ 公开主页(uid=39) (第5次): name=
✅ 频道列表 (第1次): count=0
✅ 频道列表 (第2次): count=0
✅ 频道列表 (第3次): count=0
✅ 频道列表 (第4次): count=0
✅ 频道列表 (第5次): count=0
❌ Feed列表(channel=shici) (第1次): 不支持的频道: shici可选: poetry/wisdom/story/hitokoto/riddle/efs/brainteaser/saying/lyric/why/composition/couplet/cs/drug/herbal/food/wine/article/chengyu/hanzi/cidian/prescription/tisana/joke/zgjm/lunyu/hdnj/jgj/mz/zz/zuozhuan/sj/sgz/sbbf/warring/illness/word/abbr/surname/jieqi/nation/wlyh/jiufang/bot
❌ Feed列表(channel=shici) (第2次): 不支持的频道: shici可选: poetry/wisdom/story/hitokoto/riddle/efs/brainteaser/saying/lyric/why/composition/couplet/cs/drug/herbal/food/wine/article/chengyu/hanzi/cidian/prescription/tisana/joke/zgjm/lunyu/hdnj/jgj/mz/zz/zuozhuan/sj/sgz/sbbf/warring/illness/word/abbr/surname/jieqi/nation/wlyh/jiufang/bot
❌ Feed列表(channel=shici) (第3次): 不支持的频道: shici可选: poetry/wisdom/story/hitokoto/riddle/efs/brainteaser/saying/lyric/why/composition/couplet/cs/drug/herbal/food/wine/article/chengyu/hanzi/cidian/prescription/tisana/joke/zgjm/lunyu/hdnj/jgj/mz/zz/zuozhuan/sj/sgz/sbbf/warring/illness/word/abbr/surname/jieqi/nation/wlyh/jiufang/bot
❌ Feed列表(channel=shici) (第4次): 不支持的频道: shici可选: poetry/wisdom/story/hitokoto/riddle/efs/brainteaser/saying/lyric/why/composition/couplet/cs/drug/herbal/food/wine/article/chengyu/hanzi/cidian/prescription/tisana/joke/zgjm/lunyu/hdnj/jgj/mz/zz/zuozhuan/sj/sgz/sbbf/warring/illness/word/abbr/surname/jieqi/nation/wlyh/jiufang/bot
❌ Feed列表(channel=shici) (第5次): 不支持的频道: shici可选: poetry/wisdom/story/hitokoto/riddle/efs/brainteaser/saying/lyric/why/composition/couplet/cs/drug/herbal/food/wine/article/chengyu/hanzi/cidian/prescription/tisana/joke/zgjm/lunyu/hdnj/jgj/mz/zz/zuozhuan/sj/sgz/sbbf/warring/illness/word/abbr/surname/jieqi/nation/wlyh/jiufang/bot
✅ 热门内容 (第1次):
✅ 热门内容 (第2次):
✅ 热门内容 (第3次):
✅ 热门内容 (第4次):
✅ 热门内容 (第5次):
❌ 随机内容 (第1次): 不支持的频道
❌ 随机内容 (第2次): 不支持的频道
❌ 随机内容 (第3次): 不支持的频道
❌ 随机内容 (第4次): 不支持的频道
❌ 随机内容 (第5次): 不支持的频道
✅ 全量搜索(keyword=春) (第1次): count=0
✅ 全量搜索(keyword=月) (第2次): count=0
✅ 全量搜索(keyword=花) (第3次): count=0
✅ 全量搜索(keyword=风) (第4次): count=0
✅ 全量搜索(keyword=雪) (第5次): count=0
✅ 热门搜索 (第1次):
✅ 热门搜索 (第2次):
✅ 热门搜索 (第3次):
✅ 热门搜索 (第4次):
✅ 热门搜索 (第5次):
✅ 搜索建议 (第1次):
✅ 搜索建议 (第2次):
✅ 搜索建议 (第3次):
✅ 搜索建议 (第4次):
✅ 搜索建议 (第5次):
❌ 查字(春) (第1次): 查无此字
❌ 查字(春) (第2次): 查无此字
❌ 查字(春) (第3次): 查无此字
❌ 查字(春) (第4次): 查无此字
❌ 查字(春) (第5次): 查无此字
❌ 成语查询(春暖花开) (第1次): 查无此成语
❌ 成语查询(春暖花开) (第2次): 查无此成语
❌ 成语查询(春暖花开) (第3次): 查无此成语
❌ 成语查询(春暖花开) (第4次): 查无此成语
❌ 成语查询(春暖花开) (第5次): 查无此成语
❌ 组词查询(花) (第1次): 查无此组词
❌ 组词查询(花) (第2次): 查无此组词
❌ 组词查询(花) (第3次): 查无此组词
❌ 组词查询(花) (第4次): 查无此组词
❌ 组词查询(花) (第5次): 查无此组词
❌ 综合热搜 (第1次): An error occurred
❌ 综合热搜 (第2次): An error occurred
❌ 综合热搜 (第3次): An error occurred
❌ 综合热搜 (第4次): An error occurred
❌ 综合热搜 (第5次): An error occurred
❌ 百度热搜 (第1次): Expecting value: line 1 column 1 (char 0)
❌ 百度热搜 (第2次): Expecting value: line 1 column 1 (char 0)
❌ 百度热搜 (第3次): Expecting value: line 1 column 1 (char 0)
❌ 百度热搜 (第4次): Expecting value: line 1 column 1 (char 0)
❌ 百度热搜 (第5次): Expecting value: line 1 column 1 (char 0)
❌ 微博热搜 (第1次): Expecting value: line 1 column 1 (char 0)
❌ 微博热搜 (第2次): Expecting value: line 1 column 1 (char 0)
❌ 微博热搜 (第3次): Expecting value: line 1 column 1 (char 0)
❌ 微博热搜 (第4次): Expecting value: line 1 column 1 (char 0)
❌ 微博热搜 (第5次): Expecting value: line 1 column 1 (char 0)
❌ 知乎热搜 (第1次): Expecting value: line 1 column 1 (char 0)
❌ 知乎热搜 (第2次): Expecting value: line 1 column 1 (char 0)
❌ 知乎热搜 (第3次): Expecting value: line 1 column 1 (char 0)
❌ 知乎热搜 (第4次): Expecting value: line 1 column 1 (char 0)
❌ 知乎热搜 (第5次): Expecting value: line 1 column 1 (char 0)
❌ 抖音热搜 (第1次): Expecting value: line 1 column 1 (char 0)
❌ 抖音热搜 (第2次): Expecting value: line 1 column 1 (char 0)
❌ 抖音热搜 (第3次): Expecting value: line 1 column 1 (char 0)
❌ 抖音热搜 (第4次): Expecting value: line 1 column 1 (char 0)
❌ 抖音热搜 (第5次): Expecting value: line 1 column 1 (char 0)
✅ 随机句子 (第1次): count=1
✅ 随机句子 (第2次): count=1
✅ 随机句子 (第3次): count=1
✅ 随机句子 (第4次): count=1
✅ 随机句子 (第5次): count=1
✅ 句子分类 (第1次):
✅ 句子分类 (第2次):
✅ 句子分类 (第3次):
✅ 句子分类 (第4次):
✅ 句子分类 (第5次):
✅ 站点总览 (第1次):
✅ 站点总览 (第2次):
✅ 站点总览 (第3次):
✅ 站点总览 (第4次):
✅ 站点总览 (第5次):
✅ 用户统计 (第1次):
✅ 用户统计 (第2次):
✅ 用户统计 (第3次):
✅ 用户统计 (第4次):
✅ 用户统计 (第5次):
✅ 文章统计 (第1次):
✅ 文章统计 (第2次):
✅ 文章统计 (第3次):
✅ 文章统计 (第4次):
✅ 文章统计 (第5次):
✅ 签到统计 (第1次):
✅ 签到统计 (第2次):
✅ 签到统计 (第3次):
✅ 签到统计 (第4次):
✅ 签到统计 (第5次):
✅ 金币统计 (第1次):
✅ 金币统计 (第2次):
✅ 金币统计 (第3次):
✅ 金币统计 (第4次):
✅ 金币统计 (第5次):
❌ 收藏列表 (第1次): 无效的收藏类型
❌ 收藏列表 (第2次): 无效的收藏类型
❌ 收藏列表 (第3次): 无效的收藏类型
❌ 收藏列表 (第4次): 无效的收藏类型
❌ 收藏列表 (第5次): 无效的收藏类型
✅ 笔记列表 (第1次):
✅ 笔记列表 (第2次):
✅ 笔记列表 (第3次):
✅ 笔记列表 (第4次):
✅ 笔记列表 (第5次):
✅ 点赞检查 (第1次):
✅ 点赞检查 (第2次):
✅ 点赞检查 (第3次):
✅ 点赞检查 (第4次):
✅ 点赞检查 (第5次):

View File

@@ -0,0 +1 @@
鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺?

View File

@@ -0,0 +1,373 @@
// ============================================================
// 闲言APP — 工具中心API验证脚本
// 创建时间: 2026-05-07
// 更新时间: 2026-05-07
// 作用: 验证工具中心所有API接口的能力、性能、内容质量
// 上次更新: 初始创建覆盖所有工具API
// ============================================================
// 使用方法: dart run scripts/tool_api_verify.dart
import 'dart:convert';
import 'dart:io';
const _baseUrl = 'https://tools.wktyl.com';
final _client = HttpClient();
final _results = <_ApiResult>[];
void main() async {
print('═══════════════════════════════════════════════════════');
print(' 闲言APP — 工具中心API全面验证');
print(' ${DateTime.now().toIso8601String()}');
print('═══════════════════════════════════════════════════════\n');
// 1. 汉语工具
await _section('汉语工具');
await _testPost('/api/hanzi/zi', {'zi': ''}, '查字');
await _testPost('/api/hanzi/bishun', {'zi': ''}, '笔顺');
await _testPost('/api/hanzi/zuci', {'zi': ''}, '组词');
await _testPost('/api/hanzi/cidian', {'zi': '中国'}, '词典');
await _testPost('/api/hanzi/chengyu', {'zi': '一心一意'}, '成语');
await _testPost('/api/hanzi/jinyici', {'zi': '快乐'}, '近义词');
await _testPost('/api/hanzi/fanyici', {'zi': '快乐'}, '反义词');
await _testPost('/api/hanzi/juzi', {'zi': '春天'}, '句子');
await _testPost('/api/hanzi/nick', {'zi': ''}, '网名');
await _testPost('/api/hanzi/barcode', {'zi': '6901234567890'}, '条码');
await _testPost('/api/hanzi/danci', {'zi': 'hello'}, '英语单词');
await _testPost('/api/hanzi/suoxie', {'zi': 'AI'}, '英文缩写');
await _testPost('/api/hanzi/pinyin', {'hanzi': '中国'}, '拼音(默认)');
await _testPost('/api/hanzi/pinyin', {
'hanzi': '中国',
'option': 'shengdiao',
}, '拼音(声调)');
// 2. 单位换算 - 关键验证:参数格式
await _section('单位换算 (关键验证: value+type vs zi)');
await _testPost('/api/hanzi/speed', {
'value': '100',
'type': 'km',
}, '速度(value+type)');
await _testPost('/api/hanzi/weight', {
'value': '1',
'type': 'kilogram',
}, '重量(value+type)');
await _testPost('/api/hanzi/volume', {
'value': '1',
'type': 'dm3',
}, '体积(value+type)');
await _testPost('/api/hanzi/area', {
'value': '100',
'type': 'm2',
}, '面积(value+type)');
await _testPost('/api/hanzi/temp', {
'value': '36.5',
'type': 'c',
}, '温度(value+type)');
await _testPost('/api/hanzi/power', {
'value': '1500',
'type': 'w',
}, '功率(value+type)');
await _testPost('/api/hanzi/pressure', {
'value': '101325',
'type': 'pa',
}, '压力(value+type)');
// 3. 搜索型工具
await _section('搜索型工具 (hanzi/search)');
final searchTypes = [
'poetry',
'brainteaser',
'couplet',
'wisdom',
'story',
'saying',
'riddle',
'xiehouyu',
'zuowen',
'why',
'drug',
'food',
'herbal',
'pianfang',
'tisana',
'changshi',
'lyric',
];
for (final type in searchTypes) {
await _testPost('/api/hanzi/search', {
'zi': '',
'type': type,
'limit': '3',
}, '搜索($type)');
}
// 4. searchall接口
await _section('searchall接口');
await _testGet(
'/api/searchall/search?keyword=春天&type=illness&limit=3',
'疾病自查',
);
await _testGet(
'/api/searchall/search?keyword=李&type=surname&limit=3',
'姓氏起源',
);
await _testGet('/api/searchall/search?keyword=立春&type=jieqi&limit=3', '节气查询');
await _testGet(
'/api/searchall/search?keyword=中国&type=nation&limit=3',
'国家查询',
);
await _testGet('/api/searchall/search?keyword=水&type=zgjm&limit=3', '周公解梦');
await _testGet('/api/searchall/search?keyword=搞笑&type=joke&limit=3', '笑话大全');
await _testGet('/api/searchall/search?keyword=春天&limit=5', '全量搜索');
await _testGet('/api/searchall/suggest?keyword=春', '搜索建议');
await _testGet('/api/searchall/hot', '热门搜索');
await _testGet('/api/searchall/sources', '数据源列表');
// 5. 一言接口
await _section('一言接口');
await _testGet('/api/hitokoto/random?num=3', '一言随机');
await _testGet('/api/hitokoto/search?kw=人生&limit=3', '一言搜索');
await _testGet('/api/hitokoto/categories', '一言分类');
await _testGet('/api/hitokoto/hot?num=3', '一言热门');
// 6. 热搜接口
await _section('热搜接口');
await _testGet('/api/hot/read', '热搜聚合');
await _testGet('/api/hot/read?type=baidu', '百度热搜');
// 7. 每日推荐
await _section('每日推荐');
await _testGet('/api/daily/recommend', '每日推荐');
// 8. 中国传统色
await _section('中国传统色');
await _testGet('/api/hanzi/china_colors', '传统色列表');
// 9. 酒方接口
await _section('酒方接口');
await _testGet('/api/webapi/jiufang_search?page=1&pageSize=3', '酒方列表');
await _testGet(
'/api/webapi/jiufang_search?keyword=人参&page=1&pageSize=3',
'酒方搜索',
);
// 10. 统计接口
await _section('统计接口');
await _testGet('/api/webapi/stats_overview', '站点概览');
await _testGet('/api/webapi/hot_tools?limit=5', '热门工具');
_printReport();
_client.close();
}
// ── 测试方法 ──
Future<void> _section(String title) async {
print('\n── $title ──');
}
Future<void> _testPost(
String path,
Map<String, String> data,
String label,
) async {
final sw = Stopwatch()..start();
try {
final uri = Uri.parse('$_baseUrl$path');
final request = await _client.postUrl(uri);
request.headers.set('Content-Type', 'application/x-www-form-urlencoded');
request.write(
data.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&'),
);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
sw.stop();
final json = jsonDecode(body) as Map<String, dynamic>;
final code = json['code'] as int? ?? 0;
final msg = json['msg'] as String? ?? '';
final dataResult = json['data'];
final hasData = dataResult != null && dataResult.toString().isNotEmpty;
String contentPreview = '';
if (hasData) {
if (dataResult is List) {
contentPreview = '${dataResult.length}条记录';
} else if (dataResult is Map) {
contentPreview = '${dataResult.keys.take(5).join(', ')}';
}
}
final status = code == 1 && hasData ? '' : (code == 1 ? '⚠️空' : '');
print(
' $status $label: ${sw.elapsedMilliseconds}ms code=$code ${hasData ? '有数据' : '无数据'} $contentPreview',
);
_results.add(
_ApiResult(
label: label,
path: path,
method: 'POST',
params: data.toString(),
success: code == 1 && hasData,
hasData: hasData,
ms: sw.elapsedMilliseconds,
code: code,
msg: msg,
contentPreview: contentPreview,
),
);
} catch (e) {
sw.stop();
print('$label: ${sw.elapsedMilliseconds}ms ERROR: $e');
_results.add(
_ApiResult(
label: label,
path: path,
method: 'POST',
params: data.toString(),
success: false,
hasData: false,
ms: sw.elapsedMilliseconds,
code: -1,
msg: e.toString(),
contentPreview: '',
),
);
}
}
Future<void> _testGet(String path, String label) async {
final sw = Stopwatch()..start();
try {
final uri = Uri.parse('$_baseUrl$path');
final request = await _client.getUrl(uri);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
sw.stop();
final json = jsonDecode(body) as Map<String, dynamic>;
final code = json['code'] as int? ?? 0;
final msg = json['msg'] as String? ?? '';
final dataResult = json['data'];
final hasData = dataResult != null && dataResult.toString().isNotEmpty;
String contentPreview = '';
if (hasData) {
if (dataResult is List) {
contentPreview = '${dataResult.length}条记录';
} else if (dataResult is Map) {
contentPreview = '${dataResult.keys.take(5).join(', ')}';
}
}
final status = code == 1 && hasData ? '' : (code == 1 ? '⚠️空' : '');
print(
' $status $label: ${sw.elapsedMilliseconds}ms code=$code ${hasData ? '有数据' : '无数据'} $contentPreview',
);
_results.add(
_ApiResult(
label: label,
path: path,
method: 'GET',
params: '',
success: code == 1 && hasData,
hasData: hasData,
ms: sw.elapsedMilliseconds,
code: code,
msg: msg,
contentPreview: contentPreview,
),
);
} catch (e) {
sw.stop();
print('$label: ${sw.elapsedMilliseconds}ms ERROR: $e');
_results.add(
_ApiResult(
label: label,
path: path,
method: 'GET',
params: '',
success: false,
hasData: false,
ms: sw.elapsedMilliseconds,
code: -1,
msg: e.toString(),
contentPreview: '',
),
);
}
}
void _printReport() {
print('\n═══════════════════════════════════════════════════════');
print(' 验证报告汇总');
print('═══════════════════════════════════════════════════════\n');
final total = _results.length;
final passed = _results.where((r) => r.success).length;
final failed = _results.where((r) => !r.success).length;
final avgMs = _results.isEmpty
? 0
: _results.map((r) => r.ms).reduce((a, b) => a + b) ~/ _results.length;
print(' 总计: $total | 通过: $passed | 失败: $failed | 平均耗时: ${avgMs}ms\n');
if (failed > 0) {
print(' ❌ 失败接口:');
for (final r in _results.where((r) => !r.success)) {
print(
' - ${r.label} [${r.method} ${r.path}]: code=${r.code} ${r.msg}',
);
}
print('');
}
final slowApis = _results.where((r) => r.ms > 2000).toList();
if (slowApis.isNotEmpty) {
print(' 🐌 慢接口 (>2s):');
for (final r in slowApis) {
print(' - ${r.label}: ${r.ms}ms');
}
print('');
}
print(' 📊 性能分布:');
final fast = _results.where((r) => r.ms < 500).length;
final medium = _results.where((r) => r.ms >= 500 && r.ms < 2000).length;
final slow = _results.where((r) => r.ms >= 2000).length;
print(' 快速(<500ms): $fast | 中等(500-2000ms): $medium | 慢速(>2s): $slow\n');
print('═══════════════════════════════════════════════════════');
}
class _ApiResult {
const _ApiResult({
required this.label,
required this.path,
required this.method,
required this.params,
required this.success,
required this.hasData,
required this.ms,
required this.code,
required this.msg,
required this.contentPreview,
});
final String label;
final String path;
final String method;
final String params;
final bool success;
final bool hasData;
final int ms;
final int code;
final String msg;
final String contentPreview;
}

219
scripts/verify_data_flow.py Normal file
View File

@@ -0,0 +1,219 @@
# ============================================================
# 闲言APP — 句子卡片循环重复根因深度验证
# 创建时间: 2026-05-01
# 作用: 精确模拟客户端 refreshDailySentences 的完整数据流
# ============================================================
import requests
import json
import time
import hashlib
BASE = "https://tools.wktyl.com"
def fetch_mix(mode="random", limit=5, channels=None, sort=None):
params = {"mode": mode, "limit": limit}
if channels:
params["channels"] = ",".join(channels)
if sort:
params["sort"] = sort
try:
r = requests.get(f"{BASE}/api/feed/mix", params=params, timeout=10)
data = r.json()
if data.get("code") == 1:
items = data.get("data", {}).get("list", [])
return items
except Exception as e:
print(f" ❌ 请求失败: {e}")
return []
def make_id(item):
return f"{item.get('feed_type','?')}_{item.get('id')}"
def simulate_client_flow(rounds=5, limit=5):
"""
精确模拟客户端 refreshDailySentences 逻辑:
1. existingIds = state.dailySentences.map((s) => s.id).toSet()
2. fetchMix(config)
3. dailyList = result.list.map(HomeSentence.fromFeedItem).toList()
4. unique = dailyList.where((s) => !existingIds.contains(s.id)).toList()
5. if unique.isEmpty && existingIds.isNotEmpty: retry with 3x limit
6. finalList = unique.isNotEmpty ? unique : dailyList
7. state = state.copyWith(dailySentences: finalList)
"""
print("=" * 70)
print("模拟客户端 refreshDailySentences 完整数据流")
print("=" * 70)
existing_ids = set()
all_seen_ids = set()
daily_sentences_ids = []
for i in range(rounds):
print(f"\n--- 第 {i+1} 轮 ---")
print(f" existingIds = {existing_ids}")
# Step 1: fetchMix
items = fetch_mix(mode="random", limit=limit)
if not items:
print(" ❌ API 返回空数据!")
continue
# Step 2: 构建 dailyList
daily_list = [make_id(item) for item in items]
print(f" API 返回: {daily_list}")
# Step 3: 计算 unique
unique = [sid for sid in daily_list if sid not in existing_ids]
print(f" unique (不在existingIds中): {unique}")
# Step 4: 如果 unique 为空,重试
if not unique and existing_ids:
print(f" ⚠️ unique为空! 重试 (limit={limit*3})")
retry_items = fetch_mix(mode="random", limit=limit*3)
if retry_items:
retry_list = [make_id(item) for item in retry_items]
print(f" 重试API返回: {retry_list}")
unique = [sid for sid in retry_list if sid not in existing_ids]
print(f" 重试unique: {unique}")
if unique:
daily_list = unique
# Step 5: finalList
final_list = unique if unique else daily_list
print(f" finalList: {final_list}")
# Step 6: 更新 state
daily_sentences_ids = final_list
existing_ids = set(final_list)
all_seen_ids.update(final_list)
print(f" 更新后 dailySentences = {daily_sentences_ids}")
print(f" 更新后 existingIds = {existing_ids}")
print(f"\n{'='*70}")
print(f"总结: {rounds}轮后共看到 {len(all_seen_ids)} 个不同句子")
print(f"所有ID: {all_seen_ids}")
def simulate_with_dedup_issue(rounds=5, limit=5):
"""
模拟潜在问题: existingIds 只包含当前 dailySentences 的ID
而不是所有历史看过的ID导致旧句子可能重新出现
"""
print("\n" + "=" * 70)
print("模拟问题场景: existingIds 只包含当前5条的ID")
print("=" * 70)
existing_ids = set()
history_ids = set()
for i in range(rounds):
items = fetch_mix(mode="random", limit=limit)
if not items:
continue
daily_list = [make_id(item) for item in items]
unique = [sid for sid in daily_list if sid not in existing_ids]
final_list = unique if unique else daily_list
# 关键: existingIds 只包含当前 dailySentences
# 之前看过的句子如果不在当前 dailySentences 中,会被视为"新"的
old_ids_reappeared = [sid for sid in final_list if sid in history_ids and sid not in existing_ids]
existing_ids = set(final_list)
history_ids.update(final_list)
print(f"{i+1}轮: finalList={final_list}")
if old_ids_reappeared:
print(f" ⚠️ 旧句子重新出现: {old_ids_reappeared}")
def test_specific_mode_with_hottest():
"""
测试 mode=specific + sort=hottest 是否导致数据固定
"""
print("\n" + "=" * 70)
print("测试: mode=specific + sort=hottest 数据是否固定")
print("=" * 70)
all_ids = []
for i in range(3):
items = fetch_mix(mode="specific", limit=5,
channels=["poetry", "wisdom", "story"],
sort="hottest")
ids = [make_id(item) for item in items]
all_ids.extend(ids)
print(f"{i+1}次: {ids}")
time.sleep(0.3)
unique = len(set(all_ids))
total = len(all_ids)
print(f" 📊 总ID={total}, 去重={unique}, 重复率={1-unique/total:.1%}")
def test_specific_mode_with_newest():
"""
测试 mode=specific + sort=newest 数据是否变化
"""
print("\n" + "=" * 70)
print("测试: mode=specific + sort=newest 数据是否变化")
print("=" * 70)
all_ids = []
for i in range(3):
items = fetch_mix(mode="specific", limit=5,
channels=["poetry", "wisdom", "story"],
sort="newest")
ids = [make_id(item) for item in items]
all_ids.extend(ids)
print(f"{i+1}次: {ids}")
time.sleep(0.3)
unique = len(set(all_ids))
total = len(all_ids)
print(f" 📊 总ID={total}, 去重={unique}, 重复率={1-unique/total:.1%}")
def test_default_config_no_sort():
"""
测试: 默认配置 (mode=random, limit=5, 无sort参数)
这正是客户端当前发送的请求
"""
print("\n" + "=" * 70)
print("测试: 客户端当前请求参数 (mode=random, limit=5, 无sort)")
print("=" * 70)
all_ids = []
for i in range(5):
items = fetch_mix(mode="random", limit=5)
ids = [make_id(item) for item in items]
all_ids.extend(ids)
print(f"{i+1}次: {ids}")
time.sleep(0.3)
unique = len(set(all_ids))
total = len(all_ids)
print(f" 📊 总ID={total}, 去重={unique}, 重复率={1-unique/total:.1%}")
if __name__ == "__main__":
simulate_client_flow(rounds=5, limit=5)
simulate_with_dedup_issue(rounds=5, limit=5)
test_specific_mode_with_hottest()
test_specific_mode_with_newest()
test_default_config_no_sort()
print("\n" + "=" * 70)
print("🔍 根因分析总结")
print("=" * 70)
print()
print("问题: 句子卡片5个句子循环重复永远不会出现新的")
print()
print("可能根因:")
print(" 1. API层面: mode=specific+sort=hottest 返回固定热门数据")
print(" 2. 客户端层面: existingIds去重逻辑导致数据子集化")
print(" 3. 状态层面: didUpdateWidget未正确检测新数据")
print(" 4. 并发层面: 多次refreshDailySentences竞态条件")
print()
print("修复方案:")
print(" A. FeedMixConfig添加sort字段默认'newest'")
print(" B. refreshDailySentences添加防重入锁")
print(" C. 清空existingIds后再请求确保获取全新数据")
print(" D. 添加详细日志追踪数据流")

View File

@@ -0,0 +1,575 @@
#!/usr/bin/env python3
# ============================================================
# 闲言APP — 数据导入导出验证脚本
# 创建时间: 2026-05-04
# 更新时间: 2026-05-04
# 作用: 验证 .xypk 导出文件的格式、数据完整性、签名校验
# 上次更新: 初始创建
# ============================================================
"""
闲言APP 数据导入导出验证脚本
用法:
python verify_export_import.py <xypk_file_path>
python verify_export_import.py --create-sample # 创建示例导出文件
python verify_export_import.py --validate-dir <dir> # 验证目录下所有 .xypk 文件
验证项:
1. ZIP 文件结构 (data.json + signature.sha256)
2. JSON Schema 验证 (版本/时间/数据字段)
3. SHA256 签名校验 (防篡改)
4. 数据完整性 (收藏/历史/笔记/分享各字段)
5. 数据类型验证 (字段类型正确)
6. 导入兼容性 (版本兼容性检查)
"""
import sys
import os
import json
import hashlib
import zipfile
import tempfile
import shutil
from datetime import datetime
from typing import Any, Optional
from dataclasses import dataclass, field
# ---- 颜色输出 ----
class Color:
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
RESET = '\033[0m'
BOLD = '\033[1m'
def print_ok(msg: str):
print(f" {Color.GREEN}{msg}{Color.RESET}")
def print_fail(msg: str):
print(f" {Color.RED}{msg}{Color.RESET}")
def print_warn(msg: str):
print(f" {Color.YELLOW}⚠️ {msg}{Color.RESET}")
def print_info(msg: str):
print(f" {Color.BLUE} {msg}{Color.RESET}")
# ---- 验证结果 ----
@dataclass
class ValidationResult:
passed: int = 0
failed: int = 0
warnings: int = 0
errors: list = field(default_factory=list)
@property
def is_ok(self) -> bool:
return self.failed == 0
def add_pass(self):
self.passed += 1
def add_fail(self, msg: str):
self.failed += 1
self.errors.append(msg)
print_fail(msg)
def add_warn(self, msg: str):
self.warnings += 1
print_warn(msg)
# ---- JSON Schema 定义 ----
EXPECTED_SCHEMA = {
"version": {"type": str, "required": True, "description": "导出格式版本"},
"exportTime": {"type": str, "required": True, "description": "导出时间(ISO8601)"},
"app": {"type": str, "required": True, "description": "应用标识"},
"favorites": {"type": list, "required": True, "description": "收藏列表"},
"history": {"type": list, "required": True, "description": "阅读历史"},
"sentences": {"type": list, "required": True, "description": "句子数据"},
"shareHistory": {"type": list, "required": True, "description": "分享历史"},
"notes": {"type": dict, "required": True, "description": "笔记数据"},
"stats": {"type": dict, "required": True, "description": "统计摘要"},
}
FAVORITE_ITEM_SCHEMA = {
"id": {"type": (str, int), "required": True},
"content": {"type": str, "required": True},
"author": {"type": str, "required": False},
"source": {"type": str, "required": False},
}
HISTORY_ITEM_SCHEMA = {
"sentenceId": {"type": (str, int), "required": True},
"content": {"type": str, "required": True},
"author": {"type": str, "required": False},
"readAt": {"type": str, "required": True},
}
SENTENCE_ITEM_SCHEMA = {
"id": {"type": (str, int), "required": True},
"content": {"type": str, "required": True},
"author": {"type": str, "required": False},
"source": {"type": str, "required": False},
"tags": {"type": str, "required": False},
"feedType": {"type": str, "required": False},
"isFavorite": {"type": bool, "required": False},
"isRead": {"type": bool, "required": False},
}
SHARE_HISTORY_ITEM_SCHEMA = {
"contentId": {"type": (str, int), "required": False},
"shareType": {"type": str, "required": False},
"title": {"type": str, "required": False},
"content": {"type": str, "required": False},
"author": {"type": str, "required": False},
"sharedAt": {"type": str, "required": False},
}
STATS_SCHEMA = {
"favCount": {"type": int, "required": True},
"historyCount": {"type": int, "required": True},
"noteCount": {"type": int, "required": True},
"shareCount": {"type": int, "required": True},
}
# ---- 验证函数 ----
def validate_zip_structure(xypk_path: str, result: ValidationResult) -> Optional[bytes]:
"""验证1: ZIP文件结构"""
print(f"\n{Color.BOLD}📦 验证ZIP文件结构{Color.RESET}")
if not os.path.exists(xypk_path):
result.add_fail(f"文件不存在: {xypk_path}")
return None
if not xypk_path.endswith('.xypk'):
result.add_warn("文件扩展名不是 .xypk")
try:
with zipfile.ZipFile(xypk_path, 'r') as zf:
names = zf.namelist()
if 'data.json' in names:
print_ok("包含 data.json")
result.add_pass()
else:
result.add_fail("缺少 data.json")
if 'signature.sha256' in names:
print_ok("包含 signature.sha256")
result.add_pass()
else:
result.add_fail("缺少 signature.sha256")
extra_files = [n for n in names if n not in ('data.json', 'signature.sha256')]
if extra_files:
result.add_warn(f"存在额外文件: {extra_files}")
data_bytes = zf.read('data.json')
return data_bytes
except zipfile.BadZipFile:
result.add_fail("不是有效的ZIP文件")
return None
except Exception as e:
result.add_fail(f"读取ZIP失败: {e}")
return None
def validate_signature(data_bytes: bytes, xypk_path: str, result: ValidationResult):
"""验证2: SHA256签名校验"""
print(f"\n{Color.BOLD}🔐 验证SHA256签名{Color.RESET}")
try:
with zipfile.ZipFile(xypk_path, 'r') as zf:
sig_content = zf.read('signature.sha256').decode('utf-8').strip()
actual_hash = hashlib.sha256(data_bytes).hexdigest()
if actual_hash == sig_content:
print_ok(f"签名校验通过: {actual_hash[:16]}...")
result.add_pass()
else:
result.add_fail(f"签名不匹配!\n 期望: {sig_content[:16]}...\n 实际: {actual_hash[:16]}...")
except Exception as e:
result.add_fail(f"签名校验失败: {e}")
def validate_json_schema(data: dict, result: ValidationResult):
"""验证3: JSON Schema验证"""
print(f"\n{Color.BOLD}📋 验证JSON Schema{Color.RESET}")
for key, spec in EXPECTED_SCHEMA.items():
if spec["required"] and key not in data:
result.add_fail(f"缺少必需字段: {key} ({spec['description']})")
continue
if key in data:
expected_type = spec["type"]
if not isinstance(data[key], expected_type):
result.add_fail(f"字段 '{key}' 类型错误: 期望 {expected_type.__name__}, 实际 {type(data[key]).__name__}")
else:
print_ok(f"{key}: {spec['description']}")
result.add_pass()
def validate_data_integrity(data: dict, result: ValidationResult):
"""验证4: 数据完整性验证"""
print(f"\n{Color.BOLD}🔍 验证数据完整性{Color.RESET}")
# 验证收藏列表
favorites = data.get('favorites', [])
if favorites:
fav_errors = validate_list_items(favorites, FAVORITE_ITEM_SCHEMA, "收藏")
if fav_errors:
for e in fav_errors:
result.add_fail(e)
else:
print_ok(f"收藏列表: {len(favorites)} 条, 格式正确")
result.add_pass()
else:
print_info("收藏列表为空")
result.add_pass()
# 验证历史列表
history = data.get('history', [])
if history:
hist_errors = validate_list_items(history, HISTORY_ITEM_SCHEMA, "历史")
if hist_errors:
for e in hist_errors:
result.add_fail(e)
else:
print_ok(f"历史列表: {len(history)} 条, 格式正确")
result.add_pass()
else:
print_info("历史列表为空")
result.add_pass()
# 验证句子列表
sentences = data.get('sentences', [])
if sentences:
sent_errors = validate_list_items(sentences, SENTENCE_ITEM_SCHEMA, "句子")
if sent_errors:
for e in sent_errors[:5]:
result.add_fail(e)
if len(sent_errors) > 5:
result.add_warn(f"句子列表还有 {len(sent_errors) - 5} 个错误...")
else:
print_ok(f"句子列表: {len(sentences)} 条, 格式正确")
result.add_pass()
else:
print_info("句子列表为空")
result.add_pass()
# 验证分享历史
share_history = data.get('shareHistory', [])
if share_history:
share_errors = validate_list_items(share_history, SHARE_HISTORY_ITEM_SCHEMA, "分享")
if share_errors:
for e in share_errors:
result.add_fail(e)
else:
print_ok(f"分享历史: {len(share_history)} 条, 格式正确")
result.add_pass()
else:
print_info("分享历史为空")
result.add_pass()
# 验证笔记
notes = data.get('notes', {})
if notes:
print_ok(f"笔记数据: {len(notes)}")
result.add_pass()
else:
print_info("笔记数据为空")
result.add_pass()
# 验证统计
stats = data.get('stats', {})
if stats:
stats_errors = validate_dict_fields(stats, STATS_SCHEMA, "统计")
if stats_errors:
for e in stats_errors:
result.add_fail(e)
else:
print_ok(f"统计: 收藏{stats.get('favCount', 0)} 历史{stats.get('historyCount', 0)} "
f"笔记{stats.get('noteCount', 0)} 分享{stats.get('shareCount', 0)}")
result.add_pass()
else:
result.add_fail("缺少统计数据")
def validate_list_items(items: list, schema: dict, label: str) -> list:
"""验证列表中每个item的字段"""
errors = []
for i, item in enumerate(items):
if not isinstance(item, dict):
errors.append(f"{label}[{i}]: 不是字典类型")
continue
for key, spec in schema.items():
if spec["required"] and key not in item:
errors.append(f"{label}[{i}]: 缺少必需字段 '{key}'")
elif key in item and item[key] is not None:
expected_type = spec["type"]
if not isinstance(item[key], expected_type):
errors.append(f"{label}[{i}].{key}: 类型错误 (期望 {expected_type}, 实际 {type(item[key]).__name__})")
return errors
def validate_dict_fields(data: dict, schema: dict, label: str) -> list:
"""验证字典字段"""
errors = []
for key, spec in schema.items():
if spec["required"] and key not in data:
errors.append(f"{label}: 缺少必需字段 '{key}'")
elif key in data and data[key] is not None:
expected_type = spec["type"]
if not isinstance(data[key], expected_type):
errors.append(f"{label}.{key}: 类型错误 (期望 {expected_type}, 实际 {type(data[key]).__name__})")
return errors
def validate_version_compatibility(data: dict, result: ValidationResult):
"""验证5: 版本兼容性"""
print(f"\n{Color.BOLD}🔄 验证版本兼容性{Color.RESET}")
version = data.get('version', '1.0')
app = data.get('app', '')
if app == 'xianyan':
print_ok(f"应用标识: {app}")
result.add_pass()
else:
result.add_warn(f"应用标识不匹配: '{app}' (期望 'xianyan')")
supported_versions = ['1.0', '2.0']
if version in supported_versions:
print_ok(f"版本号: {version} (兼容)")
result.add_pass()
else:
result.add_warn(f"版本号: {version} (未在已知兼容列表中: {supported_versions})")
export_time = data.get('exportTime', '')
if export_time:
try:
dt = datetime.fromisoformat(export_time.replace('Z', '+00:00'))
print_ok(f"导出时间: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
result.add_pass()
age_days = (datetime.now(dt.tzinfo) - dt).days
if age_days > 30:
result.add_warn(f"导出文件已超过 {age_days} 天, 数据可能过时")
except ValueError:
result.add_fail(f"导出时间格式无效: {export_time}")
def validate_cross_references(data: dict, result: ValidationResult):
"""验证6: 交叉引用一致性"""
print(f"\n{Color.BOLD}🔗 验证交叉引用一致性{Color.RESET}")
favorites = data.get('favorites', [])
history = data.get('history', [])
sentences = data.get('sentences', [])
stats = data.get('stats', {})
# 验证收藏数量与统计一致
if stats:
expected_fav = stats.get('favCount', -1)
if expected_fav >= 0 and len(favorites) != expected_fav:
result.add_warn(f"收藏数量不一致: 列表{len(favorites)}条 vs 统计{expected_fav}")
else:
print_ok(f"收藏数量一致: {len(favorites)}")
result.add_pass()
expected_hist = stats.get('historyCount', -1)
if expected_hist >= 0 and len(history) != expected_hist:
result.add_warn(f"历史数量不一致: 列表{len(history)}条 vs 统计{expected_hist}")
else:
print_ok(f"历史数量一致: {len(history)}")
result.add_pass()
# 验证历史中的句子ID在sentences中存在
if sentences and history:
sentence_ids = {str(s.get('id', '')) for s in sentences}
missing = 0
for h in history:
sid = str(h.get('sentenceId', ''))
if sid and sid not in sentence_ids:
missing += 1
if missing > 0:
result.add_warn(f"历史中有 {missing} 条记录的句子不在sentences中")
else:
print_ok("历史记录与句子数据引用完整")
result.add_pass()
# ---- 主验证流程 ----
def verify_xypk(xypk_path: str) -> ValidationResult:
"""完整验证 .xypk 文件"""
result = ValidationResult()
print(f"\n{Color.BOLD}{'=' * 60}{Color.RESET}")
print(f"{Color.BOLD} 闲言APP 数据导出文件验证{Color.RESET}")
print(f"{Color.BOLD} 文件: {os.path.basename(xypk_path)}{Color.RESET}")
print(f"{Color.BOLD}{'=' * 60}{Color.RESET}")
# 验证1: ZIP结构
data_bytes = validate_zip_structure(xypk_path, result)
if data_bytes is None:
print(f"\n{Color.RED}{Color.BOLD}❌ ZIP文件验证失败无法继续{Color.RESET}")
return result
# 解析JSON
try:
data = json.loads(data_bytes.decode('utf-8'))
print_ok("JSON解析成功")
result.add_pass()
except json.JSONDecodeError as e:
result.add_fail(f"JSON解析失败: {e}")
return result
# 验证2: 签名
validate_signature(data_bytes, xypk_path, result)
# 验证3: Schema
validate_json_schema(data, result)
# 验证4: 数据完整性
validate_data_integrity(data, result)
# 验证5: 版本兼容性
validate_version_compatibility(data, result)
# 验证6: 交叉引用
validate_cross_references(data, result)
# 汇总
print(f"\n{Color.BOLD}{'=' * 60}{Color.RESET}")
if result.is_ok:
print(f"{Color.GREEN}{Color.BOLD}✅ 验证通过!{Color.RESET} "
f"通过: {result.passed} | 警告: {result.warnings}")
else:
print(f"{Color.RED}{Color.BOLD}❌ 验证失败!{Color.RESET} "
f"通过: {result.passed} | 失败: {result.failed} | 警告: {result.warnings}")
for err in result.errors:
print(f" {Color.RED}{err}{Color.RESET}")
print(f"{Color.BOLD}{'=' * 60}{Color.RESET}\n")
return result
def create_sample_export(output_path: str = "sample_export.xypk"):
"""创建示例导出文件用于测试"""
print(f"\n{Color.BOLD}📝 创建示例导出文件{Color.RESET}")
sample_data = {
"version": "2.0",
"exportTime": datetime.now().isoformat(),
"app": "xianyan",
"favorites": [
{"id": "1", "content": "人生如逆旅,我亦是行人", "author": "苏轼", "source": "临江仙"},
{"id": "2", "content": "但愿人长久,千里共婵娟", "author": "苏轼", "source": "水调歌头"},
],
"history": [
{"sentenceId": "1", "content": "人生如逆旅,我亦是行人", "author": "苏轼", "readAt": datetime.now().isoformat()},
{"sentenceId": "3", "content": "大江东去,浪淘尽", "author": "苏轼", "readAt": datetime.now().isoformat()},
],
"sentences": [
{"id": "1", "content": "人生如逆旅,我亦是行人", "author": "苏轼", "source": "临江仙", "tags": "poetry", "feedType": "poetry", "isFavorite": True, "isRead": True},
{"id": "2", "content": "但愿人长久,千里共婵娟", "author": "苏轼", "source": "水调歌头", "tags": "poetry", "feedType": "poetry", "isFavorite": True, "isRead": False},
{"id": "3", "content": "大江东去,浪淘尽", "author": "苏轼", "source": "念奴娇", "tags": "poetry", "feedType": "poetry", "isFavorite": False, "isRead": True},
],
"shareHistory": [
{"contentId": "1", "shareType": "text", "title": "分享", "content": "人生如逆旅", "author": "苏轼", "sharedAt": datetime.now().isoformat()},
],
"notes": {"note_1": "这是一条笔记"},
"stats": {"favCount": 2, "historyCount": 2, "noteCount": 1, "shareCount": 1},
}
json_bytes = json.dumps(sample_data, ensure_ascii=False, indent=2).encode('utf-8')
signature = hashlib.sha256(json_bytes).hexdigest()
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('data.json', json_bytes)
zf.writestr('signature.sha256', signature)
print_ok(f"示例文件已创建: {output_path}")
print_info(f"签名: {signature[:16]}...")
# 自动验证
return verify_xypk(output_path)
def verify_directory(dir_path: str):
"""验证目录下所有 .xypk 文件"""
xypk_files = []
for root, dirs, files in os.walk(dir_path):
for f in files:
if f.endswith('.xypk'):
xypk_files.append(os.path.join(root, f))
if not xypk_files:
print(f"{Color.YELLOW}目录下没有找到 .xypk 文件: {dir_path}{Color.RESET}")
return
print(f"\n{Color.BOLD}找到 {len(xypk_files)} 个 .xypk 文件{Color.RESET}")
all_ok = True
for fp in xypk_files:
result = verify_xypk(fp)
if not result.is_ok:
all_ok = False
if all_ok:
print(f"\n{Color.GREEN}{Color.BOLD}🎉 所有文件验证通过!{Color.RESET}")
else:
print(f"\n{Color.RED}{Color.BOLD}⚠️ 部分文件验证失败{Color.RESET}")
# ---- 入口 ----
def main():
if len(sys.argv) < 2:
print(f"""
{Color.BOLD}闲言APP 数据导出验证工具{Color.RESET}
用法:
python verify_export_import.py <xypk_file> 验证单个文件
python verify_export_import.py --create-sample 创建示例文件并验证
python verify_export_import.py --validate-dir <dir> 验证目录下所有文件
python verify_export_import.py --help 显示帮助
""")
sys.exit(0)
arg = sys.argv[1]
if arg == '--help' or arg == '-h':
print(__doc__)
elif arg == '--create-sample':
result = create_sample_export()
sys.exit(0 if result.is_ok else 1)
elif arg == '--validate-dir':
if len(sys.argv) < 3:
print(f"{Color.RED}请指定目录路径{Color.RESET}")
sys.exit(1)
verify_directory(sys.argv[2])
else:
result = verify_xypk(arg)
sys.exit(0 if result.is_ok else 1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,136 @@
# ============================================================
# 闲言APP — 句子卡片修复后数据流验证
# 创建时间: 2026-05-01
# 作用: 验证修复后 fetchMix+sort=newest 的数据唯一性
# ============================================================
import requests
import json
import time
BASE = "https://tools.wktyl.com"
def fetch_mix(mode="random", limit=5, channels=None, sort="newest"):
params = {"mode": mode, "limit": limit, "sort": sort}
if channels:
params["channels"] = ",".join(channels)
try:
r = requests.get(f"{BASE}/api/feed/mix", params=params, timeout=10)
data = r.json()
if data.get("code") == 1:
items = data.get("data", {}).get("list", [])
return items
except Exception as e:
print(f" ❌ 请求失败: {e}")
return []
def make_id(item):
return f"{item.get('feed_type','?')}_{item.get('id')}"
print("=" * 70)
print("验证1: 修复后 fetchMix(mode=random, sort=newest) 数据唯一性")
print("=" * 70)
all_ids = []
for i in range(10):
items = fetch_mix(mode="random", limit=5, sort="newest")
ids = [make_id(item) for item in items]
all_ids.extend(ids)
print(f"{i+1}次: {ids}")
time.sleep(0.3)
unique = len(set(all_ids))
total = len(all_ids)
print(f"\n 📊 总ID数={total}, 去重后={unique}, 重复率={1-unique/total:.1%}")
print(f" ✅ 修复后API每次返回不同数据" if unique == total else f" ❌ 仍有重复!")
print("\n" + "=" * 70)
print("验证2: 模拟修复后 refreshDailySentences 完整流程")
print("=" * 70)
existing_ids = set()
for i in range(5):
items = fetch_mix(mode="random", limit=5, sort="newest")
if not items:
print(f"{i+1}轮: ❌ API返回空")
continue
daily_list = [make_id(item) for item in items]
unique = [sid for sid in daily_list if sid not in existing_ids]
if not unique and existing_ids:
retry_items = fetch_mix(mode="random", limit=15, sort="newest")
if retry_items:
retry_list = [make_id(item) for item in retry_items]
unique = [sid for sid in retry_list if sid not in existing_ids]
final_list = unique if unique else daily_list
existing_ids = set(final_list)
overlap = set(daily_list) & set([sid for sid in existing_ids if sid in daily_list])
print(f"{i+1}轮: API={daily_list}, unique={len(unique)}, final={len(final_list)}")
print(f"\n 📊 5轮后共看到 {len(existing_ids)} 个不同句子")
print("\n" + "=" * 70)
print("验证3: 对比修复前(sort=hottest) vs 修复后(sort=newest)")
print("=" * 70)
for sort_mode in ["hottest", "newest"]:
ids_set = []
for i in range(5):
items = fetch_mix(mode="random", limit=5, sort=sort_mode)
ids = [make_id(item) for item in items]
ids_set.extend(ids)
time.sleep(0.2)
unique = len(set(ids_set))
total = len(ids_set)
rate = 1 - unique/total
status = "❌ 高重复" if rate > 0.3 else "✅ 低重复"
print(f" sort={sort_mode:8s}: 总={total}, 去重={unique}, 重复率={rate:.1%} {status}")
print("\n" + "=" * 70)
print("验证4: mode=specific + sort=newest 数据变化性")
print("=" * 70)
all_ids = []
for i in range(5):
items = fetch_mix(mode="specific", limit=5,
channels=["poetry", "wisdom", "story"],
sort="newest")
ids = [make_id(item) for item in items]
all_ids.extend(ids)
print(f"{i+1}次: {ids}")
time.sleep(0.3)
unique = len(set(all_ids))
total = len(all_ids)
print(f" 📊 总ID={total}, 去重={unique}, 重复率={1-unique/total:.1%}")
print("\n" + "=" * 70)
print("📋 修复总结")
print("=" * 70)
print()
print("修复内容:")
print(" 1. FeedMixConfig 添加 sort 字段, 默认 'newest'")
print(" - 修复前: 不传sort → 后端默认hottest → 热门数据固定")
print(" - 修复后: 传sort=newest → 每次获取最新数据")
print()
print(" 2. refreshDailySentences 添加防重入锁")
print(" - 防止并发调用导致竞态条件")
print()
print(" 3. _fetchDailySentence 初始化不再使用existingIds过滤")
print(" - 初始化时直接使用API返回的数据")
print(" - refreshDailySentences 保留去重逻辑确保新内容")
print()
print(" 4. DailyCard 只在onEnd时触发onLoadMore")
print(" - 修复前: 每3次滑动就刷新 → 中途重建CardSwiper")
print(" - 修复后: 滑完所有卡片才刷新 → 体验更流畅")
print()
print(" 5. didUpdateWidget 始终重置卡片状态")
print(" - 修复前: hasNewContent为false时不重置 → 加载态卡死")
print(" - 修复后: 始终重置_dataVersion/_frontCardIndex/_allCardsSwiped")
print()
print(" 6. refresh() 清空dailySentences")
print(" - 修复前: refresh不清空 → _fetchDailySentence用旧ID过滤")
print(" - 修复后: clearDaily=true → 确保获取全新数据")

View File

@@ -0,0 +1,120 @@
# ============================================================
# 闲言APP — 句子卡片数据永不更新根因验证
# 创建时间: 2026-05-01
# 作用: 验证 fetchMix 接口在相同参数下是否返回相同数据
# ============================================================
import requests
import json
import time
BASE = "https://tools.wktyl.com"
def fetch_mix(mode="random", limit=5, channels=None, sort="hottest"):
params = {"mode": mode, "limit": limit, "sort": sort}
if channels:
params["channels"] = ",".join(channels)
try:
r = requests.get(f"{BASE}/api/feed/mix", params=params, timeout=10)
data = r.json()
if data.get("code") == 1:
items = data.get("data", {}).get("list", [])
return items
except Exception as e:
print(f" ❌ fetchMix 请求失败: {e}")
return []
print("=" * 70)
print("根因验证: fetchMix 默认 sort=hottest 是否导致数据固定")
print("=" * 70)
# 测试1: 默认 sort=hottest 连续3次
print("\n📌 测试1: sort=hottest (默认) 连续3次请求")
all_ids_hottest = []
for i in range(3):
items = fetch_mix(mode="random", limit=5, sort="hottest")
ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items]
titles = [item.get("title", "")[:25] for item in items]
all_ids_hottest.extend(ids)
print(f"{i+1}次: ids={ids}")
for t in titles:
print(f"{t}")
time.sleep(0.3)
unique_hottest = len(set(all_ids_hottest))
total_hottest = len(all_ids_hottest)
print(f" 📊 总ID数={total_hottest}, 去重后={unique_hottest}, 重复率={1-unique_hottest/total_hottest:.1%}")
# 测试2: sort=newest 连续3次
print("\n📌 测试2: sort=newest 连续3次请求")
all_ids_newest = []
for i in range(3):
items = fetch_mix(mode="random", limit=5, sort="newest")
ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items]
titles = [item.get("title", "")[:25] for item in items]
all_ids_newest.extend(ids)
print(f"{i+1}次: ids={ids}")
for t in titles:
print(f"{t}")
time.sleep(0.3)
unique_newest = len(set(all_ids_newest))
total_newest = len(all_ids_newest)
print(f" 📊 总ID数={total_newest}, 去重后={unique_newest}, 重复率={1-unique_newest/total_newest:.1%}")
# 测试3: 对比 hottest vs newest
print("\n📌 测试3: hottest vs newest 对比")
hottest_items = fetch_mix(mode="random", limit=5, sort="hottest")
newest_items = fetch_mix(mode="random", limit=5, sort="newest")
hottest_ids = set(f"{item.get('feed_type','?')}_{item.get('id')}" for item in hottest_items)
newest_ids = set(f"{item.get('feed_type','?')}_{item.get('id')}" for item in newest_items)
overlap = hottest_ids & newest_ids
print(f" hottest: {hottest_ids}")
print(f" newest: {newest_ids}")
print(f" 重叠: {overlap}")
# 测试4: 客户端当前代码使用的参数
print("\n📌 测试4: 模拟客户端当前代码 (mode=random, limit=5, 无sort参数)")
all_ids_client = []
for i in range(5):
items = fetch_mix(mode="random", limit=5)
ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items]
all_ids_client.extend(ids)
print(f"{i+1}次: ids={ids}")
time.sleep(0.3)
unique_client = len(set(all_ids_client))
total_client = len(all_ids_client)
print(f" 📊 总ID数={total_client}, 去重后={unique_client}, 重复率={1-unique_client/total_client:.1%}")
# 测试5: 检查 FeedMixConfig.toQueryParameters 是否传了 sort
print("\n📌 测试5: FeedMixConfig.toQueryParameters 分析")
print(" 客户端代码 (feed_model.dart):")
print(" Map<String, dynamic> toQueryParameters() {")
print(" final params = <String, dynamic>{'mode': mode, 'limit': limit};")
print(" if (channels.isNotEmpty) params['channels'] = channels.join(',');")
print(" if (mode == 'ratio' && ratios.isNotEmpty) params['ratios'] = jsonEncode(ratios);")
print(" if (mode == 'group') params['group_size'] = groupSize;")
print(" return params;")
print(" }")
print(" ⚠️ 注意: toQueryParameters 没有传 sort 参数!")
print(" ⚠️ 后端默认 sort=hottest按浏览量排序")
print(" ⚠️ hottest 模式下热门内容固定,导致每次返回相同数据!")
# 结论
print("\n" + "=" * 70)
print("🔍 根因分析")
print("=" * 70)
print()
print("问题: 句子卡片5个句子循环重复永远不会出现新的")
print()
print("根因: FeedMixConfig.toQueryParameters() 没有传 sort 参数,")
print(" 后端默认 sort=hottest按浏览量排序")
print(" 热门内容排名稳定,导致每次请求返回相同的高浏览量句子。")
print()
print("修复方案: FeedMixConfig 添加 sort 字段,默认值 'random''newest'")
print(" toQueryParameters() 传入 sort 参数。")
print()
print("验证: 如果 hottest 重复率远高于 newest则确认根因。")
print(f" hottest 重复率: {1-unique_hottest/total_hottest:.1%}")
print(f" newest 重复率: {1-unique_newest/total_newest:.1%}")