fix(mobile): support Android system file picker

This commit is contained in:
key
2026-07-29 23:52:44 +08:00
parent 6a6b264f22
commit 5027bef32d
27 changed files with 680 additions and 130 deletions
+1
View File
@@ -4,6 +4,7 @@
## Unreleased ## Unreleased
- **2026-07-29,Android 文件选择、麦克风拒绝降级与上传路径修复,源码候选升级为 `0.1.16 (116)`,尚无签名 APK**:真机确认 `0.1.13` 在允许麦克风时可完成原生录音和生产 ASR,但拒绝后的降级误用 App 不支持的 `uni.chooseFile`。Android 非媒体文件现改用系统 `ACTION_OPEN_DOCUMENT`,通过 `ParcelFileDescriptor + FileChannel.transferFrom` 复制到应用专属外部缓存,避免 Native.js `byte[]` 参数兼容问题;图片/视频改走 App `uni.chooseMedia`,页面生命周期会保留系统选择器中的待完成任务,录音前统一检查麦克风权限。vivo Android 15 真机在 HBuilderX 5.22 标准调试基座完成取消、重复打开、`100,542` 字节 WAV 精确复制、multipart 上传、生产 ASR 和成果草稿整理,未点击确认投稿。移动端 206 项测试、类型检查、App 发布资源预检和 App-Plus 构建通过;调查用 `0.1.14/0.1.15` APK 已删除。DCloud 当日云打包额度耗尽,最终 `0.1.16` 签名 APK、深检和同包真机复验仍未完成,因此当前仍为 No-Go,未发布移动 H5、原生 App、后端、管理端或数据库。
- **2026-07-29,资料处理中心普通文档/图片单文件 500MB 已发布并完成生产验证**:管理端批量导入、后端异步队列、ZIP 子文件和 Tika 输入流统一允许受支持文件 `≤500MiB`,沿用生产 Spring/Undertow `500MB/520MB` 传输层配置;仍保留 ZIP 最多 1000 个子文件、解压总量 ≤2GB、禁止嵌套 ZIP 等安全边界,同步 SOP 上传和公司文件继续保持 100MB。生产实测发现首个候选虽已放宽页面和队列校验,但 Tika 内仍残留独立 `100MiB` 输入流上限,导致首批队列 `1194/1195` 上传成功后加工失败;补齐解析器上限及回归断言后,11 项定向后端测试和后端生产打包通过。管理端发布前备份整站到 `/opt/wygj/backups/www-20260729213203`,线上 `processing-BjvQ3qpr.js` 与本地产物 SHA-256 均为 `6ace59477519d6c53cbfe207a229518a7e2f7568e05961cfea28d669e1eeb305`,移动 H5 树哈希保持 `ba4aa656d64fc301141280357c204a67141d1e6b2e2a26a0be21c0dd48a25712` 未变。最终后端 JAR SHA-256 为 `be16c229cfc255e4cdf5ec32df047994b54d7d2c31b6c729dde6d949e191e53d`,旧包备份在 `/opt/wygj/backups/backend-20260729214901-5ef5dd484b5d`;整包及内嵌 `ruoyi-aihr-5.6.2.jar` 远近端哈希一致,服务、公开接口、64/64 schema 和生产禁运行时 DDL 预检均通过。已登录生产 Chrome 验收批次 `b-c2560e95-0cf7-46e0-9bdc-7690ea86ffd7` 中,`4、巡检表(事业部颗粒归仓专用).xlsx`(101.6MB,队列 `1196`、附件 `2036`)完成 5 个片段和 5 个向量,`新员工培训-社区发展中心-社区资源.pptx`(147.9MB,队列 `1197`、附件 `2037`)完成 7 个片段和 7 个向量;两项均显示“已完成/已入库”,数据库片段均含 embedding。资料总数从 1032 增至 1034,目标空间从 22 增至 24 个文件,处理中和需处理均为 0。本次只定向发布后端与管理端,未重发移动 H5、未执行 schema 迁移,不代表当前工作树与线上完整包全部匹配。 - **2026-07-29,资料处理中心普通文档/图片单文件 500MB 已发布并完成生产验证**:管理端批量导入、后端异步队列、ZIP 子文件和 Tika 输入流统一允许受支持文件 `≤500MiB`,沿用生产 Spring/Undertow `500MB/520MB` 传输层配置;仍保留 ZIP 最多 1000 个子文件、解压总量 ≤2GB、禁止嵌套 ZIP 等安全边界,同步 SOP 上传和公司文件继续保持 100MB。生产实测发现首个候选虽已放宽页面和队列校验,但 Tika 内仍残留独立 `100MiB` 输入流上限,导致首批队列 `1194/1195` 上传成功后加工失败;补齐解析器上限及回归断言后,11 项定向后端测试和后端生产打包通过。管理端发布前备份整站到 `/opt/wygj/backups/www-20260729213203`,线上 `processing-BjvQ3qpr.js` 与本地产物 SHA-256 均为 `6ace59477519d6c53cbfe207a229518a7e2f7568e05961cfea28d669e1eeb305`,移动 H5 树哈希保持 `ba4aa656d64fc301141280357c204a67141d1e6b2e2a26a0be21c0dd48a25712` 未变。最终后端 JAR SHA-256 为 `be16c229cfc255e4cdf5ec32df047994b54d7d2c31b6c729dde6d949e191e53d`,旧包备份在 `/opt/wygj/backups/backend-20260729214901-5ef5dd484b5d`;整包及内嵌 `ruoyi-aihr-5.6.2.jar` 远近端哈希一致,服务、公开接口、64/64 schema 和生产禁运行时 DDL 预检均通过。已登录生产 Chrome 验收批次 `b-c2560e95-0cf7-46e0-9bdc-7690ea86ffd7` 中,`4、巡检表(事业部颗粒归仓专用).xlsx`(101.6MB,队列 `1196`、附件 `2036`)完成 5 个片段和 5 个向量,`新员工培训-社区发展中心-社区资源.pptx`(147.9MB,队列 `1197`、附件 `2037`)完成 7 个片段和 7 个向量;两项均显示“已完成/已入库”,数据库片段均含 embedding。资料总数从 1032 增至 1034,目标空间从 22 增至 24 个文件,处理中和需处理均为 0。本次只定向发布后端与管理端,未重发移动 H5、未执行 schema 迁移,不代表当前工作树与线上完整包全部匹配。
- **2026-07-29,资料处理媒体任务恢复已发布并完成生产验证**:修复历史图片/视频在视觉输入不兼容或未提取到文字时长期停在“等待解析”的问题;图片在送入视觉模型前会按方向纠正、限制像素和体积并统一编码,异步队列只有生成至少一个片段才进入完成态,零片段会保留为可重试失败。新增 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,可从当前租户原 OSS 对象重新暂存并入队;正式迁移 `aihr_20260729_media_reprocess_mysql8.sql` 增加 `source_attach_id` 和 `(tenant_id, source_attach_id, id)` 索引。生产已备份 schema、后端和完整站点,发布管理端与后端 JAR `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,移动 H5 未重发;发布脚本在重启后等待公开端点真实就绪再执行预检,服务为 `active`,根站、租户和移动首页均返回 HTTP 200。原卡住任务 `1133/1175/1308/1405` 从原文件重试为队列 `1185–1188`,四项均完成、各生成一个片段且向量存在;资料处理页显示“待重试 0”、Tika `1028/1028`、可引用资料 `1028`。该证据证明本次资料恢复和定向发布成功,不代表移动 H5 与当前工作树完整匹配,也不替代 Android 真机或正式试点验收。 - **2026-07-29,资料处理媒体任务恢复已发布并完成生产验证**:修复历史图片/视频在视觉输入不兼容或未提取到文字时长期停在“等待解析”的问题;图片在送入视觉模型前会按方向纠正、限制像素和体积并统一编码,异步队列只有生成至少一个片段才进入完成态,零片段会保留为可重试失败。新增 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,可从当前租户原 OSS 对象重新暂存并入队;正式迁移 `aihr_20260729_media_reprocess_mysql8.sql` 增加 `source_attach_id` 和 `(tenant_id, source_attach_id, id)` 索引。生产已备份 schema、后端和完整站点,发布管理端与后端 JAR `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,移动 H5 未重发;发布脚本在重启后等待公开端点真实就绪再执行预检,服务为 `active`,根站、租户和移动首页均返回 HTTP 200。原卡住任务 `1133/1175/1308/1405` 从原文件重试为队列 `1185–1188`,四项均完成、各生成一个片段且向量存在;资料处理页显示“待重试 0”、Tika `1028/1028`、可引用资料 `1028`。该证据证明本次资料恢复和定向发布成功,不代表移动 H5 与当前工作树完整匹配,也不替代 Android 真机或正式试点验收。
- **2026-07-29,新增移动端 UI token 与渲染态审计,未发布静态资源**:`audit:ui-tokens` 以 `mobile-uni/src/styles/base.css` 为源扫描颜色、字阶、间距和圆角,`audit:ui-rendered` 在 390×844 的真实 Chrome 中审计 H5 构建的六个默认路由并保存截图。两项均把报告写入 Git 已忽略的 `output/ui-ux/`;本地实跑覆盖 53 个源码文件和 6 个渲染路由,渲染路由均无横向溢出或控制台错误,同时如实记录现有 palette 外颜色、孤立字号和非 token 圆角,不能把“脚本执行成功”解释为视觉债务已清零。 - **2026-07-29,新增移动端 UI token 与渲染态审计,未发布静态资源**:`audit:ui-tokens` 以 `mobile-uni/src/styles/base.css` 为源扫描颜色、字阶、间距和圆角,`audit:ui-rendered` 在 390×844 的真实 Chrome 中审计 H5 构建的六个默认路由并保存截图。两项均把报告写入 Git 已忽略的 `output/ui-ux/`;本地实跑覆盖 53 个源码文件和 6 个渲染路由,渲染路由均无横向溢出或控制台错误,同时如实记录现有 palette 外颜色、孤立字号和非 token 圆角,不能把“脚本执行成功”解释为视觉债务已清零。
+1
View File
@@ -12,6 +12,7 @@
- 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。
- 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。
- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前 DCloud 云端证书内测 APK 为 `0.1.13 (113)`,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,启动阶段不展示 DCloud 原生隐私确认,法务同意统一由登录页承担。该版进一步让认证身份、角色和组织岗位异常失败关闭,不再回退为普通员工或公开首页;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、App-Plus 资源和 APK 深检已通过。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;冻结候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,其语义已包含在 2026-07-29 后续定向发布的生产 JAR `5376B67B…` 中。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁会核对固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色的主处理人/替补、五通道正向授权/越权拒绝/一次正式回复验收,以及受控分发、法务例外和测试签名接受,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` 及更早 ZIP 只作历史追溯。生产现有在职 APP 账号的固定码链路已经完整通过首次获取/登录、认证只读主路径、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,应用日志确认两次均未真实发送短信;固定码、登录和重新登录不再列为功能缺口。当前 x86_64 AVD 对仅含 ARM ABI 的 DCloud Weex 运行时出现 IPC 超时;浏览器截图只作布局和交互补充证据,不替代 App 运行、认证业务或设备能力验收,也不因此修改发布 ABI、增加降级路径或启用兜底内容。机器签认当前准确显示正式场景 `0/5`、有据问题 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`;生产后端定向发布已经完成,但业务签认、受控分发和真机门禁仍未闭环,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。 - 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前 DCloud 云端证书内测 APK 为 `0.1.13 (113)`,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,启动阶段不展示 DCloud 原生隐私确认,法务同意统一由登录页承担。该版进一步让认证身份、角色和组织岗位异常失败关闭,不再回退为普通员工或公开首页;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、App-Plus 资源和 APK 深检已通过。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;冻结候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,其语义已包含在 2026-07-29 后续定向发布的生产 JAR `5376B67B…` 中。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁会核对固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色的主处理人/替补、五通道正向授权/越权拒绝/一次正式回复验收,以及受控分发、法务例外和测试签名接受,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` 及更早 ZIP 只作历史追溯。生产现有在职 APP 账号的固定码链路已经完整通过首次获取/登录、认证只读主路径、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,应用日志确认两次均未真实发送短信;固定码、登录和重新登录不再列为功能缺口。当前 x86_64 AVD 对仅含 ARM ABI 的 DCloud Weex 运行时出现 IPC 超时;浏览器截图只作布局和交互补充证据,不替代 App 运行、认证业务或设备能力验收,也不因此修改发布 ABI、增加降级路径或启用兜底内容。机器签认当前准确显示正式场景 `0/5`、有据问题 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`;生产后端定向发布已经完成,但业务签认、受控分发和真机门禁仍未闭环,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。
- Android 当前边界:最后一个签名内测包仍是 `0.1.13 (113)`;它已在 vivo Android 15 真机完成真实页面登录、麦克风允许和生产 ASR,但麦克风拒绝后的文件降级存在缺陷。当前源码为 `0.1.16 (116)`,已改用 Android 系统文档选择器、SAF `FileChannel` 复制、应用专属外部缓存和 App 媒体选择器;206 项移动端测试、类型检查、App 发布资源预检、App-Plus 构建及标准调试基座的真实 WAV 选择/上传/生产 ASR 已通过。DCloud 当日云打包额度耗尽,尚无 `0.1.16` 签名 APK,调试基座不能替代最终签名包验收。
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,ASR/TTS 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;生产短音频 ASR 当前为阿里云百炼 `qwen3-asr-flash`,SiliconFlow audio 接口保留兼容。SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,ASR/TTS 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;生产短音频 ASR 当前为阿里云百炼 `qwen3-asr-flash`,SiliconFlow audio 接口保留兼容。SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示待重试、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。历史图片/视频若无片段会保持可重试失败,可直接从原 OSS 文件重新入队;2026-07-29 生产已用该链路恢复原卡住的 4 项资料,全部生成片段并入库。后端目录导入接口仅保留为运维/调试能力。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示待重试、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。历史图片/视频若无片段会保持可重试失败,可直接从原 OSS 文件重新入队;2026-07-29 生产已用该链路恢复原卡住的 4 项资料,全部生成片段并入库。后端目录导入接口仅保留为运维/调试能力。
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。2026-07-25 记录的生产快照为 3417 人、2938 人在职且均有姓名、3392 个手机号映射、175 名在职主管和 20 名项目经理;姓名通过稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游 `employee_number` 与既有主体不一致且手机号大量脱敏,修复契约前禁止强制全量同步;人数属于时点事实,发布或验收前应重新查询。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。2026-07-25 记录的生产快照为 3417 人、2938 人在职且均有姓名、3392 个手机号映射、175 名在职主管和 20 名项目经理;姓名通过稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游 `employee_number` 与既有主体不一致且手机号大量脱敏,修复契约前禁止强制全量同步;人数属于时点事实,发布或验收前应重新查询。
+2 -1
View File
@@ -296,5 +296,6 @@
- 2026-07-25 15:44:45,源码提交 `acd5fd3a` 增加常规陪练“建议回复”:服务端根据当前回合与教练提示生成可编辑但不自动发送的员工回复,失败时不复制提示。该提交已推送、尚未部署或生产验证。 - 2026-07-25 15:44:45,源码提交 `acd5fd3a` 增加常规陪练“建议回复”:服务端根据当前回合与教练提示生成可编辑但不自动发送的员工回复,失败时不复制提示。该提交已推送、尚未部署或生产验证。
- 2026-07-29,8 月 1 日 `0.1.13 (113)` 候选已在干净提交 `af8af2f6` 完成统一 RC,`releaseEligible=true`;199 项移动端测试、类型检查、H5/App、管理端构建、`ruoyi-aihr` 700 项测试、后端打包和 APK 深检通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。生产固定码完成首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,两次均确认未真实发送短信;该项不再列为缺口。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未获部署授权、未发布。当前双提交交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节,SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯。当前机器签认仍为正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`。 - 2026-07-29,8 月 1 日 `0.1.13 (113)` 候选已在干净提交 `af8af2f6` 完成统一 RC,`releaseEligible=true`;199 项移动端测试、类型检查、H5/App、管理端构建、`ruoyi-aihr` 700 项测试、后端打包和 APK 深检通过,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`。生产固定码完成首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,两次均确认未真实发送短信;该项不再列为缺口。候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,生产只读发布计划通过但未获部署授权、未发布。当前双提交交付包 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip` 大小 `192,948,093` 字节,SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯。当前机器签认仍为正式场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`。
- 2026-07-29 后续获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;当前 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,冻结候选所含的内容语义与移动身份失败关闭不再是生产阻断项。同批还发布资料处理媒体重试 schema 和管理端,移动 H5 未重发;正式内容、五通道处理人、受控分发和 Android 真机门禁仍保持 No-Go。 - 2026-07-29 后续获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;当前 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,冻结候选所含的内容语义与移动身份失败关闭不再是生产阻断项。同批还发布资料处理媒体重试 schema 和管理端,移动 H5 未重发;正式内容、五通道处理人、受控分发和 Android 真机门禁仍保持 No-Go。
- 2026-07-29 真机完成签名 `0.1.13` 的真实页面登录、麦克风允许和生产 ASR,同时发现拒绝后的文件降级误用 App 不支持的 `uni.chooseFile`。当前源码 `0.1.16 (116)` 已改用 Android 系统文档选择器、SAF `FileChannel` 复制、应用专属外部缓存和 App 媒体选择器;206 项移动端测试、类型检查、App 发布资源预检、App-Plus 构建,以及 vivo 标准调试基座的取消、重复打开、真实 WAV 选择/上传/生产 ASR 已通过。DCloud 当日云打包额度耗尽,尚无 `0.1.16` 签名 APK,调试基座不能替代最终候选验收。
当前只能确认 `0.1.13` 自动化候选已冻结、现网接口兼容且 schema 为 64/64;新后端尚未获授权部署,不能把历史完整包匹配外推为当前候选已上线。正式内容与五通道处理人签认、主管真实复盘、真机媒体和严格试点仍未完成,不能称“陪练模块完整交付完成”。 当前只能确认 `0.1.13` 自动化候选已冻结、生产后端定向发布成功且 schema 为 64/64,以及 `0.1.16` 源码的 Android 音频链路在标准调试基座通过;不能把这些结果外推为 `0.1.16` 已出包或当前完整包已与线上匹配。正式内容与五通道处理人签认、主管真实复盘、最终签名包真机媒体和严格试点仍未完成,不能称“陪练模块完整交付完成”。
+15 -5
View File
@@ -50,6 +50,14 @@ Android 每个人工步骤完成后,可在不改变设备状态的前提下保
2026-07-29 当前非自定义基座内测 APK 已升级为 `0.1.13 (113)`,大小 `33,921,543` 字节,SHA-256 为 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`。DCloud 云端证书打包成功,APK 深检通过包名、API 35、v2 测试签名、登录页法务门禁、身份失败关闭、包内资源一致性、敏感值和禁止明文 HTTP。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`。生产固定码的首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录已在非真机认证脚本中通过,两次验证码请求均确认未真实发送短信;固定码认证不再列为功能缺口,这不替代本文件下列 Android 真机权限、系统选择器、前后台、弱网、附件和媒体链路验收。 2026-07-29 当前非自定义基座内测 APK 已升级为 `0.1.13 (113)`,大小 `33,921,543` 字节,SHA-256 为 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`。DCloud 云端证书打包成功,APK 深检通过包名、API 35、v2 测试签名、登录页法务门禁、身份失败关闭、包内资源一致性、敏感值和禁止明文 HTTP。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`。生产固定码的首次获取/登录、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录已在非真机认证脚本中通过,两次验证码请求均确认未真实发送短信;固定码认证不再列为功能缺口,这不替代本文件下列 Android 真机权限、系统选择器、前后台、弱网、附件和媒体链路验收。
同日,`0.1.13 (113)` 已在 vivo V2443A(Android 15/API 35)完成从 `0.1.6` 保留数据覆盖安装、同版本再次覆盖安装和 Launcher 冷启动;两次安装均成功,冷启动约 `1.1s`/`1.2s`,相机、录音、图片和视频权限保持未授权,未发现 Crash/ANR。真机登录页确认协议默认未勾选,“获取验证码”和“登录”都会先显示协议门禁;勾选后继续进入验证码必填校验。《服务协议》和《隐私政策》分别由 vivo 系统浏览器打开线上 `/legal/terms.html` 与 `/legal/privacy.html`,隐私正文显示 2026-07-28 版本。按 Home 后进程保持,重新唤起为 `HOT`、约 `0.2s`,页面恢复且无 Crash/ANR。脱敏截图和统一取证保存在 `output/aug1-rc/android-acceptance/20260729-140453-upgrade-0.1.13-cold-launch/`;登录页只截取不含手机号的下半屏,未保存手机号、验证码或令牌。该证据完成登录法务门禁和前后台基础恢复,不替代真实账号登录、麦克风、WebRTC、系统选择器、真实断网、附件和 ASR/TTS 验收。
同一真机随后使用生产现有在职 APP 主体和固定码完成页面登录;账号、固定码和令牌未进入日志、截图或仓库。`0.1.13` 允许麦克风后,原生按住录音、上传和生产 ASR 已跑通,识别结果进入真实“问”主路径。拒绝麦克风后的文件降级同时暴露两个 App-Plus 缺陷:`uni.chooseFile` 在 App 不受支持,且 Native.js 无法可靠构造并传递 Java `byte[]`。调查包 `0.1.14/0.1.15` 仅用于定位,生成的 APK 已删除,不得分发。
当前源码候选已升级为 `0.1.16 (116)`:Android 非媒体文件通过系统 `ACTION_OPEN_DOCUMENT` 选择,以 `ParcelFileDescriptor + FileChannel.transferFrom` 复制到应用专属外部缓存;页面生命周期会区分系统选择器暂时离开和真实离页,图片/视频改走 App 支持的 `uni.chooseMedia`,麦克风拒绝在启动录音前统一进入降级。vivo 真机使用 HBuilderX 5.22 标准调试基座完成选择器打开、取消、重复打开及选择 `100,542` 字节 WAV;复制后字节数一致,并继续完成 multipart 上传、生产 ASR 和成果投稿草稿整理,识别测试语句后只生成追问草稿,未点击“确认投稿”、未形成正式投稿。当前 206/206 项移动端测试、类型检查、App 发布资源预检和 App-Plus 构建通过。
由于 DCloud 当日免费云打包次数已用尽,`0.1.16` 的最终签名 APK 尚未生成,标准调试基座证据不能冒充签名包验收。最后一个可重复校验的签名包仍是 `0.1.13 (113)`,但它不包含上述系统选择器修复;额度恢复或购买次数后必须重新云打包、深检并在同一真机复跑麦克风拒绝、选择器取消/选择和 ASR。当前结论因此仍为 No-Go。
随后根据企业内部应用的体验决策,`0.1.12 (112)` 取消 DCloud 启动原生确认,法务同意统一放到登录页。新 APK 大小 `33,921,458` 字节,SHA-256 为 `DD24E5F20BC7C064F5059E538A1FAB7518DF46CBAB502F59A3629F0B1B1F5EBB`;包体门禁确认 `prompt=none`、登录页两个 HTTPS 链接和未勾选拦截均已内置。在同一 Android 15/API 35 虚拟机卸载旧包后全新安装,取证结果为原生隐私层 `not-visible`、四项敏感权限未授权、进程与前台 Activity 正常且无 Crash/ANR,证据位于 `output/aug1-rc/android-acceptance/20260729-021418-no-native-privacy-fresh-install-retry/`。当前 AVD 是 `x86_64`,而 APK 只含 `arm64-v8a/armeabi-v7a`;DCloud Weex 跨架构转译实际报 `spinWaitPeer timeout`、`sender is null` 和 IPC timeout,所以原生外壳可见但页面主体空白。该现象不证明产品页面失败,也不能作为页面通过证据;不得为此修改正式 ABI、回退 H5 壳或增加业务兜底。 随后根据企业内部应用的体验决策,`0.1.12 (112)` 取消 DCloud 启动原生确认,法务同意统一放到登录页。新 APK 大小 `33,921,458` 字节,SHA-256 为 `DD24E5F20BC7C064F5059E538A1FAB7518DF46CBAB502F59A3629F0B1B1F5EBB`;包体门禁确认 `prompt=none`、登录页两个 HTTPS 链接和未勾选拦截均已内置。在同一 Android 15/API 35 虚拟机卸载旧包后全新安装,取证结果为原生隐私层 `not-visible`、四项敏感权限未授权、进程与前台 Activity 正常且无 Crash/ANR,证据位于 `output/aug1-rc/android-acceptance/20260729-021418-no-native-privacy-fresh-install-retry/`。当前 AVD 是 `x86_64`,而 APK 只含 `arm64-v8a/armeabi-v7a`;DCloud Weex 跨架构转译实际报 `spinWaitPeer timeout`、`sender is null` 和 IPC timeout,所以原生外壳可见但页面主体空白。该现象不证明产品页面失败,也不能作为页面通过证据;不得为此修改正式 ABI、回退 H5 壳或增加业务兜底。
同一 `mobile-uni` 源码的 H5 构建另在 390×844 Playwright 真浏览器补验:登录页核心表单、登录按钮和协议行均在首屏;协议默认未勾选,点击“获取验证码”或“登录”都显示“请先阅读并勾选同意”,且网络列表无业务请求;勾选后进入手机号必填校验;《服务协议》《隐私政策》分别打开 `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html`,页面标题和正文可读,控制台 0 错误。截图位于 `output/playwright/aug1-login-consent/`。该证据仅覆盖 Web 页面布局、链接与请求前门禁,不替代 App 运行、认证账号业务、系统录音/媒体权限或弱网验收。 同一 `mobile-uni` 源码的 H5 构建另在 390×844 Playwright 真浏览器补验:登录页核心表单、登录按钮和协议行均在首屏;协议默认未勾选,点击“获取验证码”或“登录”都显示“请先阅读并勾选同意”,且网络列表无业务请求;勾选后进入手机号必填校验;《服务协议》《隐私政策》分别打开 `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html`,页面标题和正文可读,控制台 0 错误。截图位于 `output/playwright/aug1-login-consent/`。该证据仅覆盖 Web 页面布局、链接与请求前门禁,不替代 App 运行、认证账号业务、系统录音/媒体权限或弱网验收。
@@ -67,16 +75,18 @@ Android 每个人工步骤完成后,可在不改变设备状态的前提下保
- [x] `0.1.8 (108)` 在同一虚拟机完成保留数据覆盖安装、标准 Launcher 启动和首次隐私弹窗前的后台/前台恢复,未见 FATAL 或 ANR。 - [x] `0.1.8 (108)` 在同一虚拟机完成保留数据覆盖安装、标准 Launcher 启动和首次隐私弹窗前的后台/前台恢复,未见 FATAL 或 ANR。
- [x] `0.1.10 (110)` 从 `0.1.9` 保留数据覆盖安装并冷启动成功,最新原生隐私弹窗、未授权权限基线和无 FATAL/ANR 状态保持。 - [x] `0.1.10 (110)` 从 `0.1.9` 保留数据覆盖安装并冷启动成功,最新原生隐私弹窗、未授权权限基线和无 FATAL/ANR 状态保持。
- [x] 只读统一取证脚本已通过状态变更命令回归并在虚拟机实跑,可重复保存当前版本、Activity、隐私控件、权限、UI、截图和退出记录。 - [x] 只读统一取证脚本已通过状态变更命令回归并在虚拟机实跑,可重复保存当前版本、Activity、隐私控件、权限、UI、截图和退出记录。
- [x] 媒体选择取消/真实失败、直通车首次弱网恢复和登录法务门禁状态机已由当前 199/199 个移动端测试覆盖;真实 Android 文件选择器取消与断网恢复仍列为下方人工门禁。 - [x] 媒体选择取消/真实失败、Android 系统选择器生命周期、直通车首次弱网恢复和登录法务门禁状态机已由当前 206/206 个移动端测试覆盖;真实断网恢复仍列为下方人工门禁。
- [x] `0.1.12 (112)` 全新安装后不显示原生隐私确认,四项敏感权限保持未授权且无 Crash/ANR;包内登录协议门禁与统一 RC 自动校验通过。 - [x] `0.1.12 (112)` 全新安装后不显示原生隐私确认,四项敏感权限保持未授权且无 Crash/ANR;包内登录协议门禁与统一 RC 自动校验通过。
- [x] `0.1.13 (113)` 在 vivo Android 15 真机完成保留数据升级、同版本覆盖安装、冷启动、登录法务门禁、两个线上法务正文和前后台热恢复;四项敏感权限保持未授权且未见 Crash/ANR。
- [x] 同一真机在签名 `0.1.13` 完成真实页面登录和麦克风允许后的原生录音/生产 ASR;当前 `0.1.16` 源码又在 HBuilderX 标准调试基座完成 Android 系统选择器取消、重复打开、真实 WAV 选择、精确复制、上传、生产 ASR 和无提交草稿整理。
## Android(最低 Android 10 + 一台当前主流机型) ## Android(最低 Android 10 + 一台当前主流机型)
- [ ] 登录页协议默认未勾选;两个法务链接能打开正确正文;未勾选时验证码和登录均不发请求,勾选后才允许继续。 - [x] 登录页协议默认未勾选;两个法务链接能打开正确正文;未勾选时验证码和登录均不发请求,勾选后才允许继续。
- [ ] 在 Android 设备选择数据库现有有效手机号,点击获取验证码后使用生产固定码登录;不得开启真实短信。首次登录、退出、旧令牌失效及重新获取/登录的非真机链路已通过,本项只剩设备页面操作。 - [x] 在 Android 设备选择数据库现有有效手机号,点击获取验证码后使用生产固定码登录;未开启真实短信,且未保存手机号、固定码或令牌。
- [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。 - [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。允许分支已在签名 `0.1.13` 通过;拒绝、取消、重复选择和本地音频 ASR 已在 `0.1.16` 标准调试基座通过,仍须等待 `0.1.16` 签名 APK 后复跑。
- [ ] 原生实时对练可完成麦克风授权、实时转写与远端语音播放;WebRTC/WebView 不可用或断线时明确提示并保留录音对练。 - [ ] 原生实时对练可完成麦克风授权、实时转写与远端语音播放;WebRTC/WebView 不可用或断线时明确提示并保留录音对练。
- [ ] 图片、视频、文档和音频可选择并上传;受控附件下载后可打开,不泄露 OSS 地址。 - [ ] 图片、视频、文档和音频可选择并上传;受控附件下载后可打开,不泄露 OSS 地址。当前仅完成 `0.1.16` 调试基座的 Android 音频选择/上传/ASR,图片、视频、文档、受控下载和最终签名包仍待验收。
- [ ] 前后台切换、网络断开/恢复、窄屏和底部手势区无白屏、遮挡或重复提交。 - [ ] 前后台切换、网络断开/恢复、窄屏和底部手势区无白屏、遮挡或重复提交。
- [ ] 再次安装同版本后,桌面图标、启动图和 Android 导航栏不闪白。 - [ ] 再次安装同版本后,桌面图标、启动图和 Android 导航栏不闪白。
+3 -1
View File
@@ -80,7 +80,9 @@ APK 门禁还会比较包内与当前 `dist/build/app/app-service.js` 的 SHA-25
脚本会复跑移动端测试/类型检查/H5/App 构建、管理端构建、后端定向测试和打包,验证 APK 后在被 Git 忽略的 `output/aug1-rc/` 生成标准文件名、哈希及 JSON/Markdown 证据。它不执行生产同步、数据库迁移、服务重启或登录页人工同意操作。 脚本会复跑移动端测试/类型检查/H5/App 构建、管理端构建、后端定向测试和打包,验证 APK 后在被 Git 忽略的 `output/aug1-rc/` 生成标准文件名、哈希及 JSON/Markdown 证据。它不执行生产同步、数据库迁移、服务重启或登录页人工同意操作。
2026-07-28 最终自定义基座大小为 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`。2026-07-29 当前独立内测候选包为 `bangdao-0.1.13-dcloud-test.apk`:包名 `com.yincheng.wygj`、版本 `0.1.13 (113)`、`targetSdkVersion=35`,大小 `33,921,543` 字节,SHA-256 为 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`。APK 反编译门禁确认包内业务资源等于当前 App-Plus 编译产物、启动原生隐私提示已关闭、登录页法务链接与未勾选拦截及身份加载失败关闭已进入包体,并通过敏感值、`usesCleartextTraffic=false`、`networkSecurityConfig` 和系统证书信任检查。干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的统一 RC 已完成,`releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节、SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,`ops9c53668c`、`0.1.12` 及更早 RC/交付 ZIP 只保留为历史基线。当前 AVD 为 `x86_64`,APK 原生库仅含 `arm64-v8a/armeabi-v7a`;安装和原生外壳可由系统转译运行,但 DCloud Weex JS 进程实际出现 `spinWaitPeer`/IPC 超时,页面主体空白不能用于判断产品页面。浏览器验证只作补充,不替代 App 运行;不会为了模拟器改变正式 ABI、回退为 H5 壳或增加业务兜底。该包仍使用 DCloud 云端测试证书,只能作为 8 月 1 日受控内测包;受控分发、法务例外和测试签名接受当前均待业务签认,企业正式 release 还必须使用签认后的企业证书摘要。 2026-07-28 最终自定义基座大小为 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`。2026-07-29 最后一个已生成并可重复校验的独立内测包为 `bangdao-0.1.13-dcloud-test.apk`:包名 `com.yincheng.wygj`、版本 `0.1.13 (113)`、`targetSdkVersion=35`,大小 `33,921,543` 字节,SHA-256 为 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`。APK 反编译门禁确认包内业务资源等于冻结时的 App-Plus 编译产物、启动原生隐私提示已关闭、登录页法务链接与未勾选拦截及身份加载失败关闭已进入包体,并通过敏感值、`usesCleartextTraffic=false`、`networkSecurityConfig` 和系统证书信任检查。干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的统一 RC 已完成,`releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节、SHA-256 为 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,`ops9c53668c`、`0.1.12` 及更早 RC/交付 ZIP 只保留为历史基线。当前 AVD 为 `x86_64`,APK 原生库仅含 `arm64-v8a/armeabi-v7a`;安装和原生外壳可由系统转译运行,但 DCloud Weex JS 进程实际出现 `spinWaitPeer`/IPC 超时,页面主体空白不能用于判断产品页面。浏览器验证只作补充,不替代 App 运行;不会为了模拟器改变正式 ABI、回退为 H5 壳或增加业务兜底。该包仍使用 DCloud 云端测试证书,只能作为 8 月 1 日受控内测包;受控分发、法务例外和测试签名接受当前均待业务签认,企业正式 release 还必须使用签认后的企业证书摘要。
当前源码版本为 `0.1.16 (116)`,已修复 Android 麦克风拒绝后的系统文件选择、SAF 复制、上传路径和页面生命周期;标准调试基座已在 vivo Android 15 真机完成真实 WAV 选择、上传和生产 ASR。`npm run verify:app-release` 与 `npm run build:app` 只证明出包输入资源可用。DCloud 当日云打包额度耗尽,尚未生成 `0.1.16` 签名 APK;`0.1.14/0.1.15` 调查 APK 已删除。不得把 `dist/build/app`、标准调试基座或旧 `0.1.13` 的哈希标为当前源码的签名候选。
企业正式签名 APK 还必须用发布负责人保管的证书摘要执行 release 模式核验;该模式会额外拒绝 `debuggable=true`、明文 HTTP 和签名摘要不一致: 企业正式签名 APK 还必须用发布负责人保管的证书摘要执行 release 模式核验;该模式会额外拒绝 `debuggable=true`、明文 HTTP 和签名摘要不一致:
@@ -1,6 +1,6 @@
# 帮道 8 月 1 日 Android 内测 Go/No-Go # 帮道 8 月 1 日 Android 内测 Go/No-Go
> 评估时间:2026-07-29 15:58(CST) > 评估时间:2026-07-29 23:42(CST)
> >
> 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。 > 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。
> >
@@ -10,15 +10,15 @@
**当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。** **当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。**
`0.1.13` DCloud 云端证书 APK 已生成并通过深检,认证身份失败关闭和可重复固定码重新登录门禁已进入包体;干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`。候选后端语义已包含在 2026-07-29 后续定向发布的生产 JAR 中,服务、公开入口、schema 和资料处理真实任务均已验证,生产后端发布不再是阻断项。当前阻断点只剩正式业务内容/五通道处理人签认、企业发布签认,以及本轮明确暂缓的真机设备验收。 `0.1.13` DCloud 云端证书 APK 已生成并通过深检,且在 vivo Android 15 真机完成真实页面登录、麦克风允许和生产 ASR;干净提交 `af8af2f6` 的统一 RC 曾达到 `releaseEligible=true`。真机继续发现该签名包的麦克风拒绝降级错误使用 App 不支持的 `uni.chooseFile`。当前 `0.1.16` 源码已修复 Android 系统选择器、SAF 复制、上传路径和选择器生命周期,并在标准调试基座跑通取消、重复打开、真实 WAV 选择、上传、生产 ASR 和无提交草稿整理;但 DCloud 当日云打包额度耗尽,尚无 `0.1.16` 签名 APK。因此生产后端不再阻断,当前阻断点是最终签名候选包复验、正式业务内容/五通道处理人签认、企业发布签认,以及真实断网、WebRTC、图片/视频/文档和受控下载等剩余真机门禁。
## 已通过 ## 已通过
| 检查项 | 当前结果 | | 检查项 | 当前结果 |
|---|---| |---|---|
| 当前运行时冻结 | 干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的 `0.1.13 (113)` 统一 RC 已完成,`releaseEligible=true`;证据目录为 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`,候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD` | | 当前运行时冻结 | 干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的 `0.1.13 (113)` 统一 RC 已完成,`releaseEligible=true`;证据目录为 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`,候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD` |
| 移动端自动化 | 199 项单元测试通过;TypeScript 类型检查通过;登录页协议默认未勾选,法务链接、验证码和登录共用失败关闭门禁;五个文件选择器和九个消费页面已覆盖用户取消/平台失败分流,直通车首次弱网加载失败可原位重试并失败关闭 | | 移动端自动化 | 当前 206 项单元测试通过;TypeScript 类型检查通过;新增 Android SAF、FileChannel、应用专属外部缓存、麦克风预检和系统选择器生命周期回归,五个文件选择器和九个消费页面继续覆盖取消/平台失败分流 |
| 构建 | `mobile-uni` H5、App-Plus、管理端生产构建通过;后端 `ruoyi-admin.jar` 重建通过;统一 RC 的前端、移动 H5 和后端 JAR 哈希均已写入冻结证据 | | 构建 | `0.1.16` App 发布资源预检与 App-Plus 构建通过;历史统一 RC 的 H5、管理端和后端 JAR 构建证据保持。DCloud 当日云打包额度耗尽,当前没有 `0.1.16` 签名 APK,不能把源码构建当成已出包 |
| P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 | | P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 |
| 内容候选门禁 | 已生成 5 个禁用场景候选和 30 道待审政策题,机器校验通过;正式可发布场景 `0`、正式有据标准答案 `0`,不得导入或启用 | | 内容候选门禁 | 已生成 5 个禁用场景候选和 30 道待审政策题,机器校验通过;正式可发布场景 `0`、正式有据标准答案 `0`,不得导入或启用 |
| 最终业务就绪门禁 | `verify-aug1-formal-content.mjs --strict` 和 `verify-aug1-release-readiness.sh --execute` 已落地并覆盖正反例;当前机器签认准确返回场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING` 并失败关闭。签认完成后还会只读核对生产固定码模式、五角色各至少两名有效处理人和五个已发布场景的版本/哈希/审核链/SOP 引用;不请求验证码、不登录、不发短信、不写数据库 | | 最终业务就绪门禁 | `verify-aug1-formal-content.mjs --strict` 和 `verify-aug1-release-readiness.sh --execute` 已落地并覆盖正反例;当前机器签认准确返回场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING` 并失败关闭。签认完成后还会只读核对生产固定码模式、五角色各至少两名有效处理人和五个已发布场景的版本/哈希/审核链/SOP 引用;不请求验证码、不登录、不发短信、不写数据库 |
@@ -26,10 +26,11 @@
| 独立 APK | `com.yincheng.wygj`,`0.1.13 (113)`,`targetSdkVersion=35`,v2 测试签名,明文 HTTP 关闭且网络安全资源已进入 APK;大小 `33,921,543` 字节,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0` | | 独立 APK | `com.yincheng.wygj`,`0.1.13 (113)`,`targetSdkVersion=35`,v2 测试签名,明文 HTTP 关闭且网络安全资源已进入 APK;大小 `33,921,543` 字节,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0` |
| 双提交交付包 | `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`;运行时 `af8af2f6`、operations `46f4616b`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希复核通过;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯 | | 双提交交付包 | `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`;运行时 `af8af2f6`、operations `46f4616b`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希复核通过;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯 |
| APK 内容门禁 | 包内业务资源等于当前 App-Plus 编译产物;原生提示关闭、登录法务链接、未勾选拦截、身份失败关闭、手机号、固定测试验证码、私钥和常见令牌扫描通过 | | APK 内容门禁 | 包内业务资源等于当前 App-Plus 编译产物;原生提示关闭、登录法务链接、未勾选拦截、身份失败关闭、手机号、固定测试验证码、私钥和常见令牌扫描通过 |
| 真机安装启动 | vivo V2443A(Android 15)完成 `0.1.6` 覆盖安装和冷启动;相机、录音、媒体权限保持未授权,未见 `FATAL EXCEPTION` | | 真机安装启动 | vivo V2443A(Android 15/API 35)完成当前 `0.1.13` 从 `0.1.6` 保留数据升级、同版本再次覆盖安装和 Launcher 冷启动;四项敏感权限保持未授权,未见 Crash/ANR |
| 虚拟机安装与生命周期 | 本机 `Medium_Phone` Android 15/API 35 已全新安装 `0.1.12`;启动原生隐私层不可见、四项敏感权限为 `false`、未见 Crash/ANR。AVD 为 `x86_64`、APK 仅含 ARM ABI,Weex 跨架构转译实际报 `spinWaitPeer`/IPC timeout;原生外壳证据有效,页面主体既不判失败也不判通过,不向发布包增加 x86 ABI或 H5 兜底 | | 虚拟机安装与生命周期 | 本机 `Medium_Phone` Android 15/API 35 已全新安装 `0.1.12`;启动原生隐私层不可见、四项敏感权限为 `false`、未见 Crash/ANR。AVD 为 `x86_64`、APK 仅含 ARM ABI,Weex 跨架构转译实际报 `spinWaitPeer`/IPC timeout;原生外壳证据有效,页面主体既不判失败也不判通过,不向发布包增加 x86 ABI或 H5 兜底 |
| Android 统一取证 | 只读脚本确认 `0.1.12 (112)` 进程和前台 Activity 正常、原生隐私层 `not-visible`、四项敏感权限为 `false`、退出记录无 Crash/ANR,证据在 `output/aug1-rc/android-acceptance/20260729-021418-no-native-privacy-fresh-install-retry/` | | Android 统一取证 | 当前 `0.1.13 (113)` 进程和前台 Activity 正常、原生隐私层 `not-visible`、四项敏感权限为 `false`、退出记录无 Crash/ANR;Home 后进程保持,热恢复约 `0.2s`。证据在 `output/aug1-rc/android-acceptance/20260729-140453-upgrade-0.1.13-cold-launch/` |
| 登录页法务策略 | 启动不再弹原生确认,包体门禁通过;390×844 Playwright 真浏览器实测协议默认未勾选、两个 HTTPS 正文、验证码/登录请求前失败关闭和勾选后手机号校验,控制台 0 错误。该结果只作 Web 交互补充,不替代 App 认证业务验收 | | 登录页法务策略 | 启动不再弹原生确认,包体门禁通过;vivo 真机实测协议默认未勾选、验证码/登录请求前失败关闭、勾选后进入验证码校验,两个链接分别打开线上服务协议和 2026-07-28 版隐私正文;脱敏截图不含手机号、验证码或令牌 |
| 真机登录与音频 | vivo 真机使用生产现有在职 APP 主体和固定码完成页面登录,签名 `0.1.13` 完成麦克风允许、原生录音和生产 ASR;当前 `0.1.16` 资源在 HBuilderX 5.22 标准调试基座完成系统选择器取消、重复打开、`100,542` 字节 WAV 精确复制、multipart 上传、生产 ASR 和成果草稿整理,未点击确认投稿。账号、固定码、令牌和业务正文未入库为证据 |
| 生产固定码认证 | 运行时确认固定码和生产显式开关同时启用;生产应用日志确认首次和重新登录前的两次验证码请求均命中“未真实发送短信”分支。只选择已存在 `app_user` 的双向唯一在职主体,不触发自动注册,不输出手机号、固定码或令牌 | | 生产固定码认证 | 运行时确认固定码和生产显式开关同时启用;生产应用日志确认首次和重新登录前的两次验证码请求均命中“未真实发送短信”分支。只选择已存在 `app_user` 的双向唯一在职主体,不触发自动注册,不输出手机号、固定码或令牌 |
| 认证后非真机主路径 | `scripts/verify-aug1-authenticated-production.sh --execute` 通过身份/非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网能力只读入口、退出、旧令牌业务码 401、防刷窗口后的再次获取、重新登录、重新读取身份和再次退出;五通道返回数为 5。未提交业务写入、附件、ASR/TTS 或 Agent 请求 | | 认证后非真机主路径 | `scripts/verify-aug1-authenticated-production.sh --execute` 通过身份/非空项目范围、认证首页、训练历史/画像、五通道、我的反馈、大喇叭、考试、案例/全网能力只读入口、退出、旧令牌业务码 401、防刷窗口后的再次获取、重新登录、重新读取身份和再次退出;五通道返回数为 5。未提交业务写入、附件、ASR/TTS 或 Agent 请求 |
| 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` | | 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` |
@@ -39,10 +40,10 @@
## No-Go 阻断项 ## No-Go 阻断项
1. **剩余认证问题已收敛为真机设备能力。** 固定码首次请求、登录、退出、旧令牌失效、等待防刷窗口后的再次请求和重新登录均已通过,两次请求都由日志确认没有真实短信发送。后端定向发布后公开接口再次通过;尚未完成的麦克风允许/拒绝、真实系统选择器取消、App 前后台和真实断网恢复均属于 Android 真机门禁,不能用 H5 结果代替。 1. **最终签名候选包尚不存在。** `0.1.13` 的真实登录、麦克风允许和 ASR 已通过,但拒绝后的文件降级存在缺陷;`0.1.14/0.1.15` 仅为调查包且 APK 已删除。`0.1.16` 源码在标准调试基座完成系统选择器和音频 ASR 全链路,但 DCloud 当日云打包额度耗尽。额度恢复后必须生成、深检并真机复跑 `0.1.16` 签名 APK,调试基座不能替代该门禁。
2. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;正式“练”仍没有可发布的 5 个场景,五通道仍没有真实收件人,附件和 ASR/TTS 仍属真机/媒体验收。不得通过创建测试内容或绑定任意用户制造假闭环。 2. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;音频选择与 ASR 已在当前源码调试基座跑通,但正式“练”仍没有可发布的 5 个场景,五通道仍没有真实收件人,图片/视频/文档、受控下载、WebRTC 和真实断网恢复仍待最终签名包真机验收。不得通过创建测试内容或绑定任意用户制造假闭环。
3. **内容与业务验收不足。** 当前仅收到 1/24 份生活顾问访谈。技术侧已把调研整理为 30 道政策题候选和 5 个场景候选,并提供统一签认单,但全部保持待审/禁用,正式可发布场景仍为 0/5,正式有据标准答案仍为 0/30。生产只读审计确认 `direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 五个角色的有效用户绑定数均为 0,必须由业务负责人指定主处理人和替补后再验收。 3. **内容与业务验收不足。** 当前仅收到 1/24 份生活顾问访谈。技术侧已把调研整理为 30 道政策题候选和 5 个场景候选,并提供统一签认单,但全部保持待审/禁用,正式可发布场景仍为 0/5,正式有据标准答案仍为 0/30。生产只读审计确认 `direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 五个角色的有效用户绑定数均为 0,必须由业务负责人指定主处理人和替补后再验收。
4. **APK 分发与企业发布尚未签认。** 后端发布授权、备份、原子切换、发布后验证和回滚点已经闭环;当前 APK 已关闭明文流量但仍使用 DCloud 测试证书,只能用于受控内测。受控分发批准、测试签名接受、企业签名和公司法务正式签认仍是对应发布层级的门槛。 4. **APK 分发与企业发布尚未签认。** 后端发布授权、备份、原子切换、发布后验证和回滚点已经闭环;最后一个签名 APK `0.1.13` 已关闭明文流量但不含当前选择器修复,`0.1.16` 尚未云打包。受控分发批准、测试签名接受、企业签名和公司法务正式签认仍是对应发布层级的门槛。
## 转为 Go 的最短路径 ## 转为 Go 的最短路径
@@ -54,7 +55,7 @@
| 已完成 | 使用生产现有在职 APP 主体和固定码完成非真机认证、退出、重新获取、重新登录及认证后只读主路径;固定码请求不真实发送短信 | `scripts/verify-aug1-authenticated-production.sh --execute` 全部通过,五通道数为 5,旧令牌按业务码 401 拒绝,两次验证码请求均有“未真实发送”日志;执行过程不输出手机号、固定码或令牌 | | 已完成 | 使用生产现有在职 APP 主体和固定码完成非真机认证、退出、重新获取、重新登录及认证后只读主路径;固定码请求不真实发送短信 | `scripts/verify-aug1-authenticated-production.sh --execute` 全部通过,五通道数为 5,旧令牌按业务码 401 拒绝,两次验证码请求均有“未真实发送”日志;执行过程不输出手机号、固定码或令牌 |
| 1 | 业务方按统一签认单审核 30 道题和 5 个场景候选,补齐正式来源;同时指定五渠道主处理人和替补 | [业务内容与五通道签认单](帮道8月1日业务内容与五通道签认单-20260729.md)、正式内容版本、授权矩阵;正式可发布场景从 0/5 提升到 5/5、有据标准答案从 0/30 提升到 30/30,五个角色有效绑定均不少于 2 | | 1 | 业务方按统一签认单审核 30 道题和 5 个场景候选,补齐正式来源;同时指定五渠道主处理人和替补 | [业务内容与五通道签认单](帮道8月1日业务内容与五通道签认单-20260729.md)、正式内容版本、授权矩阵;正式可发布场景从 0/5 提升到 5/5、有据标准答案从 0/30 提升到 30/30,五个角色有效绑定均不少于 2 |
| 2 | 绑定获批处理人后完成五通道正例、跨通道反例和一次正式回复;用正式内容完成“查/练/问”业务写入闭环 | 每条用例的时间、角色、结果和脱敏证据;不记录手机号、验证码或反馈正文 | | 2 | 绑定获批处理人后完成五通道正例、跨通道反例和一次正式回复;用正式内容完成“查/练/问”业务写入闭环 | 每条用例的时间、角色、结果和脱敏证据;不记录手机号、验证码或反馈正文 |
| 3 | Android 真机完成麦克风、系统选择器、前后台、弱网、附件和 ASR/TTS 门禁 | 当前 APK 哈希对应的设备证据;不得用 H5 或模拟器空白 WebView 代替 | | 3 | DCloud 额度恢复后生成并深检 `0.1.16` 签名 APK,在 Android 真机复跑麦克风拒绝、系统选择器取消/选择、音频 ASR,并继续完成真实断网、WebRTC、图片/视频/文档和受控下载 | 新 APK 版本、哈希、签名和同一包对应的设备证据;当前标准调试基座只证明源码链路,不得替代签名包验收 |
| 4 | 7 月 31 日复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 | | 4 | 7 月 31 日复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 |
公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。 公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。
@@ -62,7 +62,7 @@
- 直通车已经支持五通道、业务匿名、按角色收件和一次正式回复。 - 直通车已经支持五通道、业务匿名、按角色收件和一次正式回复。
- 生产短音频 ASR 已切换到阿里云百炼 `qwen3-asr-flash`,并在一台 Android 标准基座完成过“按住说话→转写→提交”验证。 - 生产短音频 ASR 已切换到阿里云百炼 `qwen3-asr-flash`,并在一台 Android 标准基座完成过“按住说话→转写→提交”验证。
当前短板主要是:正式内容、五通道真实处理人、生产后端部署授权与发布后匹配、企业签名/法务签认,以及本轮明确暂缓的完整真机反例。`0.1.13` 受控内测 APK、统一 RC、固定码认证和生产只读发布计划已经完成;API 35 自定义基座链路已成立,但不能替代企业签名包或正式试点。 当前短板主要是:正式内容、五通道真实处理人、`0.1.16` 最终签名 APK、企业签名/法务签认,以及真实断网、WebRTC、图片/视频/文档和受控下载等剩余真机反例。生产后端定向发布与发布后预检已经完成;最后一个签名内测包仍是 `0.1.13`,当前 `0.1.16` 只有源码构建和标准调试基座证据,不能替代最终签名包或正式试点。
### 3.1 生产只读基线(2026-07-27) ### 3.1 生产只读基线(2026-07-27)
@@ -90,7 +90,7 @@
| 门禁 | 结果 | 说明 | | 门禁 | 结果 | 说明 |
|---|---|---| |---|---|---|
| `mobile-uni` 单元测试 | 通过 | 199/199;覆盖身份/岗位加载失败关闭、文件选择器取消/失败分流、九个消费页面、直通车首次弱网恢复及版本一致性,测试过程有本机 WebSocket 端口占用告警,但无失败 | | `mobile-uni` 单元测试 | 通过 | 当前 206/206;新增 Android SAF、FileChannel、应用专属外部缓存、麦克风预检和系统选择器生命周期回归,既有身份/岗位失败关闭、取消/失败分流、九个消费页面、直通车弱网恢复及版本一致性继续通过 |
| `mobile-uni` TypeScript 检查 | 通过 | `npm --prefix mobile-uni run typecheck` | | `mobile-uni` TypeScript 检查 | 通过 | `npm --prefix mobile-uni run typecheck` |
| H5 资源构建 | 通过 | 仅证明 H5 资源可编译 | | H5 资源构建 | 通过 | 仅证明 H5 资源可编译 |
| App-Plus 资源构建 | 通过 | 仅生成 HBuilderX 出包资源,不等同于 APK | | App-Plus 资源构建 | 通过 | 仅生成 HBuilderX 出包资源,不等同于 APK |
@@ -103,7 +103,7 @@
| `ruoyi-aihr` 后端全量测试 | 通过 | 700 个测试,0 失败、0 错误;包含正式场景占位来源/回合/Rubric 禁止发布和移动身份空结果失败关闭回归;Windows CRLF 引发的 7 个源码契约误判已通过读取时统一 LF 修复并全量复跑 | | `ruoyi-aihr` 后端全量测试 | 通过 | 700 个测试,0 失败、0 错误;包含正式场景占位来源/回合/Rubric 禁止发布和移动身份空结果失败关闭回归;Windows CRLF 引发的 7 个源码契约误判已通过读取时统一 LF 修复并全量复跑 |
| 法务配置技术链路 | 通过 | 登录协议默认不勾选;协议未配置时禁止验证码与登录;配置生成器和发布联网校验已接入,发布包拒绝开发验证码提示 | | 法务配置技术链路 | 通过 | 登录协议默认不勾选;协议未配置时禁止验证码与登录;配置生成器和发布联网校验已接入,发布包拒绝开发验证码提示 |
| 正式 App 发布配置校验 | **技术通过,法务签认阻断** | 受控内测版协议正文已通过 Caddy 公网 HTTPS 发布;启动原生提示关闭,登录页协议默认未勾选且验证码/登录失败关闭,包体与联网门禁使用同一组地址。公司法务尚未正式签认正文,不能视为正式发布通过 | | 正式 App 发布配置校验 | **技术通过,法务签认阻断** | 受控内测版协议正文已通过 Caddy 公网 HTTPS 发布;启动原生提示关闭,登录页协议默认未勾选且验证码/登录失败关闭,包体与联网门禁使用同一组地址。公司法务尚未正式签认正文,不能视为正式发布通过 |
| Android 设备、调试基座、测试签名和设备门禁 | **部分通过,仍阻断** | API 35 自定义基座和可独立安装的 DCloud 云证书内测 APK 均已生成。`0.1.12` 在 Android 15 虚拟机全新安装后确认无原生隐私提示、四项敏感权限未授权且无 Crash/ANR;390×844 Playwright 真浏览器已通过登录页默认未勾选、两个线上协议、验证码/登录请求前拦截及勾选后手机号校验,控制台 0 错误。AVD 的 x86_64 与 APK 的 ARM-only ABI 导致 DCloud Weex IPC 超时;浏览器结果只作补充,不替代 App 运行,也不修改发布 ABI、回退 H5 壳或增加业务兜底。录音允许/拒绝、真实系统选择器取消、真实断网恢复和认证业务反例仍待完成 | | Android 设备、调试基座、测试签名和设备门禁 | **部分通过,仍阻断** | vivo Android 15 真机已在签名 `0.1.13` 完成真实页面登录、麦克风允许和生产 ASR;当前 `0.1.16` 源码又在 HBuilderX 5.22 标准调试基座完成系统选择器取消、重复打开、真实 WAV 选择、精确复制、multipart 上传和生产 ASR。DCloud 当日云打包额度耗尽,尚无包含修复的 `0.1.16` 签名 APK;真实断网、WebRTC、图片/视频/文档和受控下载仍待同一最终签名包验收 |
统一 RC 脚本已在干净运行时提交 `dc20b92061f10a356ed73eb105ef7b00c8b8a617` 上完整复跑,证据目录为 `output/aug1-rc/0.1.10-110-dc20b920-frozen/`,结果为 `releaseEligible=true`。发布/回滚、API 探针和可识别隐私二次确认/退出态的 Android 取证工具另冻结在 operations commit `71f005c22981989c5f2b24fa31c385a1b82657b7`。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.10-110-dc20b920-ops71f005c2.zip`,大小 `192,926,422` 字节,SHA-256 为 `4391895420B50EBBD8CB87EA20AF71A70D5DABAD5003F5361A8B034CF7525BCA`;已完成 14 个 ZIP 条目和 13 个 `SHA256SUMS.txt` 载荷的逐条复验。旧无 operations 后缀包、`ops861fa0ea` 与 `ops414af66e` 包保留作追溯但均已被替代。上述本地自动化冻结不替代生产后端发布授权和人工验收。 统一 RC 脚本已在干净运行时提交 `dc20b92061f10a356ed73eb105ef7b00c8b8a617` 上完整复跑,证据目录为 `output/aug1-rc/0.1.10-110-dc20b920-frozen/`,结果为 `releaseEligible=true`。发布/回滚、API 探针和可识别隐私二次确认/退出态的 Android 取证工具另冻结在 operations commit `71f005c22981989c5f2b24fa31c385a1b82657b7`。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.10-110-dc20b920-ops71f005c2.zip`,大小 `192,926,422` 字节,SHA-256 为 `4391895420B50EBBD8CB87EA20AF71A70D5DABAD5003F5361A8B034CF7525BCA`;已完成 14 个 ZIP 条目和 13 个 `SHA256SUMS.txt` 载荷的逐条复验。旧无 operations 后缀包、`ops861fa0ea` 与 `ops414af66e` 包保留作追溯但均已被替代。上述本地自动化冻结不替代生产后端发布授权和人工验收。
@@ -113,6 +113,8 @@
同日后续已获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;生产 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,后端发布不再是 8 月 1 日 APK 的阻断项。同批发布资料处理媒体恢复 schema 和管理端,移动 H5 未重发;这不改变正式场景/题目、五通道处理人、受控分发与真机设备门禁的 No-Go 状态。 同日后续已获得独立授权并通过 `release-backend.sh` 完成生产后端定向发布;生产 JAR SHA-256 为 `5376b67b33b171b5c85e22cd6a2f20bbb0bc527fc50a223603d0aec3f6587bac`,服务、三个公开入口和后端 + schema 发布后预检通过,后端发布不再是 8 月 1 日 APK 的阻断项。同批发布资料处理媒体恢复 schema 和管理端,移动 H5 未重发;这不改变正式场景/题目、五通道处理人、受控分发与真机设备门禁的 No-Go 状态。
同日真机继续确认 `0.1.13` 的麦克风允许和生产 ASR 可用,但拒绝后的降级误用 App 不支持的 `uni.chooseFile`。当前源码已升级为 `0.1.16 (116)`,改用 Android 系统文档选择器、`ParcelFileDescriptor + FileChannel.transferFrom`、应用专属外部缓存和 App 媒体选择器,并保留系统选择器暂时离开时的待完成任务。vivo 真机标准调试基座完成取消、重复打开、`100,542` 字节 WAV 选择/复制/上传/生产 ASR 和无提交草稿整理;206 项测试、类型检查、App 发布资源预检和 App-Plus 构建通过。调查用 `0.1.14/0.1.15` APK 已删除,DCloud 当日云打包额度耗尽,`0.1.16` 签名 APK 尚不存在,不能进入分发。
生活顾问调研已转成 `docs/content-candidates/aug1-life-advisor-content-candidates-v0.1.json`:包含 5 个禁用场景候选和 30 道待审政策题,内容候选校验脚本通过。由于当前只收到 1/24 份访谈,且正式来源、业务审核和 Rubric 签认尚未完成,正式可发布场景与正式有据标准答案均为 `0`;交付包中的候选文件只供审核,不得直接导入或启用。 生活顾问调研已转成 `docs/content-candidates/aug1-life-advisor-content-candidates-v0.1.json`:包含 5 个禁用场景候选和 30 道待审政策题,内容候选校验脚本通过。由于当前只收到 1/24 份访谈,且正式来源、业务审核和 Rubric 签认尚未完成,正式可发布场景与正式有据标准答案均为 `0`;交付包中的候选文件只供审核,不得直接导入或启用。
业务回填入口已统一为 `docs/帮道8月1日业务内容与五通道签认单-20260729.md`。2026-07-29 生产只读审计确认五个直通角色均存在,但有效用户绑定数分别为 0;系统功能可用不等于真实收件人已配置,技术侧不得擅自绑定人员。 业务回填入口已统一为 `docs/帮道8月1日业务内容与五通道签认单-20260729.md`。2026-07-29 生产只读审计确认五个直通角色均存在,但有效用户绑定数分别为 0;系统功能可用不等于真实收件人已配置,技术侧不得擅自绑定人员。
@@ -136,9 +138,9 @@
- 本机 Android Studio `Medium_Phone` 虚拟机已启动为 Android 15/API 35(`1080×2400`、密度 `420`)。在此前未安装本应用的虚拟机上,`0.1.6 (106)` 首次安装和 Launcher 冷启动成功,最新包真实显示原生隐私弹窗,四项相机、录音、图片和视频权限均未授予,crash 缓冲区为空。虚拟机只补充干净安装和 API 35 权限基线,不替代真机媒体、WebRTC、弱网与系统浏览器验收;拒绝/同意仍由用户亲自执行。 - 本机 Android Studio `Medium_Phone` 虚拟机已启动为 Android 15/API 35(`1080×2400`、密度 `420`)。在此前未安装本应用的虚拟机上,`0.1.6 (106)` 首次安装和 Launcher 冷启动成功,最新包真实显示原生隐私弹窗,四项相机、录音、图片和视频权限均未授予,crash 缓冲区为空。虚拟机只补充干净安装和 API 35 权限基线,不替代真机媒体、WebRTC、弱网与系统浏览器验收;拒绝/同意仍由用户亲自执行。
- `0.1.8 (108)` 进一步把 Android 原生清单和 `network_security_config.xml` 纳入 App 构建链及 APK 反编译门禁。独立内测 APK 大小 `33,921,705` 字节,SHA-256 为 `E252140645F3AAD14B3594FD78E76BDF1A2122F7DC70EBD21CC5DF53223C7878`;APK 反编译确认 `usesCleartextTraffic=false`、`networkSecurityConfig` 引用和系统证书信任配置均已进入安装包,本机虚拟机卸载旧实例后全新安装并在 `2.904s` 内冷启动,最新隐私弹窗真实显示,四项敏感权限未授予且 crash 缓冲区为空。 - `0.1.8 (108)` 进一步把 Android 原生清单和 `network_security_config.xml` 纳入 App 构建链及 APK 反编译门禁。独立内测 APK 大小 `33,921,705` 字节,SHA-256 为 `E252140645F3AAD14B3594FD78E76BDF1A2122F7DC70EBD21CC5DF53223C7878`;APK 反编译确认 `usesCleartextTraffic=false`、`networkSecurityConfig` 引用和系统证书信任配置均已进入安装包,本机虚拟机卸载旧实例后全新安装并在 `2.904s` 内冷启动,最新隐私弹窗真实显示,四项敏感权限未授予且 crash 缓冲区为空。
- 同一 `0.1.8` APK 随后在虚拟机完成保留数据的覆盖安装:覆盖后冷启动 `3.092s`、标准 Launcher Intent 暖启动 `0.538s`、切到系统桌面再恢复热启动 `0.120s`;三次均回到 `PandoraEntryActivity`,隐私弹窗保持可见,四项权限仍为 `granted=false`,未发现 FATAL 或 ANR。该证据只完成安装与首次弹窗前的生命周期基线,未代替用户选择隐私选项。 - 同一 `0.1.8` APK 随后在虚拟机完成保留数据的覆盖安装:覆盖后冷启动 `3.092s`、标准 Launcher Intent 暖启动 `0.538s`、切到系统桌面再恢复热启动 `0.120s`;三次均回到 `PandoraEntryActivity`,隐私弹窗保持可见,四项权限仍为 `granted=false`,未发现 FATAL 或 ANR。该证据只完成安装与首次弹窗前的生命周期基线,未代替用户选择隐私选项。
- 针对 Android 系统文件选择器“用户取消”被误判为上传或转写失败的问题,五个选择服务已统一结构化取消信号,九个页面在取消时恢复空闲态并清理残留“转写中”;真实错误仍显示稳定中文。自动化已覆盖该状态机,但真实系统选择器取消仍须人工点击确认。 - 针对 Android 系统文件选择器“用户取消”被误判为上传或转写失败的问题,五个选择服务已统一结构化取消信号,九个页面在取消时恢复空闲态并清理残留“转写中”;当前 `0.1.16` 源码已在 vivo 标准调试基座完成真实取消、重复打开和音频选择/上传/ASR。最终签名 `0.1.16` 仍须复跑同一组门禁。
- `0.1.10 (110)` DCloud 独立内测 APK 大小 `33,922,134` 字节,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`。同一虚拟机从 `0.1.9` 保留数据覆盖安装成功,Launcher 冷启动约 `1.271s`,最新原生隐私弹窗可见,四项权限仍为 `granted=false`,未发现 FATAL 或 ANR。 - `0.1.10 (110)` DCloud 独立内测 APK 大小 `33,922,134` 字节,SHA-256 为 `20CB16B4E9D024DBF5EEEF577BFBA3B4DF5E5E78B597BABB99CB7450C7AE5034`。同一虚拟机从 `0.1.9` 保留数据覆盖安装成功,Launcher 冷启动约 `1.271s`,最新原生隐私弹窗可见,四项权限仍为 `granted=false`,未发现 FATAL 或 ANR。
- 自定义基座和独立内测 APK 均使用 DCloud 云端测试证书,不是企业正式签名 APK;当前 `0.1.12` 已关闭明文流量、内置网络安全配置并取消启动原生隐私确认,但仍只可用于 8 月 1 日受控内测。公司法务签认、企业签名、登录页协议门禁和登录后权限反例仍是正式发布阻断。 - 自定义基座和独立内测 APK 均使用 DCloud 云端测试证书,不是企业正式签名 APK;最后一个签名包 `0.1.13` 已关闭明文流量、内置网络安全配置并取消启动原生隐私确认,但不包含当前系统选择器修复。`0.1.16` 云打包、公司法务签认、企业签名和最终签名包权限反例仍是正式发布阻断。
## 4. P0 工作分解 ## 4. P0 工作分解
@@ -98,7 +98,7 @@
- 首次退出成功,旧令牌再次访问时按业务码 401 拒绝;第二次固定码登录、身份读取和退出再次通过; - 首次退出成功,旧令牌再次访问时按业务码 401 拒绝;第二次固定码登录、身份读取和退出再次通过;
- 脚本只选择已存在 `app_user`,不会触发手机号自动注册,不提交反馈、训练、附件、ASR/TTS 或 Agent 业务请求。 - 脚本只选择已存在 `app_user`,不会触发手机号自动注册,不提交反馈、训练、附件、ASR/TTS 或 Agent 业务请求。
这组证据完成登录与认证只读链路,不替代 Android 真机媒体权限、系统选择器、前后台和弱网验收,也不替代本单的内容与处理人签认。 这组证据完成登录与认证只读链路;后续 vivo 真机又完成签名 `0.1.13` 的麦克风允许/生产 ASR,以及当前 `0.1.16` 源码标准调试基座的系统选择器取消、真实音频选择/上传/ASR。上述结果仍不替代 `0.1.16` 最终签名 APK、真实断网、WebRTC、图片/视频/文档、受控下载,也不替代本单的内容与处理人签认。
## 6. 最终签认 ## 6. 最终签认
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aihr-mobile-uni", "name": "aihr-mobile-uni",
"version": "0.1.13", "version": "0.1.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aihr-mobile-uni", "name": "aihr-mobile-uni",
"version": "0.1.13", "version": "0.1.16",
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020220260725001", "@dcloudio/uni-app": "3.0.0-alpha-5020220260725001",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001", "@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package", "$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile-uni", "name": "aihr-mobile-uni",
"version": "0.1.13", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -3,8 +3,8 @@
"appid" : "__UNI__B36D8BE", "appid" : "__UNI__B36D8BE",
"description" : "帮道员工端", "description" : "帮道员工端",
"vueVersion" : "3", "vueVersion" : "3",
"versionName" : "0.1.13", "versionName" : "0.1.16",
"versionCode" : "113", "versionCode" : "116",
"uniStatistics" : { "uniStatistics" : {
"enable" : false "enable" : false
}, },
@@ -137,6 +137,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import type { import type {
AsrResponse, AsrResponse,
InterviewFinishResponse, InterviewFinishResponse,
@@ -500,7 +501,7 @@ onShow(() => {
onHide(() => { onHide(() => {
speechPlayback.stop(); speechPlayback.stop();
cancelQuestionRecording(); if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
}); });
onUnload(() => { onUnload(() => {
+2 -1
View File
@@ -503,6 +503,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import ChatComposer from '@/components/chat/ChatComposer.vue'; import ChatComposer from '@/components/chat/ChatComposer.vue';
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue'; import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
import type { import type {
@@ -1851,7 +1852,7 @@ onHide(() => {
stopRealtime(); stopRealtime();
invalidateRequests(); invalidateRequests();
speechPlayback.stop(); speechPlayback.stop();
cancelRecording(); if (!isPlatformFileSelectionActive()) cancelRecording();
}); });
onUnload(() => { onUnload(() => {
+8 -5
View File
@@ -231,6 +231,7 @@
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import ChatComposer from '@/components/chat/ChatComposer.vue'; import ChatComposer from '@/components/chat/ChatComposer.vue';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { chooseKnowledgeMedia } from '@/services/knowledge'; import { chooseKnowledgeMedia } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
@@ -769,13 +770,15 @@ const goLogin = () => {
uni.navigateTo({ url: '/pages/auth/login/index' }); uni.navigateTo({ url: '/pages/auth/login/index' });
}; };
const teardown = () => { const teardown = (preservePlatformFileSelection = false) => {
organizeGeneration += 1; organizeGeneration += 1;
uploadGeneration += 1; uploadGeneration += 1;
mediaSelectionGeneration += 1;
mineGeneration += 1; mineGeneration += 1;
submitGeneration += 1; submitGeneration += 1;
cancelVoiceTranscription(); if (!preservePlatformFileSelection) {
mediaSelectionGeneration += 1;
cancelVoiceTranscription();
}
activeUploadTask?.abort(); activeUploadTask?.abort();
activeUploadTask = null; activeUploadTask = null;
speechPlayback.stop(); speechPlayback.stop();
@@ -784,7 +787,7 @@ const teardown = () => {
uploading.value = false; uploading.value = false;
submitting.value = false; submitting.value = false;
loadingMine.value = false; loadingMine.value = false;
mediaSelecting.value = false; if (!preservePlatformFileSelection) mediaSelecting.value = false;
}; };
const clearConversationState = () => { const clearConversationState = () => {
@@ -833,7 +836,7 @@ onShow(() => {
} }
}); });
onHide(teardown); onHide(() => teardown(isPlatformFileSelectionActive()));
onUnload(() => { onUnload(() => {
teardown(); teardown();
speechPlayback.destroy(); speechPlayback.destroy();
+2 -1
View File
@@ -424,6 +424,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
import type { import type {
AnswerFeedbackVerdict, AnswerFeedbackVerdict,
BroadcastKnowledgeContext, BroadcastKnowledgeContext,
@@ -1565,7 +1566,7 @@ onHide(() => {
cancelPendingBroadcastConsultation(); cancelPendingBroadcastConsultation();
speechPlayback.stop(); speechPlayback.stop();
composerRef.value?.teardown(); composerRef.value?.teardown();
cancelVoiceTranscription(); if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
cancelPendingMediaSearch(); cancelPendingMediaSearch();
}); });
+7 -23
View File
@@ -7,7 +7,7 @@ import type {
} from '@/types/api'; } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getAuth } from './auth'; import { getAuth } from './auth';
import { fileSelectionFailure } from '../utils/file-selection'; import { chooseLocalFile } from './local-file-picker';
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp']; const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
@@ -38,28 +38,12 @@ export const getCandidateInterviewRecords = () =>
timeout: 15000 timeout: 15000
}); });
export const chooseCandidateFile = () => export const chooseCandidateFile = (): Promise<CandidateSelectedFile> =>
new Promise<CandidateSelectedFile>((resolve, reject) => { chooseLocalFile({
uni.chooseFile({ label: '文件',
count: 1, mimeType: '*/*',
type: 'all', extensions: materialExtensions,
extension: materialExtensions, fallbackName: 'candidate-material'
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'candidate-material',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '文件'))
});
}); });
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') => export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
+7 -23
View File
@@ -8,32 +8,16 @@ import type {
CaseUploadResponse CaseUploadResponse
} from '@/types/api'; } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api'; import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
import { fileSelectionFailure } from '../utils/file-selection'; import { chooseLocalFile } from './local-file-picker';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg']; const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
export const chooseCaseAudio = () => export const chooseCaseAudio = (): Promise<CaseSelectedFile> =>
new Promise<CaseSelectedFile>((resolve, reject) => { chooseLocalFile({
uni.chooseFile({ label: '音频',
count: 1, mimeType: 'audio/*',
type: 'all', extensions: audioExtensions,
extension: audioExtensions, fallbackName: 'case-audio'
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到文件路径'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'case-audio',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
}); });
export const uploadCaseAudio = (file: CaseSelectedFile) => export const uploadCaseAudio = (file: CaseSelectedFile) =>
@@ -0,0 +1,12 @@
let activePlatformFileSelections = 0;
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
activePlatformFileSelections += 1;
try {
return await select();
} finally {
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
}
};
+76 -12
View File
@@ -11,6 +11,8 @@ import type {
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth'; import { getSelectedPosition } from './auth';
import { fileSelectionFailure } from '../utils/file-selection'; import { fileSelectionFailure } from '../utils/file-selection';
import { withPlatformFileSelection } from './file-selection-lifecycle';
import { isAndroidAppRuntime } from './local-file-picker';
type RawKnowledgeSnippet = KnowledgeSnippet & { type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string; title?: string;
@@ -20,7 +22,44 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']; const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']); const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
export const chooseKnowledgeMedia = () => interface AppMediaFile {
tempFilePath?: string;
path?: string;
size?: number;
fileType?: 'image' | 'video';
}
interface AppChooseMediaResult {
tempFiles?: AppMediaFile[];
}
type UniWithChooseMedia = {
chooseMedia?: (options: {
count: number;
mediaType: Array<'image' | 'video'>;
sourceType: Array<'album'>;
success: (result: AppChooseMediaResult) => void;
fail: (error: unknown) => void;
}) => void;
};
const validateKnowledgeMedia = (
path: string,
name: string,
size: number,
declaredKind?: 'image' | 'video'
): KnowledgeMediaSelectedFile => {
if (!path) throw new Error('未获取到附件文件');
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = declaredKind || (videoExtensions.has(extension) ? 'video' : 'image');
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
if (size > maxSize) {
throw new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB');
}
return { path, name, size, kind };
};
const chooseKnowledgeMediaWithFileInput = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => { new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
uni.chooseFile({ uni.chooseFile({
count: 1, count: 1,
@@ -31,24 +70,49 @@ export const chooseKnowledgeMedia = () =>
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number }; const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
const path = file.path || result.tempFilePaths?.[0] || ''; const path = file.path || result.tempFilePaths?.[0] || '';
const name = file.name || path.split('/').pop() || '现场附件'; const name = file.name || path.split('/').pop() || '现场附件';
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = videoExtensions.has(extension) ? 'video' : 'image';
const size = Number(file.size || 0); const size = Number(file.size || 0);
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024; try {
if (!path) { resolve(validateKnowledgeMedia(path, name, size));
reject(new Error('未获取到附件文件')); } catch (error) {
return; reject(error);
} }
if (size > maxSize) {
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
return;
}
resolve({ path, name, size, kind });
}, },
fail: (error) => reject(fileSelectionFailure(error, '附件')) fail: (error) => reject(fileSelectionFailure(error, '附件'))
}); });
}); });
const chooseKnowledgeMediaWithAppPicker = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
const chooseMedia = (uni as unknown as UniWithChooseMedia).chooseMedia;
if (!chooseMedia) {
reject(new Error('当前 App 版本无法选择图片或视频'));
return;
}
chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sourceType: ['album'],
success: (result) => {
const file = result.tempFiles?.[0] || {};
const path = file.tempFilePath || file.path || '';
const name = path.split('/').pop() || (file.fileType === 'video' ? '现场视频.mp4' : '现场图片.jpg');
// App chooseMedia reports the selected file size in KiB.
const size = Math.max(0, Number(file.size || 0)) * 1024;
try {
resolve(validateKnowledgeMedia(path, name, size, file.fileType));
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
export const chooseKnowledgeMedia = () =>
isAndroidAppRuntime()
? withPlatformFileSelection(chooseKnowledgeMediaWithAppPicker)
: chooseKnowledgeMediaWithFileInput();
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
...snippet, ...snippet,
docTitle: snippet.docTitle || snippet.title || '', docTitle: snippet.docTitle || snippet.title || '',
@@ -0,0 +1,277 @@
import { FileSelectionCancelledError, fileSelectionFailure } from '../utils/file-selection';
import { withPlatformFileSelection } from './file-selection-lifecycle';
export interface LocalSelectedFile {
path: string;
name: string;
size: number;
}
export interface LocalFilePickerOptions {
label: string;
mimeType?: string;
extensions?: string[];
fallbackName?: string;
}
interface AndroidBridge {
importClass: (target: unknown) => any;
invoke: (target: unknown, method: string, ...args: unknown[]) => any;
runtimeMainActivity: () => any;
}
interface AppPlusRuntime {
os?: { name?: string };
android?: AndroidBridge;
}
const runtimePlus = () =>
(globalThis as typeof globalThis & { plus?: AppPlusRuntime }).plus;
export const isAndroidAppRuntime = () => {
const runtime = runtimePlus();
return runtime?.os?.name?.toLowerCase() === 'android' && Boolean(runtime.android);
};
const normalizeExtension = (value: string) => {
const extension = value.trim().toLowerCase();
if (!extension) return '';
return extension.startsWith('.') ? extension : `.${extension}`;
};
const safeFileName = (value: string, fallback: string) => {
const normalized = value
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(-120);
return normalized || fallback;
};
const selectedExtension = (name: string) => {
const index = name.lastIndexOf('.');
return index >= 0 ? name.slice(index).toLowerCase() : '';
};
const validateExtension = (name: string, options: LocalFilePickerOptions) => {
const accepted = (options.extensions || []).map(normalizeExtension).filter(Boolean);
if (!accepted.length || !name) return;
if (!accepted.includes(selectedExtension(name))) {
throw new Error(`不支持该${options.label}格式`);
}
};
const chooseWithUni = (options: LocalFilePickerOptions) =>
new Promise<LocalSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: options.extensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<LocalSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error(`未获取到${options.label}文件`));
return;
}
const name = file.name || path.split('/').pop() || options.fallbackName || 'selected-file';
try {
validateExtension(name, options);
resolve({ path, name, size: Number(file.size || 0) });
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, options.label))
});
});
const readAndroidMetadata = (bridge: AndroidBridge, resolver: any, uri: any) => {
let cursor: any = null;
try {
cursor = resolver.query(uri, null, null, null, null);
if (!cursor) return { name: '', size: 0 };
bridge.importClass(cursor);
if (!cursor.moveToFirst()) return { name: '', size: 0 };
const nameIndex = Number(cursor.getColumnIndex('_display_name'));
const sizeIndex = Number(cursor.getColumnIndex('_size'));
return {
name: nameIndex >= 0 ? String(cursor.getString(nameIndex) || '') : '',
size: sizeIndex >= 0 ? Number(cursor.getLong(sizeIndex) || 0) : 0
};
} catch {
return { name: '', size: 0 };
} finally {
try {
cursor?.close();
} catch {
// Metadata is optional; stream copying below remains authoritative.
}
}
};
const copyAndroidUriToCache = (
bridge: AndroidBridge,
main: any,
uri: any,
options: LocalFilePickerOptions
): LocalSelectedFile => {
const resolver = main.getContentResolver();
bridge.importClass(resolver);
const metadata = readAndroidMetadata(bridge, resolver, uri);
if (metadata.name) validateExtension(metadata.name, options);
const File = bridge.importClass('java.io.File');
const FileInputStream = bridge.importClass('java.io.FileInputStream');
const FileOutputStream = bridge.importClass('java.io.FileOutputStream');
// uni.uploadFile on App reliably consumes app-scoped external cache paths.
// Files under /data/user/0/... can be opened by this activity but may be
// rejected or truncated by DCloud's native multipart uploader.
const cacheRoot = main.getExternalCacheDir() || main.getCacheDir();
bridge.importClass(cacheRoot);
const pickerDirectory = new File(cacheRoot, 'aihr-picker');
pickerDirectory.mkdirs();
const fallbackName = options.fallbackName || `selected-${options.label}`;
const name = safeFileName(metadata.name, fallbackName);
const target = new File(pickerDirectory, `${Date.now()}-${name}`);
const descriptor = resolver.openFileDescriptor(uri, 'r');
if (!descriptor) throw new Error(`无法读取所选${options.label}`);
bridge.importClass(descriptor);
const fileDescriptor = descriptor.getFileDescriptor();
bridge.importClass(fileDescriptor);
const input = new FileInputStream(fileDescriptor);
const output = new FileOutputStream(target);
bridge.importClass(input);
bridge.importClass(output);
const sourceChannel = input.getChannel();
const targetChannel = output.getChannel();
bridge.importClass(sourceChannel);
bridge.importClass(targetChannel);
let copied = 0;
try {
while (true) {
// Native.js cannot reliably marshal Java byte[] arguments. FileChannel
// keeps the SAF content copy entirely on the Java side.
const count = Number(bridge.invoke(
targetChannel,
'transferFrom',
sourceChannel,
copied,
16 * 1024 * 1024
));
if (!Number.isFinite(count) || count <= 0) break;
copied += count;
}
output.flush();
} catch {
try {
target.delete();
} catch {
// The cache directory is private and may be cleaned by the system.
}
throw new Error(`无法读取所选${options.label}`);
} finally {
try {
input.close();
} catch {
// Best-effort native stream cleanup.
}
try {
output.close();
} catch {
// Best-effort native stream cleanup.
}
try {
descriptor.close();
} catch {
// Best-effort descriptor cleanup.
}
}
const path = String(target.getAbsolutePath() || '');
if (!path || copied <= 0) {
try {
target.delete();
} catch {
// The empty cache file is harmless if the platform already removed it.
}
throw new Error(`未获取到${options.label}文件`);
}
return {
path,
name,
size: copied || metadata.size
};
};
const chooseWithAndroidDocumentPicker = (options: LocalFilePickerOptions) =>
new Promise<LocalSelectedFile>((resolve, reject) => {
const runtime = runtimePlus();
const bridge = runtime?.android;
if (!bridge) {
reject(new Error(`无法选择${options.label},请重试`));
return;
}
const main = bridge.runtimeMainActivity();
const Intent = bridge.importClass('android.content.Intent');
const Activity = bridge.importClass('android.app.Activity');
const requestCode = 0x4a17;
const previousHandler = main.onActivityResult;
let settled = false;
const restoreHandler = () => {
if (main.onActivityResult === onActivityResult) {
main.onActivityResult = previousHandler || null;
}
};
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
restoreHandler();
callback();
};
const onActivityResult = (incomingCode: number, resultCode: number, data: any) => {
if (incomingCode !== requestCode) {
if (typeof previousHandler === 'function') previousHandler(incomingCode, resultCode, data);
return;
}
if (resultCode !== Activity.RESULT_OK || !data) {
finish(() => reject(new FileSelectionCancelledError(options.label)));
return;
}
try {
bridge.importClass(data);
const uri = data.getData();
if (!uri) {
finish(() => reject(new FileSelectionCancelledError(options.label)));
return;
}
bridge.importClass(uri);
const selected = copyAndroidUriToCache(bridge, main, uri, options);
finish(() => resolve(selected));
} catch (error) {
finish(() => reject(error instanceof Error
? error
: new Error(`无法选择${options.label},请重试`)));
}
};
main.onActivityResult = onActivityResult;
try {
const intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(options.mimeType || '*/*');
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
main.startActivityForResult(intent, requestCode);
} catch (error) {
finish(() => reject(fileSelectionFailure(error, options.label)));
}
});
export const chooseLocalFile = (options: LocalFilePickerOptions) =>
isAndroidAppRuntime()
? withPlatformFileSelection(() => chooseWithAndroidDocumentPicker(options))
: chooseWithUni(options);
@@ -17,7 +17,7 @@ import type {
PersonalSpace, PersonalSpace,
ServiceMemory ServiceMemory
} from '@/types/api'; } from '@/types/api';
import { fileSelectionFailure } from '../utils/file-selection'; import { chooseLocalFile } from './local-file-picker';
import type { CaptureStatusHistory } from '@/types/api'; import type { CaptureStatusHistory } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id'; import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
@@ -140,14 +140,8 @@ export const createUrlItem = (data: { url: string; title?: string; capturedAt?:
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, timeout: 45000 }); apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, timeout: 45000 });
export interface PersonalSelectedFile { path: string; name: string; size: number; } export interface PersonalSelectedFile { path: string; name: string; size: number; }
export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resolve, reject) => { export const choosePersonalFile = (): Promise<PersonalSelectedFile> =>
uni.chooseFile({ count: 1, type: 'all', success: (result) => { chooseLocalFile({ label: '文件', mimeType: '*/*', fallbackName: 'personal-file' });
const file = (Array.isArray(result.tempFiles) ? result.tempFiles[0] : undefined) as Partial<PersonalSelectedFile> | undefined;
const path = file?.path || result.tempFilePaths?.[0] || '';
if (!path) return reject(new Error('未获取到文件路径'));
resolve({ path, name: file?.name || path.split('/').pop() || 'personal-file', size: Number(file?.size || 0) });
}, fail: (error) => reject(fileSelectionFailure(error, '文件')) });
});
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) => export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
new Promise<PersonalItemCreated>((resolve, reject) => uni.uploadFile({ new Promise<PersonalItemCreated>((resolve, reject) => uni.uploadFile({
+9 -1
View File
@@ -1,4 +1,5 @@
import type { SpeechSelectedFile } from '@/types/api'; import type { SpeechSelectedFile } from '@/types/api';
import { microphoneDeniedMessage, requestMicrophonePermission } from '../utils/microphone';
export type SpeechCapture = export type SpeechCapture =
| { kind: 'blob'; blob: Blob; filename: string } | { kind: 'blob'; blob: Blob; filename: string }
@@ -163,7 +164,14 @@ const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<Spe
* native temporary file; H5 preserves the existing MediaRecorder blob flow. * native temporary file; H5 preserves the existing MediaRecorder blob flow.
*/ */
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => { export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
if (isAppRuntime()) return startNativeCapture(handlers); if (isAppRuntime()) {
const permission = await requestMicrophonePermission();
if (permission === 'denied' || permission === 'denied-always') {
handlers.onError?.(new Error(microphoneDeniedMessage(permission)));
return null;
}
return startNativeCapture(handlers);
}
return startBrowserCapture(handlers); return startBrowserCapture(handlers);
}; };
+7 -23
View File
@@ -5,7 +5,7 @@ import type { SpeechCapture } from './speech-capture';
import { estimateMpegAudioDuration } from './mpeg-audio-duration'; import { estimateMpegAudioDuration } from './mpeg-audio-duration';
import { dataUriToBlobUrl, decodeDataAudioUri, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source'; import { dataUriToBlobUrl, decodeDataAudioUri, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source';
import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference'; import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference';
import { fileSelectionFailure } from '../utils/file-selection'; import { chooseLocalFile } from './local-file-picker';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg']; const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -26,28 +26,12 @@ export const normalizeOssId = (value: unknown): string | undefined => {
return /^\d+$/.test(text) ? text : undefined; return /^\d+$/.test(text) ? text : undefined;
}; };
export const chooseSpeechAudio = () => export const chooseSpeechAudio = (): Promise<SpeechSelectedFile> =>
new Promise<SpeechSelectedFile>((resolve, reject) => { chooseLocalFile({
uni.chooseFile({ label: '音频',
count: 1, mimeType: 'audio/*',
type: 'all', extensions: audioExtensions,
extension: audioExtensions, fallbackName: 'practice-audio'
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as Partial<SpeechSelectedFile>;
const path = file.path || result.tempFilePaths?.[0] || '';
if (!path) {
reject(new Error('未获取到音频文件'));
return;
}
resolve({
path,
name: file.name || path.split('/').pop() || 'practice-audio',
size: Number(file.size || 0)
});
},
fail: (error) => reject(fileSelectionFailure(error, '音频'))
});
}); });
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void; export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
+171
View File
@@ -13,12 +13,15 @@ const vite = await createServer({
}); });
const originalUni = globalThis.uni; const originalUni = globalThis.uni;
const originalPlus = globalThis.plus;
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
after(async () => { after(async () => {
await vite.close(); await vite.close();
if (originalUni === undefined) delete globalThis.uni; if (originalUni === undefined) delete globalThis.uni;
else globalThis.uni = originalUni; else globalThis.uni = originalUni;
if (originalPlus === undefined) delete globalThis.plus;
else globalThis.plus = originalPlus;
}); });
const [ const [
@@ -36,6 +39,7 @@ const [
vite.ssrLoadModule('/src/services/knowledge.ts'), vite.ssrLoadModule('/src/services/knowledge.ts'),
vite.ssrLoadModule('/src/services/personal-assistant.ts') vite.ssrLoadModule('/src/services/personal-assistant.ts')
]); ]);
const fileSelectionLifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
const pickers = [ const pickers = [
['音频', speech.chooseSpeechAudio], ['音频', speech.chooseSpeechAudio],
@@ -45,6 +49,93 @@ const pickers = [
['文件', personalAssistant.choosePersonalFile] ['文件', personalAssistant.choosePersonalFile]
]; ];
const androidPickerRuntime = ({ cancelled = false } = {}) => {
class FakeIntent {
static ACTION_OPEN_DOCUMENT = 'android.intent.action.OPEN_DOCUMENT';
static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE';
static FLAG_GRANT_READ_URI_PERMISSION = 1;
constructor(action) { this.action = action; }
addCategory(category) { this.category = category; }
setType(type) { this.type = type; }
addFlags(flags) { this.flags = flags; }
}
class FakeActivity {
static RESULT_OK = 1;
}
class FakeFile {
constructor(parent, name) {
this.path = `${typeof parent === 'string' ? parent : parent.path}/${name}`;
}
mkdirs() {}
delete() {}
getAbsolutePath() { return this.path; }
}
class FakeFileOutputStream {
constructor(target) { this.target = target; }
getChannel() {
return {
transferFrom(source) {
const count = source.remaining;
source.remaining = 0;
return count;
}
};
}
flush() {}
close() {}
}
class FakeFileInputStream {
constructor(fileDescriptor) { this.fileDescriptor = fileDescriptor; }
getChannel() { return { remaining: 3 }; }
close() {}
}
const cursor = {
moveToFirst: () => true,
getColumnIndex: (name) => name === '_display_name' ? 0 : 1,
getString: () => 'acceptance.mp3',
getLong: () => 3,
close() {}
};
const resolver = {
query: () => cursor,
openFileDescriptor: () => ({
getFileDescriptor: () => ({}),
close() {}
})
};
const main = {
onActivityResult: null,
getCacheDir: () => ({ path: '/cache' }),
getExternalCacheDir: () => ({ path: '/external-cache' }),
getContentResolver: () => resolver,
startActivityForResult(intent, requestCode) {
assert.equal(intent.action, FakeIntent.ACTION_OPEN_DOCUMENT);
assert.equal(intent.category, FakeIntent.CATEGORY_OPENABLE);
assert.equal(intent.type, 'audio/*');
queueMicrotask(() => this.onActivityResult(
requestCode,
cancelled ? 0 : FakeActivity.RESULT_OK,
cancelled ? null : { getData: () => ({}) }
));
}
};
const classes = {
'android.content.Intent': FakeIntent,
'android.app.Activity': FakeActivity,
'java.io.File': FakeFile,
'java.io.FileInputStream': FakeFileInputStream,
'java.io.FileOutputStream': FakeFileOutputStream
};
return {
os: { name: 'Android' },
android: {
importClass: (target) => typeof target === 'string' ? classes[target] : target,
invoke: (target, method, ...args) => target[method](...args),
runtimeMainActivity: () => main
}
};
};
test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => { test('所有文件选择器把用户取消识别为同一可静默处理的信号', async () => {
globalThis.uni = { globalThis.uni = {
chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' }) chooseFile: ({ fail }) => fail({ errMsg: 'chooseFile:fail cancel' })
@@ -81,6 +172,65 @@ test('无结构异常也不会被误判为取消', () => {
assert.equal(error.message, '无法选择附件,请重试'); assert.equal(error.message, '无法选择附件,请重试');
}); });
test('Android App 使用系统文档选择器并复制音频到应用缓存', async () => {
globalThis.plus = androidPickerRuntime();
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
const selected = await speech.chooseSpeechAudio();
assert.equal(selected.name, 'acceptance.mp3');
assert.equal(selected.size, 3);
assert.match(selected.path, /^\/external-cache\/aihr-picker\/\d+-acceptance\.mp3$/);
delete globalThis.plus;
});
test('Android 文件选择期间保持页面请求有效,并在完成后恢复生命周期状态', async () => {
let finishSelection;
const selection = fileSelectionLifecycle.withPlatformFileSelection(() =>
new Promise((resolve) => { finishSelection = resolve; })
);
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), true);
finishSelection('selected');
assert.equal(await selection, 'selected');
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
});
test('Android App 图片和视频使用系统媒体选择器', async () => {
globalThis.plus = androidPickerRuntime();
globalThis.uni = {
chooseFile: () => assert.fail('Android App 媒体不应调用不受支持的 uni.chooseFile'),
chooseMedia: ({ mediaType, sourceType, success }) => {
assert.deepEqual(mediaType, ['image', 'video']);
assert.deepEqual(sourceType, ['album']);
success({
tempFiles: [{
tempFilePath: '/cache/acceptance.mp4',
fileType: 'video',
size: 12
}]
});
}
};
const selected = await knowledge.chooseKnowledgeMedia();
assert.equal(selected.path, '/cache/acceptance.mp4');
assert.equal(selected.kind, 'video');
assert.equal(selected.size, 12 * 1024);
delete globalThis.plus;
});
test('Android 系统文档选择器取消仍返回统一取消信号', async () => {
globalThis.plus = androidPickerRuntime({ cancelled: true });
globalThis.uni = { chooseFile: () => assert.fail('Android App 不应调用不受支持的 uni.chooseFile') };
await assert.rejects(speech.chooseSpeechAudio(), (error) => {
assert.equal(fileSelection.isFileSelectionCancelled(error), true);
assert.equal(error.message, '未选择音频');
return true;
});
delete globalThis.plus;
});
test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => { test('所有使用文件选择器的页面都按取消信号恢复空闲态而不展示错误', async () => {
const pages = await Promise.all([ const pages = await Promise.all([
'../src/pages/candidate/interview/index.vue', '../src/pages/candidate/interview/index.vue',
@@ -100,3 +250,24 @@ test('所有使用文件选择器的页面都按取消信号恢复空闲态而
assert.match(pages[0], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/); assert.match(pages[0], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/); assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
}); });
test('会作废转写请求的页面在 Android 系统选择器打开时保留选择任务', async () => {
const pages = await Promise.all([
'../src/pages/candidate/interview/index.vue',
'../src/pages/user/practice/index.vue',
'../src/pages/user/sop/index.vue',
'../src/pages/user/report/index.vue'
].map(read));
for (const page of pages) assert.match(page, /isPlatformFileSelectionActive\(\)/);
assert.match(pages[0], /if \(!isPlatformFileSelectionActive\(\)\) cancelQuestionRecording\(\)/);
assert.match(pages[1], /if \(!isPlatformFileSelectionActive\(\)\) cancelRecording\(\)/);
assert.match(pages[2], /if \(!isPlatformFileSelectionActive\(\)\) cancelVoiceTranscription\(\)/);
assert.match(pages[3], /teardown\(isPlatformFileSelectionActive\(\)\)/);
});
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
const picker = await read('../src/services/local-file-picker.ts');
assert.match(picker, /transferFrom/);
assert.doesNotMatch(picker, /newObject\(\s*['"]byte\[\]/);
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { after, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
const vite = await createServer({
root: fileURLToPath(new URL('..', import.meta.url)),
configFile: false,
optimizeDeps: { noDiscovery: true },
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
after(async () => {
await vite.close();
delete globalThis.plus;
delete globalThis.uni;
});
test('App recording falls back before RecorderManager starts when microphone permission is denied', async () => {
let recorderRequested = false;
globalThis.plus = {
android: {
requestPermissions: (_permissions, success) => {
success({ deniedAlways: ['android.permission.RECORD_AUDIO'] });
}
}
};
globalThis.uni = {
getRecorderManager: () => {
recorderRequested = true;
throw new Error('RecorderManager should not start when permission is denied');
}
};
const { startSpeechCapture } = await vite.ssrLoadModule('/src/services/speech-capture.ts');
let fallbackError;
const session = await startSpeechCapture({
onCapture: () => assert.fail('denied permission must not return a recording'),
onError: (error) => {
fallbackError = error;
}
});
assert.equal(session, null);
assert.equal(recorderRequested, false);
assert.ok(fallbackError instanceof Error);
});
+1 -1
View File
@@ -114,7 +114,7 @@ test('异步请求具备 generation、上传取消和离页 teardown', async ()
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/); assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
assert.match(page, /composerRef\.value\?\.teardown\(\)/); assert.match(page, /composerRef\.value\?\.teardown\(\)/);
assert.match(page, /speechPlayback\.stop\(\)/); assert.match(page, /speechPlayback\.stop\(\)/);
assert.match(page, /onHide\(teardown\)/); assert.match(page, /onHide\(\(\) => teardown\(isPlatformFileSelectionActive\(\)\)\)/);
assert.match(page, /onUnload/); assert.match(page, /onUnload/);
}); });
+1 -1
View File
@@ -335,7 +335,7 @@ $markdown = @(
'', '',
'## Sensitive permissions', '## Sensitive permissions',
'', '',
$permissionSummary, ($permissionSummary -join [Environment]::NewLine),
'', '',
'> This script only captured the current device state. It did not make a privacy choice or alter app/package permissions.' '> This script only captured the current device state. It did not make a privacy choice or alter app/package permissions.'
) -join [Environment]::NewLine ) -join [Environment]::NewLine