From 9ea5d79a622872f4601b8f26a3e4ddc27f142586 Mon Sep 17 00:00:00 2001 From: key Date: Wed, 29 Jul 2026 00:35:15 +0800 Subject: [PATCH] release: prepare August 1 Android RC --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 4 +- .../aihr/service/AihrModelSeedService.java | 4 +- .../aihr/service/AihrSopSeedService.java | 4 +- .../AihrKnowledgeSpaceAdminServiceTest.java | 2 +- .../AihrFivePositionCatalogMigrationTest.java | 3 +- .../service/AihrPracticeSeedServiceTest.java | 9 +- .../aihr/service/AihrSopSeedServiceTest.java | 16 +- .../service/AihrUploadQueueServiceTest.java | 2 +- docs/MOBILE_APP_DEVICE_ACCEPTANCE.md | 29 +- docs/MOBILE_APP_PACKAGING.md | 70 ++- docs/README.md | 3 + .../帮道8月1日Android内测Go-No-Go-20260728.md | 51 +++ ...Android安装包测试版任务计划-20260727.docx | Bin 0 -> 59300 bytes ...1日Android安装包测试版任务计划-20260727.md | 397 ++++++++++++++++++ docs/生活顾问调研转化清单-20260728.md | 34 ++ frontend/public/legal/legal.css | 194 +++++++++ frontend/public/legal/privacy.html | 169 ++++++++ frontend/public/legal/terms.html | 109 +++++ frontend/src/views/knowledge/sop.vue | 4 +- mobile-uni/androidPrivacy.json.example | 2 +- mobile-uni/package-lock.json | 4 +- mobile-uni/package.json | 6 +- mobile-uni/scripts/app-legal-config.mjs | 116 +++++ .../scripts/configure-android-privacy.mjs | 30 ++ mobile-uni/scripts/verify-android-apk.ps1 | 266 ++++++++++++ mobile-uni/scripts/verify-app-assets.mjs | 207 ++++++++- mobile-uni/src/env.d.ts | 5 + mobile-uni/src/manifest.json | 12 +- mobile-uni/src/pages/auth/login/index.vue | 103 ++++- mobile-uni/src/pages/user/direct/index.vue | 2 +- mobile-uni/src/pages/user/sop/index.vue | 6 +- mobile-uni/src/services/legal.ts | 81 ++++ mobile-uni/tests/app-packaging.test.mjs | 130 ++++++ mobile-uni/tests/candidate-view.test.mjs | 8 +- mobile-uni/tests/mobile-user-pages.test.mjs | 28 +- .../tests/p0-test-package-scope.test.mjs | 39 ++ mobile-uni/tests/supervisor-view.test.mjs | 2 +- mobile-uni/tests/work-report.test.mjs | 5 +- scripts/prepare-aug1-rc.ps1 | 235 +++++++++++ 41 files changed, 2322 insertions(+), 71 deletions(-) create mode 100644 docs/帮道8月1日Android内测Go-No-Go-20260728.md create mode 100644 docs/帮道8月1日Android安装包测试版任务计划-20260727.docx create mode 100644 docs/帮道8月1日Android安装包测试版任务计划-20260727.md create mode 100644 docs/生活顾问调研转化清单-20260728.md create mode 100644 frontend/public/legal/legal.css create mode 100644 frontend/public/legal/privacy.html create mode 100644 frontend/public/legal/terms.html create mode 100644 mobile-uni/scripts/app-legal-config.mjs create mode 100644 mobile-uni/scripts/configure-android-privacy.mjs create mode 100644 mobile-uni/scripts/verify-android-apk.ps1 create mode 100644 mobile-uni/src/services/legal.ts create mode 100644 mobile-uni/tests/p0-test-package-scope.test.mjs create mode 100644 scripts/prepare-aug1-rc.ps1 diff --git a/.gitignore b/.gitignore index 9ed25225..4f473aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ mobile-uni/dist/ mobile-uni/unpackage/ mobile-uni/certificates/ mobile-uni/androidPrivacy.json +mobile-uni/src/androidPrivacy.json .tmp/ .worktrees/ mobile-uni/.hbuilderx/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e094d6..30b870da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- **2026-07-28 至 2026-07-29,8 月 1 日 Android 受控内测候选包与法务链路,当前总体 No-Go**:补齐登录页协议默认不勾选与法务地址失败关闭、App-Plus 原生隐私配置生成/校验、APK 包名/版本/API/签名/哈希/包内资源一致性/敏感值门禁、DCloud 云证书自定义基座和独立测试包流程;《服务协议》《隐私政策》静态页已部署到 `/legal/`,发布前整站备份为 `/opt/wygj/backups/www-20260728224710`。客户端法务校验已移除 App-Plus 不保证支持的浏览器 `URL` 构造器,测试验证码也不再以源码常量进入安装包。当前独立 APK `com.yincheng.wygj 0.1.6 (106)` 的 `targetSdkVersion=35`,SHA-256 为 `A5F026B70F11327355597B9FEA3625C4168700297E6D7847F10B079CEB860914`;vivo V2443A(Android 15)已完成覆盖安装和冷启动,本机 `Medium_Phone` Android 15/API 35 虚拟机已完成最新包干净安装和冷启动,真实显示原生隐私弹窗,四项敏感权限保持未授权且未见崩溃。移动端 191 项测试、类型检查、H5/App 构建、管理端构建、P0 后端 39 项定向测试及 `ruoyi-aihr` 696 项全量测试通过;跨平台源码契约测试已统一换行符后复跑全绿。本次评估时工作区尚未提交,生产后端未包含本批 P0 回退语义,用户隐私选择、权限反例、认证态核心业务、正式内容和完整发布预检仍未完成,故 Go/No-Go 为 **No-Go**;本次除法务静态页外未发布后端、H5 业务资源或数据库。 - **2026-07-26,阿里云百炼 ASR 生产热修复**:`POST /api/ai/asr` 新增 `dashscope/qianwen + qwen3-asr-flash` 的 `/compatible-mode/v1/chat/completions` + Base64 `input_audio` 分支,原 SiliconFlow `/audio/transcriptions` multipart 路径继续兼容,ASR 超时放宽至 30 秒。定向 Maven 测试 10 项通过;后端 JAR(SHA-256 `46c99cff75d21f8536a71c3c058b6437e99dbf9ff4bae3542b44471f3f34ad84`)已发布,生产 ASR 已切换至 `dashscope/qwen3-asr-flash`,旧 `siliconflow/TeleAI/TeleSpeechASR` 已停用。生产接口获得正确转写与受控 `ossId`,Android HBuilderX 标准基座“按住说话”请求成功,线上日志无 403/ASR 异常。回滚目录为 `/opt/wygj/backups/asr-hotfix-20260726210122`;本次未发布静态资源、未变更 schema,也不代表完整包一致性或原生 App 全项验收。 - **2026-07-26,移动端编译链升级与 uni-ui 解析修复,未发布**:本机 HBuilderX GUI 已升级为 `5.22.2026071707`,CLI 为 `5.22.2026072503-alpha`;`mobile-uni` 的 App、App-Plus、H5 与 Vite 编译包统一到对应的 `3.0.0-alpha-5020220260725001` 批次,消除 5.15/5.21 混用产生的两套 `uni-cli-shared` 状态隔离,使登录页 ``、`` 等组件重新由 `pages.json` easycom 规则正常解析。H5 与 App 均以 `Compiler version: 5.22(vue3)` 构建通过;浏览器和 vivo V2443A(Android 15)真机均确认手机号、验证码标签及输入框正常。HBuilder 5.22 标准基座 `versionName=15.22` 已自动更新并成功运行,测试手机号配合固定验证码登录成功,可进入员工首页并切换“问”页,未出现编译器/SDK 版本不匹配弹窗或组件解析告警。升级后仍须重建并安装 5.22 自定义运行基座完成原生权限全清单;`verify:app-release` 当前因缺少含正式法务 HTTPS 链接的 `androidPrivacy.json` 按预期阻断。本项未发布生产 H5,也未云打包 APK/IPA,手机上的 `com.yincheng.wygj` 正式包未被覆盖。 - **2026-07-26,全网 AI 本地库修复,未发布**:补齐 `reset-dev-db.sh` 遗漏的全网 AI 基表、数据库密钥和千问字段三项正式迁移,并增加迁移顺序及幂等回归,避免重置本地库后再次缺失 `aihr_web_search_provider`;当前本地库为 137 张表,管理端代理下的提供方列表接口验证通过。 diff --git a/README.md b/README.md index 65444fab..6177be3d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - 品牌:管理端与员工端统一显示“帮道”,继续使用现有红色品牌图标。 - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。 -- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-26 已完成一台 Android HBuilderX 标准基座的生产 ASR 真机链路,Agent 专项认证态业务回归和自定义基座完整验收仍未完成。旧 `mobile/` 只用于历史回溯。 +- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前候选包为 `0.1.6 (106)`,已通过 API 35、法务链接、包内资源一致性和敏感值门禁,并在 Android 15 真机完成覆盖安装、在本机 Android 15/API 35 虚拟机完成干净安装和冷启动。最新包已真实显示原生隐私弹窗,四项敏感权限保持未授权且未见崩溃;用户亲自执行的隐私选择、权限正反例、认证态核心业务全回归、生产依赖匹配和发布签认仍未完成,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。 - 英雄路径: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,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。2026-07-25 记录的生产快照为 3417 人、2938 人在职且均有姓名、3392 个手机号映射、175 名在职主管和 20 名项目经理;姓名通过稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游 `employee_number` 与既有主体不一致且手机号大量脱敏,修复契约前禁止强制全量同步;人数属于时点事实,发布或验收前应重新查询。 @@ -57,6 +57,8 @@ Windows 10/11 + Docker Desktop 使用 PowerShell 原生 portless,并仅用 Git | [本地运行手册](docs/DEV_SETUP.md) | Linux/macOS 与 Windows + Docker Desktop 的端口、账号、启动和基础验证 | | [原生 App 打包](docs/MOBILE_APP_PACKAGING.md) | Android/iOS 资源构建、隐私/签名边界和出包前置条件 | | [原生 App 真机验收](docs/MOBILE_APP_DEVICE_ACCEPTANCE.md) | Android/iOS 自定义基座或签名包的验收清单 | +| [8 月 1 日 Android 内测 Go/No-Go](docs/帮道8月1日Android内测Go-No-Go-20260728.md) | 当前候选 APK、自动化证据、硬阻断项和转 Go 最短路径 | +| [生活顾问调研转化清单](docs/生活顾问调研转化清单-20260728.md) | 将仓库外访谈脱敏转成待审核 SOP、知识、题目和对练候选项 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | | [数字师傅 Agent 总体设计](docs/superpowers/specs/2026-07-24-digital-master-agent-design.md) | “问”模块的最终产品边界、意图/工具/权限/确认/审计契约和当前实现差距 | diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java index 7d00c9c8..14c8d9ad 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java @@ -220,7 +220,7 @@ public class AihrModelSeedService { false, runtime.providerCode(), modelName, - "模型运行参数未配置,当前返回内置默认兜底:先安抚业主情绪,确认房号、影响范围和现场照片,再创建工单并承诺首次反馈时间。", + "模型运行参数未配置,当前返回内置默认兜底:先安抚业主情绪,确认房号、影响范围和现场照片,再指导员工到既有工单系统登记并说明首次反馈时间;当前系统不会代为创建或派发工单。", "seed-fallback", null, List.of( @@ -239,7 +239,7 @@ public class AihrModelSeedService { true, runtime.providerCode(), modelName, - "真实模型调用失败,已回退内置默认兜底:先确认事实、同步时限、再生成工单闭环。", + "真实模型调用失败,已回退内置默认兜底:先确认事实、同步时限,再指导员工到既有工单系统登记和跟进;当前系统不会代为生成或派发工单。", "openai-compatible-failed", "模型调用失败,已回退内置兜底;请检查模型供应商配置。", List.of("检查 aihr_model_provider.api_host/api_key 和 aihr_model_config.model_name 是否与供应商一致。"), diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 56b79b87..e2d0b513 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -4144,7 +4144,7 @@ public class AihrSopSeedService { result.put("投诉处理 SOP", new SopSeed( "投诉处理 SOP", "业主投诉漏水,管家第一步怎么处理?", - "先安抚业主情绪并确认影响范围;同步记录房号、报修时间、现场照片;再创建工单并明确首次反馈时限。", + "先安抚业主情绪并确认影响范围;同步记录房号、报修时间、现场照片;再指导员工到既有工单系统登记,并明确首次反馈时限。", "投诉处理 SOP v1.0 第 2、3、5 条", List.of( new DocResponse("投诉处理 SOP v1.0", "安抚、记录、派单、反馈时限", "引用 3 段", "pill-success"), @@ -4154,7 +4154,7 @@ public class AihrSopSeedService { List.of( new SnippetResponse("第 2 条:先安抚", "接到投诉后先表达理解,避免争辩责任归属。"), new SnippetResponse("第 3 条:确认事实", "记录房号、联系人、发生时间、现场照片和影响范围。"), - new SnippetResponse("第 5 条:明确反馈", "创建工单后告知首次反馈时限和责任人。") + new SnippetResponse("第 5 条:明确反馈", "在既有工单系统登记后,告知首次反馈时限和责任人。") ), List.of("题 1:漏水投诉首响时限如何表述?", "题 2:哪些事实必须在工单中记录?") )); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java index 32dd2503..7dbf7d78 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeSpaceAdminServiceTest.java @@ -98,7 +98,7 @@ class AihrKnowledgeSpaceAdminServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeSpaceAdminService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("aihr_knowledge_category")); assertTrue(code.contains("where c.tenant_id = ? and c.knowledge_id = ?")); assertTrue(code.contains("where c.tenant_id = ? and c.knowledge_id = ? and c.id = ?")); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java index 42596bb1..ace31490 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrFivePositionCatalogMigrationTest.java @@ -76,7 +76,8 @@ class AihrFivePositionCatalogMigrationTest { private static String migrationSql() throws Exception { return Files.readString(repoRoot().resolve( - "backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql")); + "backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql")) + .replace("\r\n", "\n"); } private static Path repoRoot() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 7864055f..c627a5d9 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -1219,7 +1219,8 @@ public class AihrPracticeSeedServiceTest { session.sessionId(), 0, "与当前业主话术不一致", 9002L, "employee-a" )); - String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + String serviceSource = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")) + .replace("\r\n", "\n"); String speechController = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrSpeechController.java")); assertTrue(serviceSource.contains("customerAudioOssIds")); assertTrue(serviceSource.contains("VALUES (?, ?, ?, 'customer', ?, '', ?, ?)")); @@ -1564,7 +1565,7 @@ public class AihrPracticeSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); } - String controllerSource = Files.readString(source); + String controllerSource = Files.readString(source).replace("\r\n", "\n"); assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),")); assertTrue(controllerSource.contains("@RequestParam(defaultValue = \"false\") boolean includeProfiles")); @@ -1617,7 +1618,7 @@ public class AihrPracticeSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); } - String controllerSource = Files.readString(source); + String controllerSource = Files.readString(source).replace("\r\n", "\n"); assertTrue(controllerSource.contains("@SaCheckLogin\npublic class AihrMobileController")); assertTrue(controllerSource.contains("@GetMapping(\"/onboard/tasks\")")); @@ -1648,7 +1649,7 @@ public class AihrPracticeSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"); } - String controllerSource = Files.readString(source); + String controllerSource = Files.readString(source).replace("\r\n", "\n"); assertTrue(controllerSource.contains("import cn.dev33.satoken.annotation.SaCheckLogin;")); assertTrue(controllerSource.contains("@SaCheckLogin\npublic class AihrPracticeController")); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index 22292441..75ceeb9d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -85,7 +85,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("LOWER(TRIM(COALESCE(r.category, ''))) = 'sop'")); assertTrue(code.contains("r.category LIKE '%SOP%'")); @@ -155,7 +155,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("String tenantId = tenantId();")); assertTrue(code.contains("TenantHelper.dynamic(tenantId")); @@ -169,7 +169,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("!modelService.visionAllowed()")); } @@ -181,7 +181,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("deleteUploadedOssQuietly(uploadedOss)")); assertTrue(code.contains("private void deleteUploadedOssQuietly(SysOssVo oss)")); @@ -194,7 +194,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); String method = code.substring(code.indexOf("public UnbindDocumentResponse unbindDocumentMembership"), code.indexOf("public VectorizeResponse vectorizeMissing")); @@ -284,7 +284,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("@SaCheckLogin\n @GetMapping(\"/position-sop\")")); assertFalse(code.contains("@SaIgnore\n @GetMapping(\"/position-sop\")")); @@ -501,7 +501,7 @@ public class AihrSopSeedServiceTest { .getAnnotation(org.springframework.transaction.annotation.Transactional.class) != null); Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java"); - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("TransactionSynchronizationManager.registerSynchronization")); assertTrue(code.contains("public void afterCommit()")); assertTrue(code.contains("afterCommitOrNow(() -> deleteQdrantDocNow")); @@ -515,7 +515,7 @@ public class AihrSopSeedServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); int probe = code.indexOf("private long probeDurationSeconds"); int run = code.indexOf("private int runFfmpeg", probe); String probeMethod = code.substring(probe, run); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java index 20f825c6..3945c6ab 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java @@ -69,7 +69,7 @@ class AihrUploadQueueServiceTest { if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java"); } - String code = Files.readString(source); + String code = Files.readString(source).replace("\r\n", "\n"); assertTrue(code.contains("select i.tenant_id, i.id, i.file_name")); assertTrue(code.contains("p.tenant_id = i.tenant_id")); diff --git a/docs/MOBILE_APP_DEVICE_ACCEPTANCE.md b/docs/MOBILE_APP_DEVICE_ACCEPTANCE.md index 8232959a..723ed515 100644 --- a/docs/MOBILE_APP_DEVICE_ACCEPTANCE.md +++ b/docs/MOBILE_APP_DEVICE_ACCEPTANCE.md @@ -1,22 +1,43 @@ # 原生 App 真机验收清单 -此清单用于 Android/iOS 自定义基座或签名包验收。`npm --prefix mobile-uni run verify:app-assets` 只校验静态资源和 manifest 映射,不能替代本清单。 +此清单用于 Android/iOS 自定义基座或签名包验收。`verify:app-assets` 校验源码侧原生配置和静态资源,`verify:android-apk` 校验实际 APK 元数据、签名与哈希;两者都不能替代本清单。 ## 预条件 1. 已执行 `npm --prefix mobile-uni run build:app`,并使用 HBuilderX 的**自定义运行基座**运行当前资源。 -2. Android 首次上机前,复制 `mobile-uni/androidPrivacy.json.example` 为 `mobile-uni/androidPrivacy.json`,替换两个法务确认的 HTTPS 链接;再执行 `npm --prefix mobile-uni run verify:app-release`。 +2. Android 首次上机前,在同一构建会话设置法务确认的 `VITE_APP_TERMS_URL`、`VITE_APP_PRIVACY_URL`,执行 `npm --prefix mobile-uni run configure:app-privacy`、`build:app` 和 `verify:app-release`;不得手工放入占位地址,也不得让原生弹窗与登录页使用不同地址。 3. 使用有效的移动端测试账号;不得把管理员 Web 登录态当作 App 登录验证。 4. 每台设备保留系统版本、App `versionName/versionCode`、基座/包哈希、录屏或截图和失败日志。 5. HBuilderX/CLI 编译链必须与手机调试基座兼容;启动时出现版本不匹配弹窗即视为未通过。当前版本组合和排障命令见 `MOBILE_APP_PACKAGING.md`。 ## 当前证据边界 -2026-07-26 已在 vivo V2443A(Android 15)的 HBuilderX 5.22 标准基座完成手机号登录,并通过生产 `dashscope/qwen3-asr-flash` 跑通“按住说话 → ASR → 提交”链路。该证据只证明单台 Android 标准基座和生产短音频识别,不代表自定义基座、原生隐私弹窗、拒绝权限、弱网、方言、iOS 或下列全项已通过,因此清单继续保持未勾选。 +2026-07-26 已在 vivo V2443A(Android 15)的 HBuilderX 5.22 标准基座完成手机号登录,并通过生产 `dashscope/qwen3-asr-flash` 跑通“按住说话 → ASR → 提交”链路。2026-07-28 同一机型再次由 HBuilderX CLI `5.22.2026072503-alpha` 完成标准基座编译、同步和启动;未配置正式法务地址时,登录页协议默认未勾选并明确阻止验证码与登录,点击协议入口不会打开占位地址。 + +同日已通过 DCloud 云端证书生成并安装首个自定义基座,包名 `com.yincheng.wygj`、版本 `0.1.5 (105)`,云打包、签名、USB 安装、资源同步和启动链路成立。系统读取发现首版因未显式配置而使用默认 `targetSdkVersion=28`,随后已在 manifest 固定为 35,并将设备信息与外部存储设为启动时不申请。最终 API 35 自定义基座大小为 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`,实际 APK 的包名、版本、API 35、单签名和 v2 签名均已由脚本复核。 + +新基座已在 vivo V2443A 完成保留数据的覆盖安装和系统 Launcher 冷启动。系统读取为 `minSdk=21`、`targetSdk=35`;启动后设备信息、外部存储、相机和麦克风均保持未授权,未出现启动权限弹窗。员工端首屏、状态栏和底部手势区正常渲染,未发现白屏、崩溃或 `FATAL EXCEPTION`;登录页法务门禁继续生效。 + +23:12 又生成可独立安装的非自定义基座 DCloud 云证书内测 APK `bangdao-0.1.5-dcloud-test.apk`,大小 `33,920,950` 字节,SHA-256 为 `AB634EB9A9F0939804151ED14815484FBD75DFE36CC24DF00EC23BDD762D8FD1`。APK 反编译门禁已确认包内 `androidPrivacy.json` 使用线上《服务协议》和《隐私政策》地址;同签名覆盖安装成功。只清除 `com.yincheng.wygj` 测试数据后,以系统 Launcher Activity 冷启动,原生 template 弹窗真实显示“同意并继续/暂不同意”,两个链接均在 vivo 系统浏览器打开对应 HTTPS 页面,且未发现 `FATAL EXCEPTION`。验收过程没有代替用户点击同意或拒绝;拒绝退出、同意后登录、主动录音权限、弱网、方言、iOS 和下列全项仍未通过。 + +2026-07-29 当前候选包升级为 `0.1.6 (106)`,大小 `33,921,279` 字节,SHA-256 为 `A5F026B70F11327355597B9FEA3625C4168700297E6D7847F10B079CEB860914`。191 项移动端单元测试和类型检查通过;APK 已通过 API 35、法务链接、签名、包内 `app-service.js` 与当前 App-Plus 编译产物一致性以及手机号、固定测试验证码、私钥和常见令牌扫描。该包在同一 vivo V2443A 完成保留数据的覆盖安装和 Launcher 冷启动,系统读取为 `minSdk=21`、`targetSdk=35`,相机、录音和媒体权限保持未授权,未发现 `FATAL EXCEPTION`。因为覆盖安装保留了前一版本的隐私状态,本次未重新触发首次隐私弹窗;仍须由用户亲自完成最新包的拒绝/同意反例。 + +同日,本机 Android Studio `Medium_Phone` 虚拟机完成 Android 15/API 35 启动,物理分辨率 `1080×2400`、密度 `420`。在虚拟机此前未安装本应用的前提下,`0.1.6 (106)` 首次安装成功并从系统 Launcher 冷启动,`am start -W` 记录总耗时约 `6.4s`;最新包真实显示原生《服务协议和隐私政策》弹窗,四项相机、录音、图片和视频权限均为 `granted=false`,crash 缓冲区为空。截图和 UI 层级保存在忽略目录 `output/aug1-rc/android-emulator/`;验收未代替用户点击同意或拒绝。虚拟机证据用于补充干净安装和 API 35 权限基线,不能替代真实设备的媒体、WebRTC、弱网和系统浏览器交互。 + +## 已完成的 Android 基座证据 + +- [x] 自定义基座包名、版本、API 35、单签名、v2 签名和 SHA-256 可重复校验。 +- [x] 同签名同版本覆盖安装成功,未卸载或清除 App 数据。 +- [x] 系统 Launcher 冷启动进入员工首页,无启动权限弹窗、白屏或崩溃。 +- [x] 启动后设备信息、外部存储、相机和麦克风权限均保持未授权。 +- [x] 独立 DCloud 云证书内测 APK 的包内法务配置、API 35、单签名、v2 签名和 SHA-256 可重复校验。 +- [x] 清除本 App 测试数据后首次启动先显示原生隐私弹窗,服务协议与隐私政策链接均可在系统浏览器打开。 +- [x] `0.1.6 (106)` 当前候选包完成同签名覆盖安装、冷启动和启动权限复核,包内资源与当前 App-Plus 编译产物一致。 +- [x] `0.1.6 (106)` 在本机 Android 15/API 35 虚拟机完成干净安装和冷启动,最新包原生隐私弹窗真实显示,四项敏感权限保持未授权且未见崩溃。 ## Android(最低 Android 10 + 一台当前主流机型) -- [ ] 首次启动先显示原生隐私弹窗;两个协议链接可打开,拒绝后退出,同意后才进入登录。 +- [ ] 用户实际选择“暂不同意”后退出;选择“同意并继续”后才进入登录,且升级后的协议版本策略符合预期。 - [ ] 手机号登录、短信验证码错误、登录失效重登均符合预期。 - [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。 - [ ] 原生实时对练可完成麦克风授权、实时转写与远端语音播放;WebRTC/WebView 不可用或断线时明确提示并保留录音对练。 diff --git a/docs/MOBILE_APP_PACKAGING.md b/docs/MOBILE_APP_PACKAGING.md index fea516f8..06896160 100644 --- a/docs/MOBILE_APP_PACKAGING.md +++ b/docs/MOBILE_APP_PACKAGING.md @@ -7,7 +7,8 @@ - Android applicationId 与 iOS Bundle ID 预设为 `com.yincheng.wygj`。在首次签名或注册 App ID 前,发布负责人必须确认企业实际拥有该标识;变更时 Android、iOS 必须同步修改。 - App 直接请求 `https://peilian.njzhmj.top/dev-api`。不依赖 H5 开发服务器的 `/dev-api` proxy;地址是公开配置,不得在 App 包中放入短信、模型、签名或任何服务端密钥。 - `manifest.json` 只保留不含秘密的发布配置。`.keystore`、`.p12`、`.mobileprovision` 与证书密码只能由发布负责人在 HBuilderX 云打包界面或受控 CI 秘密变量中提供,绝不能提交 Git。 -- Android 最低试点设备为 Android 10,iOS 最低试点设备为 iOS 15;每次发版提升 `versionCode`,再更新 `versionName`。 +- Android 最低试点设备为 Android 10,`manifest.json` 显式固定 `targetSdkVersion=35`,避免回落到 DCloud 默认 API 28 兼容模式;iOS 最低试点设备为 iOS 15。每次发版提升 `versionCode`,再更新 `versionName`。 +- Android 设备信息与外部存储权限固定为启动时不申请;Camera/Record 模块仅在用户进入相应功能后走运行时授权。不要为了消除系统权限声明而删除实际使用的媒体模块,也不要恢复启动即申请。 - 当前编译链与本机 HBuilderX 5.22 对齐:GUI 产品版本为 `5.22.2026071707`,`D:\HBuilderX\cli.exe version` 返回 `5.22.2026072503-alpha`;`@dcloudio/uni-app`、`@dcloudio/uni-app-plus`、`@dcloudio/uni-h5`、`@dcloudio/vite-plugin-uni` 均固定为 `3.0.0-alpha-5020220260725001`。这是 HBuilderX/uni-app 编译器版本,不是 Android `minSdkVersion`。 - 四个 DCloud 编译包必须保持同一批次;否则 npm 版 uni-ui 的 easycom 解析可能落入不同的 `uni-cli-shared` 实例,表现为 ``、`` 等组件无法解析。 @@ -21,15 +22,78 @@ npm --prefix mobile-uni run build:h5 npm --prefix mobile-uni run build:app ``` +云打包完成后必须校验实际 APK,不能只看 HBuilderX 的“打包成功”: + +```powershell +npm --prefix mobile-uni run verify:android-apk +``` + +该命令默认校验 `mobile-uni/dist/debug/android_debug.apk` 自定义基座,包括包名、源码版本、`targetSdkVersion`、调试属性、单签名、APK Signature Scheme v2 和 SHA-256。脚本使用 HBuilderX `app-safe-pack` 内的只读 APK 工具;非默认安装目录可通过 `-HBuilderHome` 指定。 + +Android 内测包或正式 RC 必须在同一个 PowerShell 会话注入本次签认的两个公开地址,再生成原生隐私配置、构建 App-Plus 资源并执行联网门禁。当前受控内测技术地址如下;正式发布前仍须由公司法务签认正文: + +```powershell +$env:VITE_APP_TERMS_URL = 'https://peilian.njzhmj.top/legal/terms.html' +$env:VITE_APP_PRIVACY_URL = 'https://peilian.njzhmj.top/legal/privacy.html' +Remove-Item Env:VITE_DEV_SMS_HINT_ENABLED -ErrorAction SilentlyContinue +Remove-Item Env:VITE_DEV_SMS_HINT_VALUE -ErrorAction SilentlyContinue +npm --prefix mobile-uni run configure:app-privacy +npm --prefix mobile-uni run build:app +npm --prefix mobile-uni run verify:app-release +``` + +`configure:app-privacy` 会同时校验 HTTPS、公网域名、两个地址互不相同,并从模板生成被 Git 忽略的 `mobile-uni/src/androidPrivacy.json`;`build:app` 再将其复制到 `dist/build/app/androidPrivacy.json`。这两个位置都纳入门禁,避免 npm/Vite CLI 项目把配置误写到工程根目录而产生假绿。`verify:app-release` 还会确认: + +- 首次与二次隐私弹窗使用同一组地址; +- 原生弹窗地址与本次 App 编译使用的 `VITE_APP_TERMS_URL`、`VITE_APP_PRIVACY_URL` 完全一致; +- 两个页面可通过公网 HTTPS 访问并包含可识别的协议正文; +- `VITE_DEV_SMS_HINT_ENABLED` 与 `VITE_DEV_SMS_HINT_VALUE` 均未进入发布环境; +- 编译产物不含手机号、固定测试验证码、私钥或常见云端/API 令牌格式。 + +登录页默认不勾选协议。协议地址缺失或不合规时,页面允许查看但禁止发送验证码和登录;测试验证码快捷提示只有开发环境同时显式设置 `VITE_DEV_SMS_HINT_ENABLED=true` 和本机 `VITE_DEV_SMS_HINT_VALUE` 才显示。源码不再内置固定验证码值。 + 1. 用 HBuilderX 打开 `mobile-uni`,先生成 Android/iOS **自定义运行基座**。标准基座不会加载当前的包名、权限、启动图或原生配置。 2. 在真实 Android 与 iPhone 上验证手机号登录、录音并转写、TTS 播放、图片/视频/文件上传、附件下载、拒绝权限、前后台切换、刘海和底部安全区。 3. Android 试点使用企业签名的正式 APK;上架渠道按渠道要求导出相应制品。iOS 使用企业 Apple Developer 账号的 Distribution 证书和 provisioning profile,先走 TestFlight。 标准基座可用于日常 JavaScript/WebView 兼容性调试,但不能替代自定义基座或签名包的权限、图标、启动图和原生配置验收。 +HBuilderX 5.22 当前实测中,自定义调试基座不支持安心打包,新应用也不能再使用公共测试证书;调试基座改用 DCloud 云端证书并通过普通云打包生成,CLI 项目的产物位于 `mobile-uni/dist/debug/android_debug.apk`。该云端证书和调试 APK 不替代企业正式签名资产。 + +自定义基座 APK 只包含原生调试运行壳,业务 `www` 资源由 HBuilderX 真机运行时另行同步,因此不能要求法务链接出现在基座 APK 内。可直接安装分发的 DCloud 云证书内测包必须使用非自定义基座打包,并用 `dcloud-test` 模式从 APK 内置的 `assets/apps/.../www/androidPrivacy.json` 复核两个地址: + +```powershell +& '.\mobile-uni\scripts\verify-android-apk.ps1' ` + -ApkPath '.\mobile-uni\dist\debug\bangdao-0.1.6-dcloud-test.apk' ` + -BuildKind dcloud-test +``` + +APK 门禁还会比较包内与当前 `dist/build/app/app-service.js` 的 SHA-256,避免用同版本号的旧包冒充当前源码,并在反编译结果中复查敏感文件、手机号、固定测试验证码、私钥和常见令牌格式。 + +完成代码评审并提交冻结后,从仓库根目录执行统一 RC 证据脚本。默认拒绝脏工作区;`-AllowDirtyRehearsal -SkipBuild` 只能用于验证脚本和 APK,生成的证据会明确标记为不可发布: + +```powershell +.\scripts\prepare-aug1-rc.ps1 ` + -ApkPath '.\mobile-uni\dist\debug\bangdao-0.1.6-dcloud-test.apk' ` + -BuildKind dcloud-test +``` + +脚本会复跑移动端测试/类型检查/H5/App 构建、管理端构建、后端定向测试和打包,验证 APK 后在被 Git 忽略的 `output/aug1-rc/` 生成标准文件名、哈希及 JSON/Markdown 证据。它不执行生产同步、数据库迁移、服务重启或用户隐私选择。 + +2026-07-28 最终自定义基座大小为 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`。2026-07-29 当前独立内测候选包为 `bangdao-0.1.6-dcloud-test.apk`:包名 `com.yincheng.wygj`、版本 `0.1.6 (106)`、`targetSdkVersion=35`,大小 `33,921,279` 字节,SHA-256 为 `A5F026B70F11327355597B9FEA3625C4168700297E6D7847F10B079CEB860914`,签名证书 SHA-256 为 `AB06ECC0DE544DE73C385F3F0690B22966EBC12887FB8BEE1830CDBEFDEBEF5D`。APK 反编译门禁已确认包内业务资源等于当前 App-Plus 编译产物,法务配置和敏感值扫描通过;同签名覆盖安装和冷启动成功,启动后敏感权限保持未授权且未见 `FATAL EXCEPTION`。前一候选包的干净启动已显示原生 template 隐私弹窗,两个链接均通过系统浏览器打开线上正文。该包使用 DCloud 云端证书,且 DCloud 运行壳仍声明允许明文流量,只能作为 8 月 1 日受控内测包;企业正式 release 门禁仍会拒绝明文流量和非企业签名。 + +企业正式签名 APK 还必须用发布负责人保管的证书摘要执行 release 模式核验;该模式会额外拒绝 `debuggable=true`、明文 HTTP 和签名摘要不一致: + +```powershell +& '.\mobile-uni\scripts\verify-android-apk.ps1' ` + -ApkPath '<企业签名 RC APK 绝对路径>' ` + -BuildKind release ` + -ExpectedSignerSha256 '<已签认的企业证书 SHA-256>' +``` + ## 发布前尚需提供的资产 -- 经法务确认并已可公开访问的《服务协议》《隐私政策》;Android 必须用该实际链接配置原生 template 隐私弹窗,iOS 需提供相应的麦克风、相机、相册/文件用途说明。 +- 当前受控内测版《服务协议》《隐私政策》已部署到 `https://peilian.njzhmj.top/legal/`,服务器发布前备份为 `/opt/wygj/backups/www-20260728224710`;仍需公司法务对正文、处理者信息、第三方清单和保存期限正式签认。Android 已用实际链接配置原生 template 隐私弹窗,iOS 仍需在后续出包时复核相应的麦克风、相机、相册/文件用途说明。 - 无透明通道的 1024×1024 App Store 图标,以及 Android 自适应图标/启动图。 - 企业持有的 Android 签名证书、Apple Team、Bundle ID 注册与 iOS 发布证书。上述资产缺失时可以完成代码与真机调试,不能声称已具备商店上架条件。 @@ -37,7 +101,7 @@ npm --prefix mobile-uni run build:app - 图标和启动图由 `scripts/generate-app-assets.sh` 从现有 `src/static/brand-logo.png` 生成,输出位于 `src/static/app/`,并由 `manifest.json` 显式关联。iOS App Store 图标为 1024×1024、无 alpha、未预制圆角;生成后可用 `npm --prefix mobile-uni run verify:app-assets` 校验尺寸和映射。 - 现有品牌源图仅为 64×64,因此这里生成的是**可用于自定义基座和测试包的派生资产**。正式提交商店前应替换为品牌方提供的高分辨率母版,再重新执行生成和预检。 -- `androidPrivacy.json.example` 是**未启用的法务模板**,其中 `__TERMS_URL__` 与 `__PRIVACY_URL__` 必须替换为法务确认的 HTTPS 地址。复制为被 Git 忽略的 `androidPrivacy.json` 后,`npm --prefix mobile-uni run verify:app-release` 才会通过。 +- `androidPrivacy.json.example` 是法务配置模板,其中 `__TERMS_URL__` 与 `__PRIVACY_URL__` 只能由生成器替换。不要手工复制或改写;按“本地验证与出包”设置两个 `VITE_APP_*` 环境变量并执行 `configure:app-privacy`,确保原生弹窗、登录页和发布门禁使用同一组地址。生成文件被 Git 忽略,模板和校验逻辑才是版本化事实源。 - iOS 已按实际功能填写麦克风和照片库用途说明;不申请相机、定位、通讯录、蓝牙或广告追踪权限。 - `build:app` 依赖 `@dcloudio/uni-app-plus`,它必须与 `@dcloudio/uni-app`、`@dcloudio/uni-h5`、`@dcloudio/vite-plugin-uni` 同版本。缺少 App 编译器时命令**不报错**,只静默产出带 `/h5/` 基路径的 H5 空壳,产物里没有 `app-service.js` 与 `app-renderjs.js`,App 平台代码等于从未编译。判定 `build:app` 是否真的成功要看产物中是否存在 `app-service.js`、`app-renderjs.js`、`manifest.json`,不能只看 `DONE Build complete.`。 - App-Plus 实时对练通过 `renderjs` 在系统 WebView 中执行媒体/WebRTC;认证 SDP 与工具请求仍由逻辑层代理。发布前必须在 Android/iOS 真机验证麦克风授权、实时转写、远端语音播放、前后台切换和断线降级。 diff --git a/docs/README.md b/docs/README.md index b5a40f77..e183022d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ | [DEV_SETUP.md](DEV_SETUP.md) | Linux/macOS 与 Windows + Docker Desktop 的本地端口、账号、启动步骤、基础链路验证和静态发布 | | [MOBILE_APP_PACKAGING.md](MOBILE_APP_PACKAGING.md) | `mobile-uni` Android/iOS 资源构建、隐私/签名边界和出包前置条件 | | [MOBILE_APP_DEVICE_ACCEPTANCE.md](MOBILE_APP_DEVICE_ACCEPTANCE.md) | Android/iOS 自定义基座或签名包的真机验收清单 | +| [帮道8月1日Android内测Go-No-Go-20260728.md](帮道8月1日Android内测Go-No-Go-20260728.md) | 更新至 2026-07-29 的当前候选 APK、版本冻结、生产依赖、真机与内容门禁 Go/No-Go 结论 | +| [生活顾问调研转化清单-20260728.md](生活顾问调研转化清单-20260728.md) | 将仓库外原始访谈脱敏映射为待业务审核的 SOP、知识、题目和对练候选池 | | [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 | | [FIGMA_DESIGN_SYSTEM_GUIDE.md](FIGMA_DESIGN_SYSTEM_GUIDE.md) | 员工端通用视觉元素、业务元素、状态映射和 LLM 组页规则 | | [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) | 70 张正式画板与代码/线上实现状态、二期边界和剩余动作 | @@ -52,6 +54,7 @@ | [visual-evidence/20260721/](visual-evidence/20260721/) | 工作助手项目选择、确认卡、员工今日成果和主管项目成果的 390×844 本地运行截图证据 | | [presentations/数字师傅工作助手迭代汇报-20260721.pptx](presentations/数字师傅工作助手迭代汇报-20260721.pptx) | 工作助手、多项目、今日成果、权限与发布边界的 5 页内部汇报稿 | | [AI陪练完整交付计划-20260724.md](AI陪练完整交付计划-20260724.md) | 当前阶段一“练”模块的唯一后续施工范围:住宅物业 × 五岗位 × 三级成长;定义现有基线、收口动作、验收门槛与明确不做项 | +| [帮道8月1日Android安装包测试版任务计划-20260727.md](帮道8月1日Android安装包测试版任务计划-20260727.md)([Word 同步版](帮道8月1日Android安装包测试版任务计划-20260727.docx)) | 2026-08-01 Android 受控内测 APK 的冻结范围、P0 任务、倒排日程、真机门禁、Go/No-Go 与后续边界;Markdown 为事实源 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 2026-07-11 历史推进记录;保留 M0-M5 的工程追溯,不再作为当前施工范围 | | [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 | | [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | diff --git a/docs/帮道8月1日Android内测Go-No-Go-20260728.md b/docs/帮道8月1日Android内测Go-No-Go-20260728.md new file mode 100644 index 00000000..34c165d7 --- /dev/null +++ b/docs/帮道8月1日Android内测Go-No-Go-20260728.md @@ -0,0 +1,51 @@ +# 帮道 8 月 1 日 Android 内测 Go/No-Go + +> 评估时间:2026-07-29 00:19(CST) +> +> 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。 +> +> 冻结范围与完整门禁见[《帮道 8 月 1 日 Android 安装包测试版任务计划》](帮道8月1日Android安装包测试版任务计划-20260727.md)。 + +## 结论 + +**当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。** + +安装包的技术出包链已经成立,可继续作为验收候选包使用;阻断点集中在版本冻结、生产依赖和人工业务验收,而不是 HBuilderX 或 DCloud 打包本身。 + +## 已通过 + +| 检查项 | 2026-07-28 结果 | +|---|---| +| 仓库同步 | 本地 `main` 与 `origin/main` 均为 `9ae5f750`,前后差异 `0/0` | +| 移动端自动化 | 191 项单元测试通过;TypeScript 类型检查通过 | +| 构建 | `mobile-uni` H5、App-Plus、管理端生产构建通过;后端 `ruoyi-admin.jar` 重建通过 | +| P0 后端语义 | `AihrSopSeedServiceTest` 39 项通过;`ruoyi-aihr` 696 项全量测试通过,0 失败、0 错误 | +| App 资源门禁 | 正式法务 URL、原生隐私配置、API 35、图标和启动图检查通过 | +| 独立 APK | `com.yincheng.wygj`,`0.1.6 (106)`,`targetSdkVersion=35`,v2 签名;SHA-256 `A5F026B70F11327355597B9FEA3625C4168700297E6D7847F10B079CEB860914` | +| APK 内容门禁 | 包内业务资源等于当前 App-Plus 编译产物;法务链接、手机号、固定测试验证码、私钥和常见令牌扫描通过 | +| 真机安装启动 | vivo V2443A(Android 15)完成 `0.1.6` 覆盖安装和冷启动;相机、录音、媒体权限保持未授权,未见 `FATAL EXCEPTION` | +| 虚拟机干净安装 | 本机 `Medium_Phone` Android 15/API 35 完成 `0.1.6` 首次安装和冷启动;最新包真实显示原生隐私弹窗,四项敏感权限未授权,crash 缓冲区为空 | +| 首次隐私弹窗 | 最新包已在虚拟机干净启动显示原生弹窗;前一候选包的两个 HTTPS 链接已在 vivo 系统浏览器打开,仍需用户亲自执行拒绝/同意反例 | +| 法务静态页 | `https://peilian.njzhmj.top/legal/terms.html` 与 `https://peilian.njzhmj.top/legal/privacy.html` 已部署并可访问;发布前站点备份为 `/opt/wygj/backups/www-20260728224710` | + +## No-Go 阻断项 + +1. **评估时没有冻结版本。** 评估时工作区存在未提交源码、测试、法务页面和打包脚本变更;只有完成本批 release commit 并在干净提交上生成 `releaseEligible=true` 证据后,本项才可消除。 +2. **生产依赖未形成同一版本。** “系统不会自动创建或派发工单”等 P0 修复仍在本地工作区,未发布后端;不能把本地测试通过外推为生产 APK 调用链通过。此次仅上线法务静态页,未发布后端、H5 业务资源或数据库迁移。 +3. **原生人工反例未完成。** 覆盖安装和冷启动已通过;仍缺用户亲自执行的同意/拒绝、拒绝后退出、同意后登录、短信失败与重新登录、麦克风允许/拒绝、媒体取消、前后台切换和弱网验证。 +4. **核心业务真机闭环未完成。** 仍缺认证账号下完整“查/练/问”、五个直通渠道、五个正式对练场景、ASR/TTS、附件上传下载和越权反例。 +5. **内容与业务验收不足。** 当前仅收到 1 份生活顾问访谈,距离计划中的 24 份访谈、30 道人工审核政策题、5 个正式场景及五渠道真实处理人配置均无完成证据。 +6. **发布与回滚未签认。** 尚无四个发布单元的版本矩阵、负责人签字、完整预检结果和回滚演练记录。当前 APK 使用 DCloud 测试证书且运行壳允许明文流量,只能用于受控内测;企业签名和公司法务正式签认仍是正式发布门槛。 + +## 转为 Go 的最短路径 + +| 顺序 | 必须完成 | 验收证据 | +|---|---|---| +| 1 | 审核当前工作区,确定 APK、后端、管理端/H5、数据库四个发布单元,提交并冻结 release commit | commit、变更清单、四单元版本矩阵 | +| 2 | 按授权发布实际依赖的后端/静态/schema,执行完整只读 `release-preflight` | 本地与远端静态、`ruoyi-aihr` 模块和 schema 同时通过 | +| 3 | 用户在已连接 Android 手机上完成隐私选择和权限正反例 | 设备、包版本、步骤、结果;不记录手机号、验证码和个人内容 | +| 4 | 用正式测试账号完成“查/练/问”、五直通渠道及附件/语音闭环 | 每条用例的时间、结果、失败截图或日志 | +| 5 | 业务方签认至少 30 道政策题、5 个来源明确的正式场景和五渠道处理人 | 审核单、内容版本、授权矩阵 | +| 6 | 7 月 31 日重新出 RC,复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 | + +公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略隐私选择、权限反例、版本冻结、生产依赖匹配和核心业务闭环。 diff --git a/docs/帮道8月1日Android安装包测试版任务计划-20260727.docx b/docs/帮道8月1日Android安装包测试版任务计划-20260727.docx new file mode 100644 index 0000000000000000000000000000000000000000..1a853401258231161369ac6d3298e58b22ace530 GIT binary patch literal 59300 zcmZ^KQ+Ou97Hw?Xwr$(CZQGeRlT2(+Y}>YNClm9Jd2`Nvx#!`&bbsAncRy6E+O-#2 zNfs0g4G0Jb3P=;|Q;z|2AE*o%2xtNl2nhA(uCAz~gPXa7o1vPwlew!tgO|NsbBes< zq7YK}%?HdRCSiOxHDst#Ho;%Kd=z1)?aSCh`)66+q?Xs`DV~@xnJCo-JrZu#n|Hpi z#FI3{84S<(Ij~&~%;_qczRJHbhONtS$7ye(>O|7D#qb0hHn3Xy3_DY4n^0O#Yo@wS zVt0i|Wj;YaNF78VsN0Of7FiR-!d%iv9 z=a^ax7Mv*CjMGZDF7hP^>Ndj}7}32N)kv@TkIt)K4yQbYLXgeQc-A8eTadru{#-}k zadG|vS5>eO?V-YP!#5n!Anu>se}7M2ltj4^T`*bVe{^%jbwww-kKVUqRmf%wSSU2T zSE;!S%zjHA)AA1$Ba$i=^ngTjxg+a^ET0|NMOVE49H}O9K6->7IhTe8BiR{T^<3jr zq@7zC;06odA-xlXf7H~6jEZG;);h5RXnG_{e252c-<&D#D6#(TidcSb+&&hZcqTD< zL1PR6Rr;Ta^`?*&dij~q9S|TO*q^hZi@BXEBg21=)rr$Gpv*|WZUn@~$V(ks)WwR{ z4a836iv;@8r>;vJ0;Ebddb*Xx^tCYh2~KVfO)XZk3USwHOYA~4O|+MT^H=+|cB${0 zIt{m=QlYzoh`rPfLbdHOqaR5Tgv=?PIV?1Y1Za0qhjpnNujhqpM8phf?4=OOLd%no zA7I_6G~NB_7abuSCFEMzqe`pWkL*07UIbg*q>O$EoyR*Nt7a&eF=KKSAxJx;K8Q-6 z%*Iy4w`XH?ymcyB=~mt`oQ8-hsv3BaJwQx%p$u_ZHu06tw;f9UqbDPs*7TXNaNh}0 z4s@C&xMZqNyU}TmTl0q+pV#HM`QHKBr^O1q_(8riC=d|x&j6V@nkudINa2{(RAOQ8j|c|Ymeq^>1gRZk=T&c~pL7qWtE zQ-iwFP>}lj#ands1)E(ZAKif}dEvi9Y1n(>_7q_qJR@YyAijE+l^Y_YG~O23IkUJE zwCNGgA;y#_1*}gAd37o2$-@R)u6C<64wt?aimbNQTxuLAzLA8eiWewzgRwx zpc~Alpbz#*ulCC4YXq?G^7U2WrRwwVu=4oGWU}s>7GDjRS*4mqUon8OM-H=b6}`14 zC5u1Ej|hFS?cC&V`9VfKBUzO2FA@kWo(bjDtR6ImBsVSUmVT8aBzuG_Ri1x+*C(BZ z+u{=p;Feu}F$spqkM)fkSMn-Zeb;1N>w6vdRgZhdH-C*Sk6QHvokBDit-`*x=Ku!D z(BEgfP>brnzBy{Z)dOsf>($!v33gs(3wr+-84sJAnEyeB9T*T0(*KJLV<)Ho0z+-m zVUr1|>zx)%-z~}-Qdk))F?dCIUUVC$E-|}xX@W#10j!<*Ai)1M5}iAo@$olrBkz0D z#QUnS1!R+gl{)(5Yr&Y3^EOO#ZPe($&FK>oSFj53a&(PhWPNAD*@^092zqF@3{9n) z315;%lc_rbNGDfC+F7>^1fiWK-;z!RC%?H5b0P6dkNTIP+Z3hdwViZjCdH^q(9c?{ zO7F5+es;;j$8+t}6nF#)5aUl)uu1PqYpV4~2~2q+TxO&0+bJtB8(3er#|E=A6q(T=2bWZn+a4$nJN0_EXdqM$oj}lImIoDL1K$uZY7mY-!4dt4V_c2%Ty;{Pf zf4SK~KSinjj4zO;*cdzZKOpC61#LOUw>>qM^Ts2{J|Td;@rtZ{V2IIdh(hY%$0xO9 zpa#}YI07y?x!{tj??JVIqwbI1|K-%7CJR~Pt#=@k`OnM?h}TcnRmvPNR1x#nj8KxL z;=h{^;?kN@Uu&M6h_34BE9l0c=dt4;dywyk1+)VnJ5qut+wx;6NqXoU5I$Ssd?GR2 z*)yN)y}I+mo_j)Y1++Cn6Qo^1+kKAznIs5mv)fHe5t!E8CF70A##HyUQ%p!3+LRV7 zR+Q}S%3}OrB)EmndIjKqFF#%q{x2{x4LB@kRe^x~S3v$-GCUn!%ou;_hP(aGj`<&C zTy^WJ;%YZAegz1Ki%VXnyB_RHh-~qCyn~IjU9ny}#9d`{KdVAQtXtraei3aP0?4r4 zfXTo(JfTC{aCK4EutFJ5jeDfyRw@=$1H+QqUCNQLZoxsxZ+z=N?TPzCL>=nL0dywr8&r2GeXczrxG&eOlK8_R_>}mvoHXBPv!`F_Sw*TfGKtZfjc%K~d*S#w z{D_;KPiIfq6lplN)Uhcy%Di}Y{H*QukomfyjIuJ8743xx(5B^ ze%Vt?zbSgWkLiP)WR~5-SyFjwXTt2*7C|>&pU$V+m4J@Q?b8qSUJR&U#r9Oa!Uf3- z?(OL<@3jSR+*9QXPfh|io(|n>_da~!Vf3RhM}`o$juR{2;K0tIz}AjCMMX_n+U%;n zRu3&XuHuT7H~235_M4_bj;bc@-g><+&*%VwWtTtbl>nLN7s0ArCg8w6Al*oXRC3_!{910b+{ ztF;P-pT28_<8A4+e!%Bfoc*9K29Ei7&%jY;$H5F`L<%8ZY93x%fD@J9&ZyrGDfD62 zD)IN&Zs)4==xfoSL04Yqn2VrWYwLW>!0I=!A$Oh|uxj&XAvhtHwF`JFT88tM%_4 zoev>D`W<(k8bjdE>fU$UZq(8!-Uba$okzm%`E`ls*sT3NQuH{h22Zz-H4!yuxB31@ zNqpbgeK#J094DYP^zE6Xfa!pRX)T1EnBzU0{f)gz$Xm=vQbtwB6h)uC!w#IBMcEm* zg<`V7<`y@e8@C{PqBz=nW7N>m8LDT+{E&wE;Qsamr5|4h6v=N%1H$@!y7%M{CjRJ2_xRk^irQrZTPjAM zbKYAhZmY_gikg~tCiB3`Dl4wsf>eJ2j`r7R=f>2v1JyzEkx>f6Y;HrQZ;t5MU-BcQ zuI3~75NR$PmDNv)&DV+5Gs*5HRjlUQ!+wP~OA-{;5( z4a^PPu2M#H13AL}QiI78d)@i%LWg8JHP}y}T;fp2Pdevp)uoURWETOwpfk{HVCr2$-*fQ7k0)J`5in-!gCz59 z<=c7AMHR6$zn&fPj`^+Rax#=sBOro=$5(tIBy!r(4Q@+8!x$_qquJdcV!YF8V;4MK zyrgywUlHqXriQpt3g}|yWZQb zm-s|Jiz^MpOOv63z<0VH^|m1H&-m(R@a#;Nm5>8Pm#RwMo;@2Oe99lR3BaTgO0J)m8izzLNeP+srnf-k_>rp(~1siP}K zU20FSv@9Z!ViIDkm&%oDx2M^TY;V?{O-(HB#Uk|~T-Eon`DzFD2g*xt91)mh3C?I&&GOYo;zOV_?fL&$P)d#juI)R57{7Tk0le{F)>da>{# zNL}yD+1Nt`jI4MB(8?HbL*^T{9EMdZM-&YueVKeP;Kc<(i!(GAS|uM_#SfSshQ5NU zs~|sM$sEZ(JGk-$?ztU1>w>vfiPhC=K6fYFCQWYD94(m~-4zsro>LWLR@nE#JpiyE zwV*&dAT4WR8aL1Z;(QMGpZ1^$6wi~k(5tL2r&l}-E;jv($V2@TiIhXaf_(&IU>LZN za8}IWn|RtjuRxw~ZrsfQf%aX+KD7H<#ES9@cwJMfQwUhGIjDc%7VE^-2;uGLb@rBh z)?wQpSynsm#NM@kjBl_tz)vPpr(yT{F$K&EUKg`tF-9~!GF0ECcN|^Hhsy3lus8HZ z=x7$DK{rZZMH0Vkik$lmPT_bIw zf8;451V0-5n^ZQbCXtt|LSw-p%NXEp8kQk(6FOdK-2()5N=E}j^x(wG+H>=OGrk;o z7`w>o=ZR70>V@Y3qd+`R+6TK(5Hf{l>L}Y(Ys4c6Fa6d%6LE`^?)}^Iu3h`LH z#o3&;uKe_)mzmkkkEDh|+cP)vI68P?h{UBy0Gq5ql5d=g!hnz81$zThuyo{1rwu`& zs0W=9$U$)Ik4}qX=bA+|rSb@mFjCgGO1OFq<3E>MmlyS(^%3H0Lb!mq zloz4N%LaZ&#)#>S^vmsiSG_6p^})If?#6C{N&817_8nl%>P>R)mT5ju0~|{yraJYl zG8v?05HPzvPPus6qvcprQAglo}~krLL8+2ijTWr$shgib_`x3r8MmBnT+1{5#C~>+{#* z5|j_lQ4u=ETeG@g4O}}5zX$IveL4m?GUP^~nT#nB93Iviu`}+l%OeYgC3Cir1hZEA z>%&tJQZO7r=t5&lIiYx{WLgAtoDACkS1lKL0CiI^J!SoC1TEv*xo7X=y0QoX!! zNL;XTc!czGA>ouD8;(#jy1Z})E!>~p*ok7GcW>&Kb($%Ud0C^%GPSW8HAsWh%6(V2 zQ&mj}A7r>FZe=BeCqy5WJQQy?wVcC&BK7fba@^ntOmUh%>ogoyv-;3?Ca3`*G(rfW z-;D!|Fbru_j8uk+eXhTLkvg_5p-OxP&H;wGm_7vCAa)Zy|A43|1xKo_m?1&1waadt zv)b>$^CyiFdP`L7-n~J5xbcnehmbCmw`(-PUi>UYtySMSY4}_yXkkk0Q4LYPv_gs|!E-MJXx3Wo?J0 z`chZ}&z%h#x|laAm9#Wv>bC9aagt^#XwZLHrKZ`e<%2hZ1;52auX!Wb_KQ z!C=M1x`v`J$R}QrR6M!+4WFuDeb?<;;lLB9`I>NO;2ZI&;%M2a%zvE2=||TO>HuSA z%tMm_fgDzjwCdK+4s;K#M-?S$+_2R+nCjQtN%HzYl(!_hgmFzctTv-36VstZQuw+$ zsm*8HL$e)$C3Kj2(^YOa%BSBf_%FlL@V)Q1$Apy)T4BPjz-MX zZzmI`UQRWYhuGmdH=t$0AmDM45J(%VC~$_DRtG9|b{HP9yNr)(AZ0y@oj#SjxU$;t zIln2N{@@`Hq5Y4v9kk}j6pZa;N4m<20u}B#Z4z7KkM~cAKr@IxLpn!zw|DS$$!4#A zrJ_;=z$J@Yh4fk4_M?skkNAI018D?NnF9cr$bWi#G?p_0f=P_qHE&zAFni0yS7q=8 z->MI@p;4i(Y{5htmsckc8bAp(rNM><2{o71ufFFZ?&gP#sSqwZ99%t~H=@&CLJ`J4 z-#-rU6UCv%RDh&RGCe|Do0c^&cek#^-NsW5N)MS}318X)H}xND3PLoQd?voS1c4%+ z#{#bDws)_##H8vqQ{`!*4l=IX3XOOT#Lh?PYx1MDk;ivw4xc-ib|*bDC-zzX3_xN- z#m5rGF?8_6Y@yyZN0ZVlg(%-2IWoE_%~xOD=PitssefcPqb)8?3N|P0!p-4=Fai)4GYNGz1E=m<%x9>}Sywcr%YB|YCmo)jj1hd7p( zFo_*Sqr3kF=*KLpsajT-EIB2TdmyeElEiCh-Lk|P)-O}N9`S_PeUE{`%>OJgkl2#c z(^*RnDOY9IqVit2=Bi{q+}pdC&RRnueYWO{-s+0_jE3{7ak-qwSGRaY^80dPj6mmt zG^>dm*jt~z@;e3A*@hu8qApNXkR0~y%AV@#if_`x+M0^8&(=bKK?&1a0a_Zu=i0_i z4HSk+V+=Ndc4=bwPYcY4w%~%RwZPM6A4Nqb|Hgu0|BIUOgpooWbL+jyw-6k=*y!o#ithY*2$e^^c!f^VKBciF z@>V!?7F<)Jq~y6VBC%eKmRxXUexhy2=nZZ4ijV40?&D7F*NTR%`q+jNos4LRIs{mq zTw}$6g-^waiV5*v5loUZJ4eaP1EvN=6M8F#K7*NLov0Cx~2`x8Y7$N);XiE%u2vwq2 z9F9F)50}4H?#G!i;)4BWCBlVukX#-SAa=eFx_Zum};N-0z z$Sb3fk_1~qp^#Jui-Atq^kSI6mYQ4X1VUaLk{U8pIQH}6(xa?-18DxeDqZzq2!bq& zUqv6gIu8N)VGvPuk?uC?)!9ZT7ZN`lcc~P!$g?E?xm9j3E`5Fq~L4R&-n}yf(NN>?%@UQ(f{sEWS z*B{cB{TIU<-XZj!@X6N`uex_52WEy2mcC(+k7O$r!KNOaCVqHneW9eqy~n-2r0%mK z>`GtjhPQhAN?#$2vEyY6ddgfzPxS!D*VMy_zgS zs!cSN!|w+MEW>#TT>DB1;br|qSitdtYMI6`f~qemo=4}LzMk)&l)KGJ=$pl`{ivG9R@wfAH7L!U-53abX%0=t!*AA9`4$;#%FQmEA zrJ)jvFP>Am!fe1eTOh%dC285<0#{%Q6L#r0^h4ya!QGw$aR=?leC>ZJKhkld_eM>RQ0 z2_7d(=^RXBf^n0#i$9w$vZd?wW7fg#<)~COAZVEp=+shX2r(g3hxR3lDh|#_#MTa; zGG4-1T+XqiqK>#aefeyb{r!(@XPC zo<445OfyYs;(`N!eAz*h$E@?gB3m4Cd5VvUixm$Yf)!;t4j z?bI+-y|!I)1yI=RK1jYN7ExB@x0d&`V@UI>mj8~yec*S@-rVRv7Zd56VI?G@%l2AU(J+6FR znP#eAiVw7axyTN)x213s3<6!VjChg?rQrkV7AD&o z0SNVdAadypDB=ig{(Q?HmGDv!hAt!VW(rg+UwW#*hu`S795jIX+$7p>pj6jhMpagK z6E)1+AOuXcX1Ck-BKF8H*{}d9%iLqxy^1Wwd;63Z$&c)wj7W=k;Uajtdb!$9AM6C#Dfg|j5gxDH7qQhN`xH2Dw^P-sFuT>%V#s$&jt;y z|Kl1)+DiE%=9q=oactlt?SgvKf6$MiVwqA&PM{f5qVO_416x^hn=CgBPk&$_fWGMgP;(4mbhqY-3b8yArmNa(9h zg$Hyj;79y>t4`VNo<-X_sW*Q`DIUG4(Pldp8pHnlYj$zfW#~WaQyPR%qJ} z`H;XT*Jny8>y)mB<692Y2T{%GAQ)~S zpF&Nk{gwCs-jCbIN4^W#OaDTep@Dz`B_lyj=EEtC#Fx_1#^eGdx0CwtA)5Nue0t@_ zw;5}%(J?4=&5B%@4UJowPzqt=CY~SfcuF zT+jjZGGuCvrh@$i2hU1ka`ICE^z)j4aZJ%z-sB;dh}WNQVPT z-6_#L6giovMZuxD`(xMO9V=x7h8)HBXM8$c>^--gRiSUq%Nq zyD1zzknH2o^KVzDO48?CJzw$9MD;%FDcPP#atr)LV!os`LYjyDe*FYZl?2vMSHE8bnRw)-jxYe>NlSpc?3iP0PWYPx z69V^it}do_reQtqlZx*^WijCRbdPAw14`J;&Vw<5(q70zHtW;i1~l4k`&+!LvaJl_ zEACbfbnR~&G7DFNeaf!J%2J^2x{|pZ4-FkhIlKV4%S4UO@QZMg&*pMP1*w;ht&`kzGtWhs z??oEor1+rn3mK43h>)LO@|`e3SL~*-T>8G$|bYtsg z&DMWRRWr?iMt^q=N8;~Z6od|vnP8e?`8^dc?Z>FZ_nN@3EH22wZo>H!C8#7$4K5i=8-U-~ zUyv(4I`_^$Pc)Bo#&D+m`?j;`aep3}71U=#?6OQxNwDhiquLH~7r!y*lSv|D;bm@@ zheo0k;i=%jky632LOmvZeW+XTdW>WMOUDkNNDzL?J+m}GyscPRJGc9;-hLJP zr*1&u6;?qdPL`+T_KpHuB22N)!j?95#gV zHOUT^r$miJI9Xl~{Q~A@Cx8pQu9BVuv9BZth_-S24)1?{7cFh9JmHNlp4{1)-&xR} ze|`Rbzn$Nu&5mV|jrbInjW{kK@4K^hDqun|^$*b@$~ivNeLd=Uv%vxU3NP&%o?cpB$35ZpZeLZU-=b7D#%P*oRz^(up@+nZrljQ0-FQY5}$8iP|aEGcur zDxHg)W^PrKKk4BKsUQq9w+vdSG=|KTYR?U{~MSttfdTE^eS$t=y;V8H63KP6;&7QZtiF(X?V3UclS4! zclUp6rq#UoS$MWJ74|j_>W1AG%^aNT6*YR@I-0hfI)PUe$RxIk&0pAJuxzeyrO=A! z07jTb#DdgpB+0{L zfY%!?YPz{evE3a$uEDE@vm;qzaNOsjWvK);`P~yvg5(Gk<^zDfp6TM zi@aiO*REP5l)e3hJKqtV8q%M3UEAu2lk^4?IB)1Q4GKQLfTCd{4WbmDK*pfQqJ0}y zHjxJBjJ;u!PsCn2frUw@$5u0(7PaCdJw?OuYu_{f&lwt=IDA)88!2`;N~4@sv=1W0 z+Ls^S(ECX`?I5JVoziJ#%o?u232mO{p{tuaqPoK0Nl7H|y~Aq3Ny|J{V=z7BCvR)$ zuroM6i>d-MW~-cgYQ{?uut(&=DWm=|-NGo;t%QAV0#L+-uwi}Rp%x;+evyEXSX|}E zlI41N7rOg$&NW1G2rWPNFEAx&FLg(rj>AiPTTiTP^uVUp=4XMo;}ucK($9j|TP)`S z`Po}yyKTvKk|U5zlXrAV3>f6kgge}s~1;nZ-h%P?z$pN|Neb`V-In9xEZf*ec` zgfU3Na$J49iP}JZb{N!q{Qx2lhhm8?lse<~&eOpu$ZZoU>7`&57u0AC@l=xrU7%f0 zXEDO$DAI+Lb2}^dI3CErL~o~_$sjS`t*@qh2v|%!i1_(zlF_)8hSBG)k~Uda!-*jk zunu+bTt&d{2XIFAhWi>ry>Z9$%I>6p=DGZD@Z+X`RZ{qvU2Rzguf4YwWA?Ic*Ylim z+s^yo)cxFY$MHMD|2v8kKh|)+^8IR?e}+O*@}^KBzI_)>u>H!5ZZ}wH_2)G~B*SRW zoY)IZ9iGhx&%i>@1GNHW_d@mWdE6X(x0@6W=Z~-df7(MRuCaL2d!cq+u>*Uuzq1}s zk6sVg?-l3^#5pRhJn7aSQn04df$RJ*^WYr+qbBahQN8)SRsG}j&{=%gxQ+I* zBh&rA@+%d~<>+6XbH!yqoqU}nq+V5fPy6eJxNt%$%STWiFC^Sl9H@O2FCCVH9=`~Z z7C|6NhgCw%l>5tv`&jAX-+qN|jJF;3)dRq)$fi!{@tj7UHK?OPH%Z4Do8}QfGxUAc z+b&4a0h6Ltp_4>B#=K_CS|hYzA#9wz2S*@4oGz}HZSJLKh0{1a&LBf~MRsLEHJV<( zJj+bL0@Q2K=B*v$)O4R73=XB+!%!hG%S>=YL8iLl#KGPcA;k^mP~qGLhq=cvC*U@* zrb7OJQ2v7lIlO+^81LU?xpC5lq^d3nNNr~ZbAVbVj_vT2u?4vUr4CTCGR{H)os#Z=oS)9<4OV-!#zgNAb5@yj-=H* z6^>cuJ+qlpZ(OOOV=m*yaHd&4A5~|yw)SXdY%n!ti}L&d0zO?6Q9gL{N+1MEDEFcX zX37qn8E>Cen)`cQcwmA8RM80VVFNKD+Dghsaq7P0)Rh3jSM!SdIOSvY(o~BQns1)O40v?b&)FEIy>_2l=-5FD=FKwIMhttdk5Q$*NT$KABP;E!0o5@_4b#_I?6obr z(IDxDg;A?!;5rT~XK3c>{K=U305vOtCzGv3>9pyH^N1@SpWVW5_diapX#mJ?gYbVARRj32mNVT2sauZhyUO_|@yW zdmZ*}J6xq|BP@SB^jDM3Szh11C`?>m-_^$2{QMj-eqDoJsGCLgyrR(WP4qh_nV;KfBE;OdtklLROa2&+&S)ffKeEe?6dMo*?gMdKqS3&4OjVky`u$51gLzuJ?XjITY0K_pV z>IQ#&Tuk7_Or32hxL-Qm`%5a*OXq|{jiG;FdyUpD%QnHAur4e8d?uF8E#*IY*-~4a z3iU*!`rFpwJRE41f~zghA6RQ~^AsEO@>-INd_U7uFq|DO=W??tBZb+UD=bvP&hTIA z?pE^XQEGQn>aBj$i^;)}3y@_N{fHA_C!TD@``)Ag_Z%!bvQuqKG`cmf%}($m=%}4Irx0|HgOP@;|Qm zAX>!$>0hZ)0{a^K^d4FiB19PF5FK!JhKT(3#w-qCQL-rSt=cx?jGC&~AAYjJshIIa zOHaY3VvDY?MbS@y-(Z5zl-w9C27~kU!vAuOWh*yq1P!DAxhh{P++uEWeGg)q?O1!E z{qASxg2e6I1N?-~LWhKBqvn^-h*};UVi%h@sYyLh&etgJsUP1C$`J5?gCH-DopwgY z>M~=I;~@w3|M7)c;XrQqPfQs0K@~#$bO9HY@H`xn^BUcCR6;6qt7Lx4lCazKtFRC5 zL&J-#SX78T;zb8zNC3wlHydpW#3mi0-=D*x4w$V<-bxGia-T#m{89+f3$BUvxt>fZ z7w->B!xGjozks6U*V6mxPJ7TD{<|Ma(mRpCFObAAMY}m53p_do4T%V(c&$wosXbS= zH0InKr`6$Xc^p?V_yfzJ&v|vN+YE_}-xO|kz?4357JxJIfhNfJ-;yi875G^CMnB#1 zfBdJvPU)MH5h<(*Fu6on@e%zbLv-K*H;X6+5B0-jI2aqmp#d*?VspsvggXZp&rDxI zV_{1$0627v2>MV!Twpq<(LQRApAT+Iy6qplB$pDxm?adsa92naUOxbJGZD?|%}%$w zWWF5e^x!Z`NdejvrD@r_GuIuhI|D=%q57jvayQlhCL0RJqaI;1xJ|Bi3*Zbo^Ehf% z&I@)nbu!b-#FD!83f~F3b75&=ePt)S&~BIuD4V}zm{ZFmTPqxb4n+9KN{B3Jt*5P- zIvbt?!JlN$U^0Wv1xw8Z$zF$f>aP@9o~GOrid|Dvxjp@j<4AQH-!diEvAr=}tf_xL z$~d3-I2jTcd1~Zp;7|$g_X#~TxpF+niHW{Cs4az8G?^?Lx#Ck&|N~-=c@l6$)=&+Pu%B&o7 zTrb*2{Ew}^Q$;`rT%SLDcq8Y3%(y}Qu{E_$Db#nXFNGBTTUm))WP)Tu{{F|lM`=;l z#P+X{rYR_w?BXqJ(^Xst51!&Brho(zYK!Dm4yJsiX>g)RpDT=;pgaXIHT`7qg*f0&C_cFNe9fYs)DJ6;5H1Nl)EJqV zd>rfzAMZIvfT@g10m!jmbY7$1K;fiucE1`Yu7cDcGYn+4-)Vena$t!Usmh4ukSn%d zxT}OolI|f1LnXq?&I?u#ll?7Iak?=kfKDDI9Cw=+Ytbo-uBY#lcMr_A$DJN$5BN6H zf@;qGhp=W)ibNulKuM~FOL7-9*M3^1vU+g2#Ee8Aq;aV}wDv}(mX@D>CYl3-u3O@p6&T0pBao`l5C7jIBfIuay zk)Ck=?6^d|uHkuDK*hx$Wc)WQC!ylA;$-rd2)OH0R@YOu6m?NrhVycN#;}<41Rf^P zNYVe3Cl99XSo+xG5HX#cI>_WO2>2cmW*$v8jA}<~Wd6W(iu_F2SeC$ny-suZDK#K( zk0B&WDrH5K{5qVN!9~|6BMjoVddlW>jOMrO(Ipalq#}F>5XNbDB$^mGAs71M-cbvF zCW$Eyyrk;5$GjTPh#t^r#~;&DNkO!%&E*5;Oj^5#Pg8!iPFpOJs0LW(LhbF$lSa~T zO0(mzSvtYkcs2X|J3v2Op6-eU=4&)EfiBo47}@Hqd@Frl#m4zBpyJ98prRznar#r|*AdU3p7MPjXL&gxLAy&^s{Quu1fVZIhtL7_N_uh*SpYL&x&6 z$U!h+=#hD(5aw;YPRfU{mg7@xhSz@@e(fAGRe+9-_^(^iD)? z^LWOP9g4x)PXzPFGJQEijk7rb*fJx>>Kb5qGUTngpaX0 zsoUbdnY*K=yno!QD4CvxD{PaNN0&g~k06r9 zQ>od^Ld%OF6cQQ~39BGdL5;)S4n++N5iS`9ZjSBTM-YOooW#Db7IGbGwZ!Tm)fuHn z3XbVZ7^=XT#189=0$wq^-mn|wQCnz%;vG(*pJo^((pLs=vX9XlRc)H2#&8DOoC&!< zH2;M}B6G3QhDV~GvHK;4WXmN0(O&sa^i4#W_6K+w0rs>$%)qi$4dU`)3afWhzPbHy z^{`>81D;H#k|1Zfwpm)FtWZ{s5kDjGR1}DK3tvT4IE=BU?~cuN{il+b=429rq->Vc zqp7M9&_Hva(UQxWZTawXlUH(C#iO~g>ww8|62l9ylQHa~tj$AnW`@$qcAeAfR5(r^ zovjLGYOx*)88|Ao9<0~HfLf-wAr-l}?eKXQQt)_cgSoMVoPK{016QTFag zkxO}cpG3dmfs-lrMw&afT;#Kwzg9LcDFNzx?yv?uz9?wXoJN<87l^q!i!@=B$S{yb zddOK@MA+cDo)-K~{xb)2%*^qTUw|XV5-5JHYPr{&>b-dUK$ha94kQqEBEz;Kso`~B za$bR?Bm-VzBjaT1Kc}Qui1D03Wpf5huSq*Egao*uPAD~BhbsMV}b-9p;1Gc8YK-}I^enlOuu8aiPO+HhXB` z<5>E#F!PL&oqn>trBi3gLo>wYGt^i|;uIUmpBf%b*)8zih#;qRT#VXM6eJXti{I}w zBQtlHAibN(aOU=v_>@|xokvv0?ZfkXQd!-VMDfkg|Q0l_FPL?lBrShdsfQTqW? z0!0`hX=cNHHx{Q2amb{AKtcs*4IuJo5zdI{ z%ZyDZ^h{WGGT6#ZicnRHtu2K5Ia8XL3)jx%zqv*VX>HUHnG~4@-(m^0M`tW)M=4pM zOkZJbDe>*ZAK!C6T(<-m(*y=FHoNCqvFF9_S(%3c0+NX&A zH^nGWNotw1;nQxK*+G3q?xlh9q!$weP)X@!2PS4x-w5j`+Vo#vN!pdhoLL1~E1;lY z_3UNj$x?U-0*?$h7#%o)lsVj{BN-@{Lk#Uqs)y=)nrFJjQxR)-$dmds%0rL=qy~@? zX62g*Xc}P#EUc?wR_Z7xBMuUS93-qh55moVsGW5b3)oU-@!?CBs(NNJnkPx7H;jxL z8o${33YzZOpKZh14PhY`-v{sh3~{sM%7SavG_{;!EQ7<=;XpYN<9w90i}3_BC8?V3 z!OfXUW9R_6^C{*fr&?q~5KLl`yq@Np7OD>aHZ`xbi#-cMqACbY5oHF75Ad^5)R$4N zM7C4BoZRvbNuvJ1x*lP8F)`*|F^QyRr29`87R0*=%m7}YG*PekE}X>QhdCO47SsIV zwW*AlPe^|7)!p;U=Xey?^M=FlGkKG5SA#hrlw>2gg_VoH^GY9IF~B;I3)8%7o3T*{p^()&P7DJ59i4dqjDpH9%wsvg0~WTzY(5A`YeA*<-Wt2%Fk!XN zaxE#b4??@;9nBobgiu`kOz{=vw@GQyD3z`leJ7iMN-EVTMm^C<#z$R~Oz9LMewVaY z>_8M@j9Hv+H07}?A=+9IDH1JTH=NwBwW1-phO|DV7H0w0!r{Ze!Rd&ikfH5zzQBuX zA(K#m;w)MM!Tgg%EOOh3Ue`)nQ7AA27Tq*Rh_a+$r=yn=#3{<{7-Y#<5YTaf)9KRl zoy5r0@H22EEKv-Gp^O24O%WrdP$yZH=mKEE_=NvM*gHl^wya&FUAAp?+32!u+qP}n z>MnNK)n(gOmu=hDm%aDd-+Ruv_r3Q|jvNu|nR7m|a%9Geh-aoE9;`a%i9KE>HDib9 z))!?e7=jswZ{7l&Up_D(drYh(7on6|;g9v0+GoUR29ew5ZVT2psc0 zk=x;p^Pc&#>9eoXZy*DHsOoC|{ow-NEGa+#QGr|?=>IN&M&$b0(fSg0qF-|2EmqY# zHy&Sph+g)W)_=RRIMBVUi+-&$9PuSBne>-7HO7r+j&*yp$vPtVF~;n(7Y-x%R?^5sUiyi?Wd zyxz(8&H)F5P=VJnEA;&RO4SfTgwX&PdRqvGDLYzXPC-E^pl8|eo(gxIn38t;pr_fs z5Xm2GytsD{JJSC)nyC3Lwc3ccnlnuLT{Q~OkfI|YTi(`VW%RKf_acIjn>)f0#Z8* zY^v7T8Xz0b&=wz0H@B9=?Nt9De$~Q2ffeOFd#p95j`_FQc^tlXefjL~iH*wg0{2@K zJiU+@* z@R8*RCsw?43X~Eq0F6R9dbeIECaJnDHK<;e3o5XMMV>+N-__6@ zhW#L^iQ>%Ehw*tk$36qSQ@qc`$aNtG89k1cvS@b^A}V-cDjHx+7iX!((Z1xiWTXi! z7}zRsa&F=?zpTeh4IGs7XQ^MZx>%gYL}h|Xk}wdy-$qLLf%Dx(C-|1anJC;f2xUpt z#HP)!s&yh2R23RV_u!fU6EOz=ml=+8O%J7(+mxn;j+&N{{3x-h(JqKru~LFaZ3}y- zU8#5|EXOog!CUs=D)fApzO^6&+Yh9;S0wS{-`-RXuDm?bIhB%4nRzoO?sp~YrNhUv z#srbuo3Bn(;1CEjOxc-3yKhy-g53?HVbjVvoAgFXTeYcZI~&EcgZ9F|@i(+87a^Ax zvjB%9#Au;!y#j#u3IbDNyRS&7|0?<^3^Kj*^sFsq;QTt6jgi+DmV;xy6v=so{PyNp zl*=0IN(xUIJyo5cKwPwfL4uEd#B8L@L&)Q8FUqE4XbE$3>U;i4_2VrG4?I};aQ$c? zS<(SFa&N|4OI`g7AGThw($SZ9#c@7bHyNn$j2}_mv}mTq9KZY-t{*Wvt_NL~6adHg zxLUfFOxkgW)SJEv`Bw)3@orHygBy>F@Dq@jI~(U$|DM9oq4*t9p#OwH8khM*CO#@! z-O`X;z{x1(3gcpOx<3!T2(QSZ3kMcj2JB7qn&%nSPGR+8jg${?mk%+{z zT)dVe91ad7-0ZE}HxRbU2g`{8No?+0J*lV9o(znO^QN6Cfyiy_OY9+U zii-A_+O~gSZ_{c;)d`Svm!4YfH9lj%Nqw%bV$3!zulQ9&P4Lv*SlRlZG-JKP01Lk2 zGO~Mi(SSjJ4TaVGjzcxk{cVwrO|P881pFfFy)%E6PEAK{h?3;?h>A=+v!u$8k zsWa}uJP4l~JW4SimD6JkwK|F;ZKfjgI^yQnWzqPub^b~i)s=tZh)#?pf0m)jShoM0axg8FY|mKJR6^=?~KgE7Fk6 z<<}Asf<>$=g4t2qS&q3mlQ$Ngj;&+*F@kVGF}6Fa8l0t!pyDy@(3=h&(vo$(ZMJ1w zW+i0Gf7vC&Usc;$wjJ)DRZ&)XQxa!Y7WdLTYH73d`F;!gdA`|+Wz87^ouPe@&@yXT z>668{xWm%83&awsL?78{G$FzlbqMig0ZixC{>?(60&-DjCNj* zH!IFpwwRddv*XEtcDj+_ z$_7hbN7N1xO;rw%opRQ<6h$QtM12TLh|=z>u~JMY1KAbk0%3PhH<*rvMpbolF)g1W zxOOCwFasB6opi$D6*;AKObf=s)>5|liRZmzWNUjFr0R|WLxwrB^g&3)-^5zrhq~xR z8wTB6ZG<_IQV^_A+h!-(b-Lw&G8*l+Y70M*~c#4L0Zr%;bB?e z6>?fK%ulzJkO246hq!RB zaYeSSBA^8aIybW<-L0UpAZ6&4sc@##l<@z3x+@?_-ae}E3z#v9to(c4eTL?-_nCDy zqW_I#O(c#qSiGiQUh1kc6M^Ozsx8W&AuSqmU>SX1EZBgmWb{6lLRCEWII`7 zB4S8SKk%O63!_3)=!`gd0X#O#_0LI{F?0kqYoUI$tQ%A1I;jJEeLj&&TUnM#P|;jo zw2ok4zOl;#%J3ig?q#KI#}+14IE!j5=M7GOO$7c-zFR8Nl3PD9@!TfMPO07ulhC4#XwT?8^-GB?=C zLR^B!!CGl#fhGTA8{iM}t{NlbEg!a(UDceD*C;scph!yb=m17SaLtYZ|6NN<99KmPmJ>yy=K*-Pav|1J0$has(f51*p2g?Ala`q>G@jiz>`d^buDj1g0|n1?5iWeMAZ)b7jMa6gd>;uL5l}t4S>2L^qlR zNes~~h%PYR zFA;_OOs`f!kh&f(y=X)6AdHWdw|+R>fjS}SVKlUhQfD2eCABrA?}(!hYJHJlV6CpW zIuR}_#>(`pDk=@&SGnHeJ{nzXWpCG4-_X6hQ`dmR7xtUz!D!0&xY#mejf20oq625i zh^@fmVpPAWe{yK(>G({GJ~#~TP@_$c(62#D{wh@tn`}y< zswqKA?yL}kmRZ}R)_EqHsiG-C1hE!3ab4j|)JmQzBi#g1E|feGDL}?TI#WUVf>h+` zmT~k$u4vvJ6*Uq&i^?ti?ILokF3Pg)&*qCp>XcLsj`sUFJ7&3gz1-yCqiN4%vL2LH z)GwxQ%nJ4sDO`Bw7v?pO{IHIRL7pP_1{QF@GRA1zqSL{&Md>#^G; z^cUoSMB6#^!&{R$^q}2dgF&3|Y}wK8#M=_~>xFLwoyw*>s<3Pu76J{oox5F9Jgp^$k%H0TzqKdF*T{hhu;*4_uO<_Ptu#u=2)&HxO-h$a!aM=P7e zWhooXL9{c*sTla$8R`?ci#fQ1Y=I0%#r9Cqg_-BkoyTaDXu`I9O9*F|3m;Rpw>$z) zy4Q6I_%3Y~O2SwlqiGNVed5GN#q%x0lCDr(CImJNGP&@R`M{~J3x=kpye$A_8Jr~n zA-y8pmx=UtM8wyAF-h*EjSb(a+3$Tpe)cSI;azoZy>bj}jeG}&x!I2Mi@)vS60H56 zpFg8u^J>G}ktqj%y-cz6n6oHpxy1golnW{qu4r8L1C4YzBSUZ+&a4zRYqVHA2jZJ zjOpzNCj8fiyfNxQ>)U0M%ouH&AjIsH;5=Sj9V#3P>^fP?9*c4e|!ts06`}X5z&n=p&nuab}Z@UB4$ex z1{dUacU?+^tPh8FDSaYy^_cU^t8Md#K*Q3Sfx(23Xm!sH^Vr-@?n^5k$D`yF*e?g( zdt(0#9$%xgvD6ydAJ6pMN!&>f+vDOcpFR}FUFAU#?Cw0^$OC*muR zg;v~b)}x&SP%UpM5o`T(C3lGh+d{Wg>f8OVX)P2(;*@cG*%@H91Oav;_o*RjO6nkF zmJFpy7`SMKL$+f5g=XWsll zL~D?OIBRc!N`o*P>1Wrh?i=;5(jbLkpJTsX>i7*zfT_Lu@p}}rq$6o<(DT|b-84N% z0(rkR0E+)fDl93L8E7qdPxo_??k&LYk{aI^k}b^;qDiBV3RQl6W<8dbL#@ZY{^llE zYV;wxr+ebd#wD8^5>1Vkin24;qE7tWx*p=45^^Q<5n>$ z>cnV248XH3KPPnLn13!$;DFbS^9$rwJUJe^(FyoT*!o-dYQ!eu?UkmRd-eGabpKUm z+wDQAAPiwDj_heb=!<_I8wj~$LJLG-LgakfBWFURYmA# z$#(PE9m;pNIXBbMKIWzy$=5Hp%LX=9a9(t3f40JGw$TVyL0}N!x<9gMqO2-&z1n+kcWr>yL>q-Ta2XvoUOn#+WO$6%k{wD&dK0!(q^ymNzDT zT8goJdb=WG43#pLtZQaV9xZF&hi(dP$=n=1!VG%)>bc>p2 zab-F;j2qSUdo>M-geh}2k33qEL1YOmSOcoT4>+F_Ri&Gb63F$|#Qsf2;6Z?vscXPg zXk|<3o^sMJaj6&JqvPI2QciX z_W%egHdV6XHG-@=_vo8g%voU8N+aI zBj|~VkG<|d9f1PEjt$CcDGrAYfh>|7qMK>9HYQ{O1wu(XNhkW2=IFE3kx06;!HoUM zk_>YP9DRL-S+U&HretR`oq#8URSrPf#KAaDxw}DZe*-_S(X_Jz+a<->b;BgXk^or> z@>o1X7v3@j1DgkV2=A+%>>)9|n9`-au&>b(9>iLL%n& z@S{)BQfRC`u--Bi=qEP12Wx`lJ$TQELYS_SqQC$0(@}Ec&PJI0nig;sIw&DkXdbzg zhpM3myTXHXaMRNj69I0p63Zju81IxBiAd@W5o&AcRJZgubXp733iD+M609RoY>~-c z#8%|nvD5eD0!mT_Zk|c1d}%X7h+kGbS|nydNoDu#^J^u(1>8RLVL^3YXDES6(1j(V z*r{ws=ix@7Mtr{~YUNFhrCkO0^=-DyHxL0K6CB<;vLh@r6XrQMPYv5sj?AkGRXdR` zXdWgI0=yd?h}E!w@G6f|U7^FrT+G1xcQp_#{*y-Z>D!kC>x`y7r_ zZFG4|_r$}Zr&Rj3e!o_9#LTL2f#ljX0WB#Y$gX&VM&bz^qwCewXJjsSGmIz7yEm^aNTJ-(7gNU~)$UR_7Gt!{r`DqDn*IFj74JZ5Z@qJHK>X*JSe5NRG~X46j*sUt;x=y0h&WVoh~DsCGun1 z;kU8ouLp}~klCq|-8(;b$qCBYg?R$-wlWsk3I)fZgv7kDg+mGMuO#aD^d~)rSbiKb&8Vg3o`kz=cM?h zJJl^1l-%%sH^daZtfG;7MCblYCu1raHPp~?Xy~~lAaw-t==4zsrp^Q}?JP0vdQh*8 z2@&)VR}kKB)N#lB^tU8`Z! zumJF>8^~u(;*xc^6d#gWXq9T@3|W!TFxQbPpj-usCXOLZc&L`=?Gwypg#jC*@#Mjw zK~IgMd4yg%kBcVg-~r-)$zl#8+b2j1a3sSKiKGyXu)s?o&f^ZxBLJNOLD#(9 zWj6%}kjsyvA0uCijs1fpPMGxlwHEYj*PEYj*O5D}q-O6Aiug9$9fq7oWHZEmt{hyu zJr|TSbW|3-Muy<+cVon`9c5Xt(-8X?78^@TgLjw3bmI+uSG!x%`q^|f(&n|YYTFmF zU?tN4r?LpJ>2rp875ufJX=S6Lblt~gpZHW?*4kM6fYUxH#JIHG_}WX$f|KmZRN~v)K)dM_5 zCbqGnZX_;%+8#4haDusM)s~%iTu3g(<&t5#PG?zLK6&(g zT1<-OcXvjs9sNq-d94Juy?5NzN7omFnwLbGJD@@6ipZ~Wbzgx8oGF!6zS3t5jd>tz z6qFUjsLFr!svfcQsBsSr&096HnqC=(Q)mpg>VJqu9J7p{D{2MXS8ZlZ%%heO(Hz~d z05eWBQ8FB4-r1iXH`hV0(MVBqs9$cn%3e-)>e~{vJyk78}O^C-D?~ zej8ypcPC&R|tQm+ZDaR+sUGXL?(<5338@9 zEqJcv=USixy}|E0T7n(cuX*5_fmonA=Us_3XS;Lk;DFK*Lb2?t8=U{V1v9S^990Zo zzSL-feL?u=7C1S3SerQgWxuMUm9)eD!*^3JA%XsVE=eB;1W&6DXesL?{32_kG;3o? zEU|!!M?{b=el|s~tC6vQK9Lp=Z#B`UjM%gl_Gsy_=#uM4?8j}Fuix_eyO$StNZ5Or z-Jlto38<6-^yZOGQE zHOjY(&*!oB+5Xd;x6AkO%6^60PH&;O3s<-4^{8(uY`MuAfElQO8Om1rX^q6zSjyV6_ zm7TW(A0gxX7?tOC?1E`yjB{->6pO1cK(}||+Md&!_mk8!^)5CsH!nL*Y{akQ?BY_F zb<^k5#E-p_z-<-bahm_pFNWpOdC7fs>a=P78TIq_>6Y=`?4@e&GqCT&yY=?{8wDmJ7s$u?{cNw;Q*z!8Q_C{@`nriV*!@B3^zNbyw=Rt|#nOSk?JDd-Ky97GY>uj&E)1@OVIOHRa0a>8^g=ubDuvtJx1e zyYch3ef{&c-p8}+V^d(q>Nq^3Z}r2w<$aG#R_^06E_fTAD^!M)>S_2U`hWY`S6@G_PWiXB8MZEFp8sc4zckCG4=%!<(^3Uo~Y|-r!@hxvpq@jy~L*|@bl2`FHDp6|gBX17~(1g|gfI<4Y@R4Uc zIjU#9Fw!N}l?M6RO~38YR3XD}G@zVP^xl@eICP(~`*QzGm!&18NG1Ao_u!7+Hea(+ z;mb*Y@JiocNFyZ^{dx1!&KpwfDG*uqNea&E=EoH(*N{h+=@H8JG}U%*`s%&JV-x4r zW$ni|_VH2Nw_Xf0TGCXWj_nKC#hrc0%R??vrh68yd$?rSI2Z7k))dojQD- z&PCV68&v4JO2woZM<{8GJcQU(tc^zvSvkcLb+B7 z;ie)IA?R^Z3K)_43Mjph@w7$E4cyJZ2deF5EEc)0Zpr zG@!I_c&BSKyXkfIK~_4w0usmR_GfU+JQ**PfF*NIFkwTfyda{tEGkAGFNfr?Vg@NG zXsJvJa-jWz4A5H$gB`TEKq?zpLC!=5aP9Adi9wFYV8U;;#6*zf9x#x-5*snMm{0;I zBW=2nx;N`0wj4 zMava-1g{dC{An`xXbJzpmZkm^DhLP-{1ci>Y6qkN!2OTVzp41+J#Q*80#p8rAgeyH z9jt)xj|y`@9Qt1>{$1j~L6wqUdJ?(+x$579*)@F!=m8`0fDspv+5n8ee@g@ove9kaoR z43lB)643*EHDbp+rt6&FI&0Ww(P7JWKE2P{Xvw|pK9OC_z$qW^@98R)j`$JCDU=j$ zV#Nf0=V}TXYm&q%z;DV%7_upAc5a$`s&#y=)f)BPtH-D7*0=tC`LfHpF5MVWH+YXkGvt~%_jN$WG%dy;qS5lsYu4Pl=e(w1`fzMt zcy5=|v9#+_V>Gt@`rh-MY39V^=f~MPQRQZ8F%Hqq|QU@VT)r3^mTN_&$2-r*h^T%eQyoJo$d(Q|#o; zv3wlO+{UB(;pSpE?(#9GAsgxRb7Dn-N9pG$v6Q+tK^=q7IPw*vD6iGz`7aIlD|YQB z9E9a_nv`uS$dJdnnH#ENP``0Si=5Xy`6lEp?>=`3rL>Qqz{ zt8}}%-KC>F;se+o4Sl?8X*zYS9nW5#zMf*Jt*&1T-S4l4seP!qtxg?|74J?*UqA0| zd-Jwm`DM3%-9osE?@9|>6}r0b@bmIm!=HWpwZ@h&p_f?OO8+j2`i(Y&#*b4wLy*z- z=tZHy3wU$a^#T3z0X+AqpP-7Knn8D;v`VO~7_77QuGDmIrs`Za@Hv&O27?`p1Y`;m zOa1Dne?dTnB#4$cc;kzyKF3~>yj2QIv5ti-&Pe9N);LtiQVN)yh?jPyq5i3H0hK-v zKF~qd;{GgS7))Z-pilMeyq4b}`_l!Jgv~DSwoaKmql)ej{e;7VxbcrDlXD}Q*T#)P z<|@`y?oW7QY#C5eoqji42l9Xn$qgxUMmLTP?S85bEdU7h;s^KWV3y2&2Y6|DAdt;;lZ0k92zn{nCiMn=v(jqm@E}@ zy?|<#RDe6rSg~^MSAv`y-$!$}j8pC+i@gV(kJ_x3@evxF{B`{z_W?zH7rGDohAbeo zA+KBV@S%J!HLo5g-$7Q<)7>@Ua>?AEs^El4vWR;#rIRZ|L$JMwXXy>9A@D|-%;5xl zAcbZJctShIK0Cbskn(n1^l9xRaKnztuF2Gzt-OQdZL?|V@m7Yy()4(6B$LSM;EiB; zJlmcART!q%dG4y!xVc%F-B2EigBJa<`?{(IqaB~1WJb)jd!w1f?m? zb-C6i!U5#-Tz%hciZf4z-GFs?(C=0j+1Qs^0o`Vy@|N6QV`4@8E)BsWO)`S$&Qs&5N8@xRgAI(q-P2RD3$~&~HRX0Y^@rio=fUV2E}loY)wH>_Qs&yKx}E*f z)5D#xkr2>#T568ZUo<{ij95BrCe%3E**gt6f9E8 zU5>@}=1rIsRiZT&aZZ%LRNKk1N&S|j+!fX@-bYebl5t$q)UeKyu^E6qeENm9$k|6)H5Ld)2sBe&pcnsKE|JYVBosyvU0 zOZ<_ks_7VMJYq3L8`1SFKNDRi$vhc9&VCw%)wcYF!eYv!kmpeRA@i{(3){BW2&G`2 z)52Aw_Jphho>U@wKo_OxUD?g4GsOkeO<*0D1VLyUiQV*U3`^)?zn#92n*br2*5ufv z1=6UR8irq@ZZwd0p~AuTy2qA)f-ilp6C6AuI8?`#!F4#Yyo`5A=KQuf zvmmfxDyU<5C7to4&WX!{^ye8*$6GmX{k?uFX@*K`#EEgb(=hgBJoE>^*iuWobJY<= zyauh=5+931?c6k9qxG)Hb`pZIEHBRCUfI&1GSj}W(Zu{f_R?mO_XL zT36H;Y$4|IY2!V67AGBMt>Lv3+kr*37KirQUS+^^_GJmD8u2@JQ>U37=mg7vnreQ` zFVP>G-K+Yaovjb^^ksyrPT7S)-cetmkY}W&(j0|SJxIC?$(Iv~-jNH+FsH)PD z=jVfeOdxF}nD_mlK08Dpz0X+VEW#BZF;GA5;fyY%NDJ!9+1^{&G0U{))LnFj>fC78 z9k-$MZH`HbnG7y{+r3(K?C^U*{qUHbdP2`0VawZo-s}#)!Wz=B+SrL?BZ+g1F zo1Yq5jpGd)n&n`+;F(3!favjmOH{#@v^(1Y&bS*bQji;#1$wn#O!Y!l{E$&5O2Mf7 z)NKBUpB?H`kwVZAavX2fN>X_>oByhw-_}}H6HpRH2zQTJ_7*} z7Xac30P$o7&wLU9mD?~IqG2D=!=v&PQi&(_G&-L%%VB#6^_iGno%^Zv5c$*pj5oLG z*|8;a+yenxcB`Qe*>lz*7UK%MoN!o&+)3iM7f{uPE&;!-(sxD~9Ie87d;mMsg zayp2g#wiOdn8rC|m{ccNBjFIy*E}!Xee!W}7E@8=dcC&+S6to-!;Y?pe}n!?D*l1` z?Y()!``wYdT_i10K+5r0k=+5!I(?mrKBITyi;7%W(~z5*8|65W0k6KjD03)!<<}65 z5mTF0SWp8i&!Vq~G*oQTMAGFekNlTKl!V#Esx&_RjbP;mT2Qc9>x$hlVBERd=|Xq* zrKZz#%kU$vR3#~kR%B70+cBo8C2kG4EtSm5bJWZ9f|sVugjL1ptUkgWf5I7HvF`s4 z*Zn(Of&QQHd~!fIoG0Xc*~9YhHVK5`)6#o60tLICvgZXfA<+)hD?uQ+l9U5P9_=!X zIjKgW+Mm!T`}eG)dAjaKB!V4f?-F;mwxBnjmT7Zhb6VAwo%=)a3iM#@^4%yWg&e{N zDVFH>iRbaw#etq4Jh`Q047#$gcJq!2p<)!#}8-Xgf6^fzJo|Ic5z*+M2 z6GWcP%6+GvBd+X?N&+42x|$kuFm?qZC29=>u^SWh{JL_qtuNu8Fsc(xsaQ-Wh5i`O z?t&dGVMp7F6M4F|c!}o)3YVL)+b9s{WCQ{N7cL>p0os=G#7w5ShXf}jU3syR{MzvH z_(6bmnMeo@o8GGw^+JLRy@gLsTs}JD+x~oOlIR-tkkhj;Ur*YPH;xZtz~L%B%TG10uLMRwhPI5_zET_=#t9~`9S{x%mxPyLYeu4k5|5Hb5zGN+!Rkq zR~zPtmlI3W&yJXk^Wcn*1=%pC8%UIrj7DG6gzWp-DS0cGZ||zRRV9yFg*$lnT0zm! zR69^{)L8j*1V1D($;8;2Qj4YB%jcQWtve)0c`R9_x8t)3oM~ef9O442CiHWDtY|LF z=0WZ*>u$X^CESXE7*|DAD`|E3 zIf$fA?Hes{qMftkJF zX>}j}8hlYO-~kdD`o3N-XC|dI-M4jg@CZ8hwH+-Z=bO7VJsOaT7qBHJ0xG;R=-kyu z7$cSiy7vkI+BLB;iE>!|gLv4@(2IcGOJ$ar@L9B2Sq|VdQB6+xoQco7D2((QjRIH~ zfv<>|P!K6Twh|o^O&JswUyVsJBzC2USbr)C|3H+j39*R$A*} zdx91*WlLB_1*O1+$>d}393BtBUH>9R4nF&`eA()NIU(D=m^hG(pnF(@`E-5S zZz#EJk0za>B%7}TV*?!^4)oI*JRXGO1(p!pHMp&w(2&u`fDtuqUD(tqWPGh9^cjUd zY}{~o8qVI?knsgSbxjd?_H0;}Rzv!EPaQuhAe%snrYIRM<-zjXt}}G~bP-}W>M$yB z0!XSLp&>tws4iknYG*ZO$eu|cdM{008-nV`b~nfy?QAcl-Lf#CypAp`Qpt|)0T z0sg%s$j{7Tq5q~)V}2o$sRLeS29WpE#Wo;sDBga<(Nin4@`S_cx^2+E@}`vp9}b|A z1P>25k4iunrYWMQ2_>$}7`C?7lkWWH+G+9fZz`GDR{p4b$@!y_w;Q061QMW)m#Zu7`kMYfFHi#oq!+q3=_mcpx^MN*N}p#En>WgpFg+Btn>8r-w=zoZAck9 zR#dG93@zR6``B)|`bcTlW+N+`5V+4MZtp;U!~l;0<0piGHx=aUk%d6D##@y9w3(K1 zj7@k}6?&=}Dxw#jAGq7k=@l}y5xJK!=wQZjtamk`F4B3)Q!m|3zncwai~3YG4ml%d z{IWjSC2&aCx_;@4(??o}V&@4$zlZv`D&z1AD>FxDaZj-xsIhS4OU4pejf`Q2kCX&c zRK>WJptLv2*i%1S3^SHzJMylza6>;wgwq3#G2-Ez+~a6w-N!(YXPe_r=3mXUq!Q8# z;EmB_lMyD~JDG!NuspgEPON#FfBIkHn8KY-ioHxGHCDAZ6z%BbGLzWJ9VR;qzebF# zbc1j9bJRr3WzMoEKY?IFJpB5n-HuYi6eY=H z;x@v`5G$L#*nvzaPHiVMrsT&TCB8whJDD%Xx@k@e_;m)Q1a$)cS0UDl!G;1vKgaan z3Jw0B3hAdf{2=AtOdGDlpy4dZ&*Plga{q8_SGZ|CsS{v5v#!GDi3Ks^ zy;pxU*#7Fao;R2YvJ773R6l#5n*U@66smi{;8jsmchK6bKzLAMmF(Apf1+~(6}%M~ zFIc>Om&;V)YS5#@MFWJ?7Z4sF@(L%?8vT=~4+-Qx7F>dR6~;STV}1@UIcK8`n`Zk# zak3BDox~)Nw7I(AcQhkL6gqdmRT!zdAv+CHt$mk)L`n^_vASW-#FMk`H}ZA=p|+3%P;)&X(g^5k zC2rnlM-4#zZ{hk8e`eV;(T`qpu>T@|a2W=WD?$84Ud_mY5k>dEL{IBWY(2-St*tqB zh;`^gomBMdOWZ4}JwdBAsi4mEI5UABDYYfgx9RC_b_9g*Jk>L|!vFmF(S9;2nSXzG zc#_;z1X^71N}~=Rc^qR`lRV(Flp{d3!l62w?l; zmj)qhP}k6P>Rv)iGArL*1Er)rX{L*aEDPZ&P;0ZY11)m%86_1toelfQ1s^@+2?@gL9d3lv_4vP80uJ0|J?I>=V9!N_C?YUAWsESS};YhALxq11{0Cb z4H}H0wLHt*>Ero3j+qUD6uae8qNXEP1(9L!_zU>2fP~?{5b^i>GXQJHf0c~KU_s2xS@5hK z5(he93`R@UZA_&Dky{&U6^1EkDhtJEUKhJ{2iovSz7lLn(O}S#<;d8hHC+ngECMR< zZ}kD*<4->Z>i56;gQoijtvzP}q+-bZ4;lu$R}lJa2=$qX&JNg|mP#MY8mqfU`xm<) zG51{52PvU(vHO9Y0zoeCVi{VU`CHE=rt%65n&N~eCKx}$kuAFP*rIiEkOe1@h!t~iFc z|No$Y2V%^%3jTvOJNBc;aa@CT^cmjZvl%N{@ArN4WD-XCnFQZF8z1+_h=o~Pwsxh_QklWp{yb=@0!XcV7fF#M&^ zt>o|;*gK4KJLJX}uwKEkAKWnp>-D>m(;xMBti3<(aH!OODSQ~Y|5v`NV?W9V0(qe7 z+J8y4`+Jr%w99u)B6YSqEWmT;fV0W~r>BPn=A^6;5y=<2%739N#X|uV1Qq)(jC()H z2I@Zj(kNAk`Xz(Wo9%=8DSEf%Zca5Q;3`WUkx>IP&7vF5m9r+-vkHGVrW<&WEoM`% zLZMmEm9p?|FV0-Dsc>R018$k)UX>W73CS237wrsF=CtiKl#8E}yncOMaq~jC$Bj_CDyZUCr<#Yp8u%fQd&jH) zG5pfP2z1Ab!u9&cM{r%9INRW&j~1NEWO1Zo_ii9wfk#EgZB{cWw%KdMi-ERkD z`;NWKW41zR+1)ug=ot5Xf}EOjanM%!M4Xk@U6HL0VZCoFc4qTnw;94~aXQ<^Jfw_# zDBZM-UHI4msRWTafHT|pZF2+Cr+7?V7h3D3%zP@z!1x%Qkj)N%k}Au9(ma^}1t{}> zo)E1}TDD1#l7|?50|gIZ;0rJS8kf^oM!ZGAS4(`JkL5xA&W1Znv*-{ySoE1ybUFBs@he;H)jY8=_=}2TY`}8Amc$v@ z#1}Xy@ZotL0cQD7jgQJYJNu})_Vw*#5D)s+s%yZS?e%Rqk}#DjQQd$ zTTy&k-ijpSw)b-15H9P}#fVXHMKWJ)8R0>4$mipeQ^^}>#e)|`6JAtVUUY}yoW^@y z!kM5dwi0jVGP$KNxB!h#s=^Q%t>fD zL0Mcm+FKcdMcq7tMSdVbAtcq)ef!qph=O_Rmd|Za7PK#9lIM1Jt~acFkDT5mo5?6n z^sQ3Q)LbBeC;Z$ZOvzT5r8U)48s|HOoBqrxqSQ^r>R5 zg@}Al)g@RLoox3U*ySI=S3;?CFo)y&M*h&g|0CG92>tDE!C-s83i^~Ih zT0iTaKhWOfeY7jK)O^0yRrr2o{ul5eSeGn3@4`R8$1@3Bx-0}B%2%_CuQF*j;Ja_i zSIiAtVOb#e_rO$7x!4)dzD2TpWwr&&3HSd2%!Wk$q$4L<97urn#v+ZRd}ZXl@pyaT z((`%%!Gre3u#&nj_kD8c3)5QYiM#fQ0_*C&#hb)|U2r%Av{E0H zZiO+FLd>?DRzJr{f$_z3Y$j9MF-*#V@hQRh9&yO)(dPsbl+~1O0sI~F&qT-O1MB3I z2k=U^7KJuXym}DkhfJV-H(s9Z!>*j^RGxPbE)wPZLpqL2e)j5e;!scR(Jp{{sc&JC z`a#2ca~Ny3BnE-E*kY6`?qD*LH^Q}mP7rDt_{&C=wW2a);mbD(UM^c@XD!NUAqtNu zY?$`?S4e0FSz5#vwsX|Zp?rrZqgr~~`l1dp3OAphpIrtjpKDN#9N0YjcxuFZ)7h!j z70N1LAGn2SwucN!j<_L)Xv^WcRf~az2zv<}x_a?7wXVqn3GtGUU$c71KF3{a6c-e8 zNTmoEm!3dFCvbObw+t#40V)kdnR8ifad)h~2iG zClq98Rq=pui@lNwZOi?ce*_s9f`ClI-do&W`^g(Ca2Isw6>p~>c0V+i~j)J3;zYs{L0ni5V-3Tyz8nTM}EeZ1^+LAr0z61jQ@89 z455Dj`uYC`V59yQz=AoGQ*q`OLiYbd*gHUHvh3Z%u|2VE+xBGQiEZ0s!xCua)kq{o8w2cdhEGuBxrRpV!xufc0?Uh3z)nJmDatTZ$%y zdhNB0se7*-Dh>d-TcpT2`AIqHH@Kp9IQy>o`>&rC)1uvg1?@V$IWIrE^Q+yN{@vO$i$WjuNT4gVu`ZY$+%4t*a-%qkQ z%%aN66=>XM!^`k_O^jKT6Nk4mO-iK=1rlHLQ?#aB_j_c*W4m0;H@LGi#J80Vn%6J) z-3hu@gO_SBgm+Rej*=qCfy7I_9_3-X7LFXvwHsvJ%p|&|fa&kYc08C$R-$qCfg>k4 zTl$>VUF3b^L*@zNDRk3_e5!hn#~-Eh9sxE1)d&->u)!&E!Mg18Y$^{^32 z`PqphwPFD=aa-kqd(>qo)Q0`vo(}Ep?iA61LK>t0yTHlc_G7naT37 zviMvUZ*WMtO--mRm}=LfO`Ux?!2d_gi4i=jcynfzO801&%gh-1g7MB%e~WabOmIps z>D#Jf_FpAaSyobEzb^Tb;muFj5Jg_c{NqkT-bP=#RmUbdsYy={kVhRfD#^XtmO+gB z++}85CHt6e(6%W(sf;Dn;j12gpuZ~wIYi*39@y^=-auqO6(k6fh}BtfGKd0}z`9pL zy_MpdulOD#dPZ1$OsVgTn5M=I+JM1FTuC-92&I38>T3jqV*Cu%SE*7FH^RRi`6*Km z(B3_-`$2T3a?%wKkm(w71dthM_P5M?Iu78rPnqYLI8Oc2U1kHq4|+>60PhV|jy(Nw zYAN!(Xr}*1r^&a?p+xOCWAfht3HdZzdhc7d(ie@K4_liN$B#7mi6`;^8q$Yv;>^z=B zvwT2QxmL$ z=758z23ualual$Fua33FrADE(lITe%9YszR&~%yNzgdt!a%yt$fZu zBMkny>=*zO=m&#mMS+j4T$`2FqPOxbjZXI8EHLo@Vc~zwlc`crskL;X0X~!Ch~3I; zi-t%2dV$G*O(c9ng^INr1XrFu&$_~Qm`7Qj6Dw+bg#;C={LN;M1|>ltz!iQqh@N9; z0z5ohZo^9iVON5#G~rJvm667{9BEeFo9K zZJBKdsg>Yru9Q`7vvw(uQ!Su+;_s09W_vAkJ5z})T)Qd7h+#j;uNQ=lj}A{pTRagK z3G1n!!U=q@f)*z{q%%sZ=yw%NS|}))Csml+tXaxaxFeU`aH#y)?dgx2?rRbi+rLq# zodF+_s;L?!4nxOFyHz*WXV9+4P;26@pCb7Wkjf{J?f(F&1At~QpTd@HK|++Lqm(QH@$IvURE)yatu(Lq%d1`UU(K@zwdIX)JID40Ah;HOI7 zB_N+f=4t5P3Ih1Dxf~xXnT||Q3*v-f5hGe;^RB;m-t%gyAyX-z2Tl@$Kk`co!vvwG zi^5p&1h> zK*Z}p&dPCprBxWA+3(+t+Ls6!HE^r$HbQ z{)&o4?eyySQ)%j!3teBq96yxN7|GF~3!hXfL9Ey|7RM2Uu*Y}bcQ^O$7w@*sD)zHK z+i)N3k4H9bKC8Ud%Q@S=w^zFRwiMeN7Y`Bl`+M#UI^7>fGl!QMA5T{w&+lih9d9`^ z6PwyMFZY)nH8qG=S2+(ap9@B>^YPN@qIdLkRpK{iY#uGV+ahM9xMs-Zs31T;}) zxK8VFa=!MgTpa1Qt&USH7m<17FBPp?+)fP>JXwoxuX;w=3Fc|Zgv{QX@cR!-hSqO=Qt-KO*Wc^;@ObdK33xx8UwO7F|8#WJ zY}2_QRxH(wy^&S*RHBFoOfGjUc{`Z2tzMXRED?J$bK`xT5h%i7_I;{dH${M~7+HGj z$h_aw9sAzHkiESP7|bers=hn8HF$gSa%g*h*e0m|lg4$&#Psy;Q}^~TaQ?IZ&lU5g z-80FG!ViB{m2JT86gGd_-PG`GjIPalWR{za9+fI)Do|OcR|P(P7 zxW;j$;Zwn#?za6IBYxOz#UR6=6?kiOf2{X=96tLwJ7~(QU4nVpreT-j*vV z2_#5ns31^@iJ7>6dD(sHzh5}DfBMKyKJ&dwj#=6Nz#%yLC>si$TKDkv%KZw-cZFXd zfS+7*lAoznuTwbi>iAr7d`Hm{Sto*HTX9B*hlnaefbjm&u({h-FfPMKhjCKv%~9FJ zPS$wDh=XA}LS`{SVqq$}7%jIrom!d&t-zx+6kyQw{gM`)OAaj;*KqVP@jzy|rXrX7)o`hc5d1 z(DB6{V434XaE{{B^rzns_A{;vA^{oiwc9p|51NJ(_E1)03^j5Qn-;3JQ3jko-_*A@ zw_7SkG&~F9->QcD8D=6v4;PM}?Ay9toS#N?%Hwi4TnVCaOMQBwJq}Bjj!WLO`$Gxb zX`jhJ`-~4tmTYuw^A7S_Y3<;vbwM_JkuOW(Fb? zqwIKp)EJQBd1X^z(ctX5#s8>BZrKXkphlx_)_pwMQ?qHF;h4#|C}}ah{BBIkam?WI zh%avQQ#aDCiq5c&D@QZv$^;SdhsZdn@zRo7Z{le^tzNi^H#(zagFGxIsahR)qi6ed4rwQFanXJKoAX%%N1}cxeNu7txdY zu`Uue!=Wg(;yW_XY9t#VoR!Enz*CRvAj@IRejZ;3HGx8f`sw@zuy@gW^`q=ycYcqT zH3K0iDFZH0{RCVfyZ~H?1ze~VZw0seHw5l7R145sI!+JS-e;nJ<^D|MHf0TrTU`2y z()bDg7s`Jo_)nDoGYd?%7I3@N{GTA4GIKV-xDe(oKnQ_B05G=y;`$GzpCG4n$hLrF z(12u?|0L_RwSnDX6l?m#*#zN)oBzc5&jkOWxmLUp(8Er=1xSVhNcJfW!P22Z>KWnT z_9prz_>kyOll-+tDxnj|$7|7-PJpgu;vRJ5j1E<0W`kX-kvLKUZWBIRE}SmUm)vb$AIFySrb6&iiNZ_>qg- zYlk|re2hlhQ};N{6>l*SMwRbv^j*96H!+O$nLoHP&WAnC}jy;_|>*@H@zU%4HhLv+j+^6Mm!EX)7IypJ#U^YDtGY2 zbFi&q%d(udF>;1dLSm8SGr@J3e@yNVR*p4o^Q)Z>seHGbJ^{rwUgM#A+803cI!MdC5N*J z_GomjSx*O8hrjNZ%J?2{Rhsm2Wda^JvL{~mL6iyck%P*Bg_li-k5KV#DvE8Ryp+NH zq}$cmYEoLnl=iimE{h(pD<`#o$$nD*!gXVnJ=dyoK(bAdp-dolsz$@AGlanTA>C4R zHI{gq9hDK$q3($d(T;}^&C)3$MRB_B^Yuh-tddyt z8kdtz7e8kI?N8%w2Ga(8PUt7+`n&;;m@E_TaXwRLxp_MIl)U}R)f)t5f7vmd(-jJI zcZsZfKisQ44$zzk9Oq}Be4;$IZA9m+Z-}+nyD|d((mI$u{EeeD`;`<2Zgibh;Ne1e z)g33bTKPDq3BPW-R5)1g+$!Tv9)GXu^&~eWIymHuO8x3fHzvQlK5Q=;dUn(pz5neB zDnoXW1laXjS+sDM-VskA8=b!B;n9TAxZ$dPAs<^|7GqrKqv~T_Pw%)*DD~q17 z;*X7U^ucIM%61AY-jtK+(gvT7{swCGi9*RCepUL<5_~r6kd?dAb&Z&JFE`_&d=WVe zwWX|rG0~c5bMEtZ7H=vj;9O28 zhbRs0jpNQn^YxQsOmOj{`>sUn1~**V@9?OTnu z-zbjYxpk(<){E%4mz_nq5lnn_Q}O|Po|Yx4%rQmw(5#IIUMJFc;Xzu1sI3L!UGt<0 z<>0Q&Eacr9#0g}dB@yqj0l)JNAMQcJq4IX*{AQo({XX@DEL>!u2{yJM-ke$hY2$jY zZ&%>A$P#bUf%M#i)S-)Lcag}j_``W7;MU?@KJCRmrAH-)E=UoVWL(!@l|o>6 z)VpXIE7u`*VX0L*Fej?U=|4#fi44Vr*KC8Fw`A1UT+1CBsX~Xa!Oe7;*;cs!xvF^*-Sig}aS z6bX(Naf#p89j{2+jUR)TJgt)+@pniF!pTBC%VH8qcxgR%SMQ2NujrhuDj7gGdLGkr z@aU+IJ}nyu6KhocbMs9dr{DVPp{HVnzjsxJQt_mOhi{Vf3{IS$t(NN*O+F`x67irOEMehKuHW++Zv6xtFa|b z2pk`KC{6lIq8CzMNQyHvDEM|}(WbYY(3htR?zOLVHw55KGjvHeWf~7XC3PV9$J$R=XIk}yc<7<)pNJ#G zg^M0NCiV#MJyr}=UK-Y}FJR(-(hx>v;`>>-BJ`im*Ss8y87;$aiv^)=rFJ{^Sb2Cg z5i${jj~);?^p;>&GOAp=Ji57R;Ag=*6SoBQDd>6*TH?PV^isQAT<xeNC#b`j7r)bc#yS z{$&UYNm@Tl< zp>}{yl#*@W=+#=o^>>}H)kNyQRhr;1)%(vt`5*<1*icHMHf#Yj_2*k}LbMkulc$$3#}Wr?i73^J1XO-%|uZ z-FopasmXS>e@XpQywb@XIUvQVe`CSmih3DUG$6RM6$!SHVa2{6+s<)mxAzV7!N$&6 z{^fYftwL{NXveb`oxH5i2~CtKo`97O4Sj+JluzL(#4 zv!8Xw=+$Y7X&PW1n-Tv&T*7l1H7R_pEX!rvS49a4C2 zb$h&V?{NRxoaMCL)drmVM3 z@wB|v1HA8|BL80g(7~3jn+i(1#EzxkB@W((UI!?P)>L*qD2$S39*w$-qOEmrR8X1O+jEjLJiOr9kb)1Be;}c?3m6GKl8IEXMC1qRX*WyL23!vy`c>}%gTo`D}xyyT>4{JLmZ42+% zJIgiO+ybH32nR=HdvUeCWSErV!dY{fY5XOjmrI2EJ$d1p0GmPicy{5{7CSxkt9ka< zwkVFXH+pN&KeZZf=?GgdFDXO5?CS(3&`9&cDbU)P7b+3wW51ke`92bLMqdvHY;w48 zEAdr_h|D+iPZfQB<*h;It4E+oP~|-&C~M}i-C6JDkAojoi6;$3pNN)UuU?O?K3`}G z{kkVYjXA+5ybS7>ycK%BGN95Fr1hL!&Cl%B?ocik$~FM5#RJ0WCExBZO7hA+l|6Az zT*@N6Fl7*NrVuITsq3`Wh5$=JSS%T*^NM}4{&Rmp#i&SqpP{5LT4A*+3ycf(PA996 zDn9@ha))?A;M6O>=+1QrRQ)rbFxYSq)MG@~#H3x^vhh)iz%25(q8u=31h-7WfCyU%4%$V)NVbsr zpol=eh=A!ZX3TuGP>F%@2@s`b@?S1O`2pR?kRUFT>)xgT)(;`tka&$H7zRWH!ov!D ziB;e^RIc4HZix()Hrr-pj)wWP!X5<0%^b{SiLAyF%oM1Z+6RTK_Bxsi`cO@l1%nKP zneIvkjpSRXzaQi;}*f5B3oCFy7@r;BY zU&b{PULFL;@%w>}f`O@duLk@F{Brt`3EpWCUPgunQfAhtH2Xtulh-{P!^Mt60J{Wm0IIOL^!G_Ypm*MFh}QM-|1+k=C!YaW4?j_vZ1fD(4h#TM`tW^Ql-X7 z-k?IYp5kzGLL@6Ung)Rmk!Nkp9G1AbJ(~dB(4S%hBF=D$})od%6TKshzhM z+M4|4_EzQjrjY4YDLWRB!Ut2c-Ki6_lJihYb98UY=i>G@?Dpo*1K5%4^lNJpPU2#5 zjghR$geo&--7d{q!p*ZWUH@6=%68Zag8&(}u91eCNxH6CWS;E;A(Xi4G3D27E$3p0 znw){NzlE)AcduLlo?vSA-}Mr^_ZceyrVIEFgPre{UlD4yF>8FWpec}+C)iZj}xd&yrJQsEc>QAmZ|KGfELJn z$gO-nh6bG6_D8faQ7`sE4PL zQ?+naoOam}>JKEnCv3a+6yaC@N(tN#3skONr2txm9Cl3T^|HV0Mdgz1T)}U6U&`%Hi*ky8lMmq6i+u=BCx^%Bj7 zRdv_$Dr?~8Xg#q+Sw1nI+xjDzdhyZfgUw7BWQfv*SQ{Ll&VUNJiFSYCnTMGig?&Yu zIt7MchM$9@WRzyzr;)pqPo@Yp1Bl4~3*#RVr-CA^pCSOP{@)^^21OgbcIo^rfGHPx z4vwZ1-(}SYK)r6p3Et5h+zq#(#uyr-hRp+Tz z<@2rM?(J=2Paxw0vBAh*%GNxM@ng-VX*4@hc4Yom?j(3pm;d}BZJ_VeeknYs6y9VI zhfYLa(V;G+jsq{W($o%nD-g|dtz1DFRrh9HMC6i7P!JA8ia>*~!u`2srv;CI%JY0> z$>ZaFD{9JBw23bkBCy zY^&VPVAyX3ox#|cZN@`nxt*C8fj0p+>>#Z2I%35Gn={E#q?8PPvv7y^1(di8R$VA5~ zY(Pr+_5J2m-bL{r9ZI>!03Gj+|I+b3wo%Va{Qn@~oUfkgJ9PgnpRB0k{g)MAQa`PD zQ2ZCfLn*+DFaHlKhV>7EhYlc)L+cZ6EWAkF$0x4Wu!mMGy7l2&_}a4}wO;PwX2 zc-l0K4arjS=39g#o=Cm=M7$;GM2OlaYMnSOT@6}e{h7fhYNfy0Kd3|4Nrk6>q1J`O z_06aA-esX%YG6WyvEd?FNLB8Zs_e+C(D2Uu-vc#nJXG2qNr(0$f_%~Mt(~-Q& z2GBYP19+(@0{d!1NpK={hbRy+fEZ*A>gBK`;kp177wt-99Oh3&jX)Iy1vbrU1&X6E zN?lH~M9YlbJ$1zoswG3|=pn!c@Q^_oD3+YmgItMddxXn^l|*H-6xU-dSWQ<(V{FoA zLY2FsX71D2dBi#@v*XnfYz86_fZ$Q+diRElQ|Ok;(u@Y>DG1zy*dfw--|o7roG$n8c|6~bb?kdXcvtldOD9^a})D=H3_M*FNo(y_;ZjBH}9L7V7ZaqGW!ln zRV!GXo-4sFss07Ekf$CmnHx=)DkM3D+Fs<2N|rZjXKZscArsSpRJ%;bsn4X?%xH}b0k z0ryfwuCg{9nxkS;w=&##qJ=^og%}z%EsUa=dPHvcbA^;2waIT0aWu3e%+mZgWu{2e z{j?drKC9gD&-#m6WkqC6Ni5uhGBt>^qZLx3W$`Ft#h>c@cCk5EG_t?= z?t_0KZ+3*=IKnB;|BmkT3DSP$mZXZ_1phGbUC#L+N_#@?udjq;hCn{HA%2;l}ga zETYi{hFtU#1exjR6pgRe6Sb=E%Vee0zWa)u%8l+qDC0}Kn5K{p7m{=q4v`86;ByBA zrnc}Urw2k&d>pI6dxh%?nu0dyHlzwFy=nOo`slORk{cVkg}+;aF_2I>hApe#_o)0z zynh3hl)H8SR>k`|>Z}4kAq{f*m-}=F#&1nExQDJ_otAYs|wJDV1@Ijpn=

ySf!864)Yw({bQbshhomyKO`k?vd7Ef@3{U@R- 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 文档版本:v1.9 +> +> 制定日期:2026-07-27 +> +> 目标交付:2026-08-01 Android 受控内测 APK +> +> 当前定位:测试版安装包,不等同于正式签名包、应用商店包或阶段一正式试点通过 +> +> 合并说明:已吸收《下一步计划-跟进意见对齐-20260727.md》和《AI物业陪练系统功能需求清单_无来源依据版》v2.0 中经会议录音、仓库和生产只读复核确认的近期事项;冲突项仍以本计划冻结边界为准 + +## 1. 决策基线 + +本计划基于 2026-07-27 会议跟进意见、当前仓库实现和后续确认形成,以下事项已经冻结: + +- 8 月 1 日交付形态为 **Android 安装包**,不以 H5 或 HBuilderX 标准基座代替。 +- 员工侧受控内测重点聚焦“查/练/问”;“学”、大喇叭和直通车复用现有链路完成必要冒烟,不在本节点扩展完整课程、自动视频或工单闭环,不删除既有代码与数据。 +- 直通车保持现有五个通道:总裁、财务、人力、审计、运营。 +- 当前不扩展视频制作能力,不开发 AI 视频生成、视频编辑、供应商接入或平台内制作流程;已有视频只作为现有课程资料归集和使用。 +- 当前不接入自动工单。系统只能指导员工如何在既有系统中填写工单,不得展示“已创建、已派单、已送达”等未经真实外部系统确认的状态。 +- 现有知识检索已经采用 MySQL LIKE、MySQL 全文检索和 Qdrant 向量检索融合并 rerank,本轮只补合格内容、覆盖率和回归证据,不重做 RAG,也不开展“多套架构选型”。 +- Android APK、后端 JAR、管理端/H5 静态资源和数据库迁移是四个独立发布单元;先按实际依赖确定变更范围,不把“测试包交付”自动解释成“全部生产资产上线”。 +- 8 月 1 日是受控内测节点,不替代正式试点;正式试点仍需独立项目、人员、时间窗和严格统计证据。 +- “约 600 名生活顾问”只作为账号与推广规模基线,不等同于并发数;容量验收必须另行冻结在线峰值、请求并发、音视频占比和压测口径。 + +## 2. 测试版目标 + +测试版只验证四个业务结果: + +1. 员工能够通过 Android 安装包完成登录,并顺利进入“查/练/问”主路径。 +2. 员工提出政策、制度和高频业务问题时,系统能够给出简洁、有来源、权限正确的回答;无可靠依据时明确说明无法确认。 +3. 员工能够完成首批正式场景对练、评分和记录查看;运营预览、开发 seed 和标准基座结果不得计入正式员工验收。 +4. 大喇叭现有文件链路完成冒烟;员工能够通过五个直通车通道提交意见,收到明确的系统收件提示,并由已绑定的真实处理人在管理端完成一次正式回复。 + +### 2.1 交付物 + +- Android 内测 APK,版本号和 `versionCode` 均递增。 +- APK SHA-256 校验值、安装说明、已知问题清单和上一可用版本回退包。 +- 本次后端、管理端、数据库迁移和移动端资源的版本清单。 +- 测试账号角色清单,仅记录角色和用途,不在仓库保存手机号、验证码、密码或访问密钥。 +- 业务内容清单、知识空间授权清单、首批正式对练场景清单。 +- 内测验收记录和遗留问题清单。 + +### 2.2 明确不做 + +- 不交付 iOS 安装包、TestFlight 或应用商店版本。 +- 不重做现有 RAG 架构,不并行研究多套替代方案。 +- 不开发 AI 视频制作能力。 +- 不开发自动工单、自动派单或外部业务系统回执。 +- 不在本轮实现管理群机器人自动发送、群内回复回写、多轮工单或 SLA。 +- 不将 seed 场景、测试账号或单台标准基座成功记录包装成正式业务验收。 + +## 3. 当前可复用能力 + +以下能力已经存在,本轮应优先配置、收口和验收,不重新建设: + +- `mobile-uni` 已支持员工、主管和候选人端,并已具备“今日/练/问/我”基础导航。 +- “练”已经具备文字、录音、AI 对话、评分、训练记录和主管复盘基础链路。 +- “问”已经使用服务端 Agent 编排,并复用知识检索、短期会话、引用、附件、ASR 和 TTS。 +- 知识平台已经支持多知识空间、角色授权、调用应用绑定、分类和文件多空间上传。 +- 直通车已经支持五通道、业务匿名、按角色收件和一次正式回复。 +- 生产短音频 ASR 已切换到阿里云百炼 `qwen3-asr-flash`,并在一台 Android 标准基座完成过“按住说话→转写→提交”验证。 + +当前短板主要是:正式内容、生产授权配置、企业签名 RC APK、完整真机反例、当前代码完整发布以及受控内测证据。API 35 自定义基座链路已成立,但不能替代企业签名包或正式试点。 + +### 3.1 生产只读基线(2026-07-27) + +以下数字已经通过生产只读查询复核,只作为本轮排期和效果对照的日期快照,不代表业务验收通过: + +| 项目 | 当前值 | 计划含义 | +|---|---:|---| +| 最近完整生产发布源 | `230dfb4d` | 当前主分支不能直接视为已完整发布 | +| 当前 `main` / `origin/main` | `9ae5f750` | 相对完整生产基线领先 25 个提交,须逐项评估发布范围 | +| 知识片段 | 8,352 | MySQL 仍是事实源 | +| 已写入 MySQL 向量 | 3,638(43.6%) | 不能用“RAG 尚未建设”描述现状 | +| Qdrant 当前租户点数 | 3,638,1,024 维 | 与当前已向量化片段一致 | +| `aihr_knowledge_category` | 0 行 | 分类能力有结构和管理接口,但生产尚未运营 | +| 行政管理制度空间 `1008` | 4,260 片段 / 990 向量(23.2%) | 政策问答的首要补齐对象 | +| 培训视频空间 `1009` | 2,571 / 1,286 | 补向量前先核验解析质量 | +| 图片素材空间 `1010` | 153 / 0 | 无可靠视觉结果不得直接向量化 | +| SOP 空间 `1201`、`1202` | 各 3 / 0 | 内容 READY 且审核后可优先补齐 | +| 直通车反馈 | 2 条 | 不能据此证明真实处理闭环已经稳定运行 | + +生产短音频 ASR 热修复和单台 Android 标准基座请求只证明局部链路;自定义基座权限、弱网、完整 Agent 回归和本计划真机门禁仍需独立完成。 + +### 3.2 RC 自动化门禁快照(2026-07-29) + +本节记录当前工作区自动化和测试签名自定义基座结果,不代表企业签名 RC APK 或完整真机验收完成。 + +| 门禁 | 结果 | 说明 | +|---|---|---| +| `mobile-uni` 单元测试 | 通过 | 191/191;测试过程有本机 WebSocket 端口占用告警,但无失败 | +| `mobile-uni` TypeScript 检查 | 通过 | `npm --prefix mobile-uni run typecheck` | +| H5 资源构建 | 通过 | 仅证明 H5 资源可编译 | +| App-Plus 资源构建 | 通过 | 仅生成 HBuilderX 出包资源,不等同于 APK | +| App 原生配置、图标与启动图校验 | 通过 | 固定包名、语义版本、API 35、双 ABI、启动权限策略、Camera/Record 模块、22 个图标和 14 个启动图 | +| 自定义基座 APK 结构与签名校验 | 通过 | `verify:android-apk` 已核对包名、版本、`targetSdkVersion`、单签名、v2 签名和 SHA-256 | +| `390×844` 未登录首屏与入口 | 通过 | 今日主操作、练、问、问制度快捷项、大喇叭和直通车登录门禁均在首屏可见;练、问已实际点击可达,无控制台错误 | +| 登录后核心业务路径 | 未执行 | 当前没有可用的本地 APP 登录主体和后端会话;不得用伪造手机号或未认证预览冒充通过 | +| 管理端生产构建 | 通过 | 存在大 chunk 告警,不阻断本轮构建 | +| 工单边界后端定向测试 | 通过 | 39 个测试,0 失败、0 错误 | +| `ruoyi-aihr` 后端全量测试 | 通过 | 696 个测试,0 失败、0 错误;Windows CRLF 引发的 7 个源码契约误判已通过读取时统一 LF 修复并全量复跑 | +| 法务配置技术链路 | 通过 | 登录协议默认不勾选;协议未配置时禁止验证码与登录;配置生成器和发布联网校验已接入,发布包拒绝开发验证码提示 | +| 正式 App 发布配置校验 | **技术通过,法务签认阻断** | 受控内测版协议正文已通过 Caddy 公网 HTTPS 发布,原生弹窗、登录页和联网门禁使用同一组地址;公司法务尚未正式签认正文,不能视为正式发布通过 | +| Android 设备、调试基座、测试签名和真机门禁 | **部分通过,仍阻断** | HBuilderX 5.22 已识别 vivo V2443A(Android 15);API 35 自定义基座和可独立安装的 DCloud 云证书内测 APK 均已生成。干净启动真实显示原生隐私弹窗,两个协议链接可打开;用户同意/拒绝、登录后录音允许/拒绝和完整反例仍未通过 | + +### 3.3 Android 真机预检快照(2026-07-28) + +- HBuilderX CLI `5.22.2026072503-alpha` 已识别唯一连接的 vivo V2443A,标准调试基座完成“编译 → 同步 → 启动”,当前 App-Plus 页面可在真机正常渲染。 +- 真机物理分辨率为 `720×1600`;员工端首页的今日主操作、登录入口、状态栏和底部手势区未见白屏或明显遮挡。该结果不替代 `390×844` 首屏门禁和其他机型覆盖。 +- 未配置正式法务地址时,登录页协议默认未勾选,页面明确提示“当前暂停验证码发送与登录”;点击协议入口只显示配置缺失提示,不会跳转占位地址。 +- DCloud 云端证书自定义基座首版已安装并以 `com.yincheng.wygj`、`0.1.5 (105)` 启动,证明云打包、签名、USB 安装、资源同步和自有包名链路可用;该首版系统读取到默认 `targetSdkVersion=28`,不能代表 Android 15 权限模型。 +- `mobile-uni/src/manifest.json` 已显式固定 `targetSdkVersion=35`,并把设备信息与外部存储权限设为启动时不申请;相机和录音模块继续保留,只能由具体用户操作触发系统授权。上述配置已进入自动化门禁。 +- 2026-07-28 最终 API 35 自定义基座位于 `mobile-uni/dist/debug/android_debug.apk`,大小 `21,158,193` 字节,SHA-256 为 `82D3352FF96A86F5DB4AC0C71DCB0ED6CEC98F2A785F6EAED2FE8D4FC0C5F704`。`npm --prefix mobile-uni run verify:android-apk` 已验证包名、`0.1.5 (105)`、`targetSdkVersion=35`、单签名和 APK Signature Scheme v2。 +- 新基座已在同一设备完成保留数据的覆盖安装和系统 Launcher 冷启动;Android 系统读取 `minSdk=21`、`targetSdk=35`,启动后设备信息、存储、相机和录音权限均为未授权,且未出现启动权限弹窗。员工端首屏、状态栏和底部手势区正常渲染,未见白屏、崩溃或 `FATAL EXCEPTION`。 +- 登录页协议仍默认未勾选,法务地址缺失时明确阻止验证码与登录。Android 15 拒绝从 ADB 直接启动未导出的内部 Activity,使用系统 Launcher 入口可正常启动,符合平台安全边界。 +- 受控内测版《服务协议》《隐私政策》已作为静态页面发布到 `https://peilian.njzhmj.top/legal/`;发布前已备份整个站点到 `/opt/wygj/backups/www-20260728224710`,未修改 Caddy 配置、后端、数据库或 H5 业务资源。页面可公开访问不等同于公司法务签认。 +- `configure:app-privacy` 已修正为写入 uni-app CLI 输入目录 `mobile-uni/src/androidPrivacy.json`,并同时核对 `dist/build/app/androidPrivacy.json`;APK 门禁区分“只含原生运行壳的自定义基座”和“内置业务资源的独立测试包”,避免再以 DCloud 默认空链接产生假绿。 +- 2026-07-28 23:12 生成的独立 DCloud 云证书内测 APK 位于 `mobile-uni/dist/debug/bangdao-0.1.5-dcloud-test.apk`,大小 `33,920,950` 字节,SHA-256 为 `AB634EB9A9F0939804151ED14815484FBD75DFE36CC24DF00EC23BDD762D8FD1`。脚本已验证包名、`0.1.5 (105)`、API 35、非调试、单签名、v2 签名和包内两个法务地址。 +- 该内测 APK 已在 vivo V2443A 完成同签名覆盖安装;只清除本 App 测试数据后,以系统 Launcher Activity 干净启动,原生 template 隐私弹窗真实显示,服务协议与隐私政策链接均通过 vivo 系统浏览器打开线上正文,未见 `FATAL EXCEPTION`。验收过程未代替用户点击同意或拒绝。 +- 2026-07-29 当前候选包升级为 `0.1.6 (106)`,位于 `mobile-uni/dist/debug/bangdao-0.1.6-dcloud-test.apk`,大小 `33,921,279` 字节,SHA-256 为 `A5F026B70F11327355597B9FEA3625C4168700297E6D7847F10B079CEB860914`。登录页法务校验已移除 App-Plus 不保证支持的浏览器 `URL` 构造器,测试验证码不再以源码常量进入包内;APK 反编译门禁已确认包内业务资源等于当前编译产物,并通过法务链接、签名和敏感值扫描。 +- `0.1.6 (106)` 已在同一设备完成保留数据的覆盖安装和 Launcher 冷启动;相机、录音和媒体权限保持未授权,未见 `FATAL EXCEPTION`。覆盖安装保留了前一版本的隐私状态,因此最新包的拒绝/同意反例仍须由用户亲自执行。 +- 本机 Android Studio `Medium_Phone` 虚拟机已启动为 Android 15/API 35(`1080×2400`、密度 `420`)。在此前未安装本应用的虚拟机上,`0.1.6 (106)` 首次安装和 Launcher 冷启动成功,最新包真实显示原生隐私弹窗,四项相机、录音、图片和视频权限均未授予,crash 缓冲区为空。虚拟机只补充干净安装和 API 35 权限基线,不替代真机媒体、WebRTC、弱网与系统浏览器验收;拒绝/同意仍由用户亲自执行。 +- 自定义基座和独立内测 APK 均使用 DCloud 云端测试证书,不是企业正式签名 APK;独立内测包的 DCloud 运行壳仍声明允许明文流量,因此只可用于 8 月 1 日受控内测。公司法务签认、企业签名、用户实际同意/拒绝、登录后权限反例仍是正式发布阻断。 + +## 4. P0 工作分解 + +| ID | 任务 | 责任角色 | 截止时间 | 完成门槛 | +|---|---|---|---|---| +| P0-01 | 冻结范围与入口 | 产品负责人、移动端 | 7 月 27 日 | 首页突出查/练/问,学、大喇叭与五通道复用现有链路,次要入口隐藏但可恢复 | +| P0-02 | Android 自定义基座与 APK | 移动端、发布负责人 | 7 月 30 日首包,7 月 31 日 RC | HBuilderX 5.22、自定义基座、测试签名、安装升级和权限反例通过 | +| P0-03 | 政策知识空间与问答 | 知识运营、后端、业务审核人 | 7 月 30 日 | 正式空间、授权、首批分类、合格内容向量补齐和不少于 30 道标准问题通过 | +| P0-04 | 首批正式对练内容 | HRBP、业务专家、训练运营 | 7 月 30 日 | 至少 5 个生活顾问高频场景完成来源、Rubric、红线和审核 | +| P0-05 | 24 位优秀生活顾问访谈 | HRBP、业务专家、内容运营 | 7 月 29 日完成访谈 | 24 份录音/纪要及结构化提炼任务全部建档 | +| P0-06 | 五通道直通车收口 | 业务负责人、后台运营 | 7 月 30 日 | 五类真实处理人完成绑定,各通道正反权限与一次回复通过 | +| P0-07 | 工单边界纠偏 | 产品负责人、后端、内容运营 | 7 月 29 日 | 全局不再暗示系统自动创建、派发或送达工单 | +| P0-08 | 安全与发布差异收口 | 发布负责人、后端、运维 | 7 月 31 日 | 四类发布单元清单和 25 个提交影响完成签认;所选发布范围完成备份、预检和回退验证 | +| P0-09 | 真机回归与内测交付 | QA、移动端、项目负责人 | 8 月 1 日 | 核心路径全通过,无阻断级缺陷,APK/校验值/说明可交付 | + +### 4.1 P0-01:入口精简与范围冻结 + +员工端保留账户、设置和必要导航能力,但首屏只突出: + +- **学/练**:课程学习、政策学习、场景练习和每日训练。 +- **问**:政策制度、业务 SOP 和工作问题的统一入口。 + +本轮不做大规模信息架构重写。次要功能采用隐藏入口或降级展示,保留路由、权限和数据,便于后续恢复。必须在 `390×844` 视口复核首屏,核心入口无需滚动即可看到并能实际点击。 + +### 4.2 P0-02:Android 安装包 + +移动端和发布负责人应完成: + +1. 使用 HBuilderX 5.22 重新生成并安装自定义运行基座。 +2. 执行 App 资源校验和 App-Plus 构建,阻止“命令成功但未生成有效资源”的假成功。 +3. 配置测试签名、应用标识、版本号、图标、启动图、原生隐私弹窗和麦克风/相机/相册/文件用途说明。 +4. 确认 App 只请求正式 HTTPS 后端地址,安装包中不包含短信、模型、数据库、签名或其他服务端密钥。 +5. 生成 Release Candidate APK,并记录文件大小、SHA-256、构建源提交和构建时间。 +6. 保留上一可用 APK,准备安装失败、启动崩溃或关键接口异常时的回退说明。 + +8 月 1 日交付的是内部受控测试 APK。法务 HTTPS 页面已经技术上线并进入原生弹窗,但在未完成公司法务签认、企业正式签名和完整真机门禁前,不得描述为正式发布包或商店上架包。 + +### 4.3 P0-03:政策制度知识空间 + +优先复用现有多知识空间能力,建议建立并确认以下业务空间: + +- 全员制度; +- 业务 SOP; +- 人资政策; +- 优秀经验。 + +以上名称是内容归属维度,不等同于 `employee`、`supervisor`、`hr_operator`、`superadmin` 等人员角色。7 月 28 日前必须由业务与人力确认“内容空间—调用应用—可见主体—管理责任人”矩阵,禁止把“全员/业务/人资”直接硬映射成现有角色或由客户端自行指定权限。 + +每个空间必须有业务负责人、可见主体、调用应用、内容版本和下线责任人。文件可同时绑定多个空间,但最终检索范围必须是“租户 + 调用应用 + 当前主体授权”的交集。 + +当前生产已经运行 LIKE、全文检索、Qdrant 和 rerank,不再建设第二套 RAG。向量补齐按以下顺序执行: + +1. 优先处理行政管理制度空间 `1008`,以及已经 READY、来源明确、审核通过的 `1201`、`1202` SOP。 +2. 培训视频 `1009` 和图片素材 `1010` 必须先抽查解析正文、OCR/视觉结果和状态;内容不可用时保持待处理,不把噪声写入向量库。 +3. 每批补齐后同时核对 MySQL `embedding_json` 数量、模型与维度,以及同租户 Qdrant 点数。完成标准是“Qdrant 点数 = MySQL 已向量化片段数且维度匹配”,不是未经内容资格判断就强求点数等于全部 8,352 条片段。 +4. 保留补齐前后的固定问题集结果、命中片段和引用,不以点数增长代替回答质量验收。 + +生产 `aihr_knowledge_category` 当前为 0 行,但这张表记录的是空间内文档分类。现有授权检索仍调用 `dbSearch("", ...)`,底层 `category` 条件过滤的是知识空间名称 `i.name`,并不会因为分类表有数据就自动按文档分类收窄。测试版按以下边界处理: + +- 分类表先用于内容治理、负责人和文档归类,首批正式政策文件必须完成分类; +- “问制度”继续通过服务端绑定政策空间和主体授权实现,不复制问答后端; +- 若要让文档分类直接影响检索,需要另行完成请求契约、SQL 关联、Qdrant payload 和存量索引迁移设计,不能按“一行解除硬编码”估算; +- 该分类检索增强不阻塞 8 月 1 日 APK,只要政策空间隔离、30 题回归和越权反例已经通过。 + +测试版至少准备 30 道政策与制度标准问题,覆盖正常问题、同义表达、矛盾资料、无依据问题和越权问题。验收要求: + +- 同一问题集保留向量补齐前后的结果,能够解释命中与退化原因; +- 有答案时必须给出可核验引用; +- 无依据或资料冲突时不得编造; +- 跨空间越权命中为 0; +- 业务人工抽检可用率不低于 80%; +- 回答保持简洁,优先给结论、适用范围和来源。 + +“问制度”应作为“问”中的快捷入口,通过服务端绑定授权空间实现,不复制一套新的问答后端。 + +### 4.4 P0-04:首批正式对练内容 + +8 月 1 日前至少发布 5 个生活顾问高频场景,优先覆盖: + +- 业主拒绝提供楼栋房号; +- 物业费争议; +- 报修久未处理; +- 情绪激烈投诉; +- 收费沟通与服务整改并行。 + +每个正式场景必须包含真实业务来源、目标、红线、成功标准、回合设计、能力项和五维 Rubric,并完成业务审核。现有 seed 只能用于调试,不得直接标记为正式内容。 + +### 4.5 P0-05:24 位优秀生活顾问访谈 + +建议设置 4 个并行访谈小组,每组由一名主访谈人和一名记录/追问人组成。每组每天完成 3 人,两天共完成: + +`4 组 × 3 人/天 × 2 天 = 24 人` + +每次访谈不少于 1 小时,输出: + +- 原始录音或经授权的完整纪要; +- 具体业务情境和触发条件; +- 当事人的真实判断、动作顺序和关键话术; +- 无效做法、风险红线和升级条件; +- 可转成政策问答、标准动作卡、对练场景或知识缺口的候选项; +- 内容负责人和业务审核人。 + +访谈完成不等于内容已发布。运营与专家团队必须去重、脱敏、核实来源并完成业务审核,才能进入正式知识空间或场景库。8 月 1 日前只发布审核完成的首批内容,其余进入后续内容迭代。 + +24 人访谈是业务与内容团队的外部输入任务,不占用开发交付工时;技术团队只负责提供录音/纪要的受控接收方式,并导入已经脱敏、归类且完成业务审核的成果。 + +### 4.6 P0-06:五通道直通车 + +保持现有总裁、财务、人力、审计、运营五个通道,不增删通道。测试版完成: + +- 五类真实处理人员绑定; +- 员工提交成功后显示明确的“已收到,等待处理”系统回执;该提示不是 AI 正式回复,状态仍为 `SUBMITTED`,人工回复前 `reply_content` 保持为空; +- 匿名仅隐藏处理端展示名,服务端继续保留内部身份用于本人查询、幂等和审计; +- 每个通道至少执行一次授权正例和一次越权反例; +- 每条反馈只允许一次正式回复,员工可以查看处理结果。 + +会议提出的每日匿名简报在测试版先采用人工运营流程:后台运营按固定时间汇总、脱敏后发至管理层群组;群组只作通知,正式回复仍在系统内完成。本轮不开发群机器人、自动群发或群回复回写。 + +### 4.7 P0-07:工单边界 + +对模型 Prompt、seed、SOP 示例和页面文案进行全局检查,将容易误解为系统自动执行的内容调整为: + +> 请在现有工单系统中记录房号、问题、现场证据、责任部门和首次反馈时限;帮道仅提供填写建议,不会代为创建或派发工单。 + +验收时检查: + +- 不出现“已创建工单”“已派单”“已送达”等虚假状态; +- 公司范围记录保持 `PENDING` 时,不得解释为外部系统已经收到; +- 回答可以指导如何写工单,但不提供未经授权的强动作入口。 + +### 4.8 P0-08:安全与发布差异收口 + +- 轮换此前在沟通中暴露过的云服务访问密钥,并确认仓库、日志、安装包和前端资源均不包含明文密钥。 +- 密钥只通过服务器安全环境注入;移动端只包含公开 API 地址。 +- 当前 `main` / `origin/main` 为 `9ae5f750`,相对最近完整生产发布源 `230dfb4d` 领先 25 个提交;其中 `7b014765` 的 Agent 追问修复必须发布后端 JAR 才能在生产生效。发布负责人必须从最终 release commit 生成差异清单,不得把 25 个提交无差别一次性推入生产。 +- APK、后端 JAR、管理端/H5 和 schema 分别记录“是否变更、构建源、目标环境、验证项、负责人和回退物”。 +- 若只做定向后端热修复,应明确记录为局部发布,不得称为当前源码与完整生产包一致;若宣称完整发布,则必须同时核对远端静态资源、后端 AIHR 模块和 schema。 +- 发布前按所选范围运行本地预检;完整包发布后运行同时启用远端静态、后端和 schema 核验的完整 `release-preflight`,不能用单项后端热修复、H5 构建成功或 APK 可安装替代完整一致性。 +- 数据库迁移先备份并按正式顺序执行;运行时 schema bootstrap 在生产保持关闭。 + +| 发布单元 | 8 月 1 日处理原则 | +|---|---| +| Android APK | 必交;使用签认后的 release commit 构建,完成自定义基座真机门禁 | +| 后端 JAR | 仅在 APK 依赖未发布接口/修复或本轮明确纳入时发布;先备份并验证回退 | +| 管理端/H5 | 不因 APK 出包自动发布;只有本轮功能或完整包一致性需要时才进入发布 | +| 数据库迁移 | 仅执行 release commit 所需正式 SQL;生产请求不得运行时补表补列 | + +## 5. 倒排日程 + +| 日期 | 业务与内容 | 产品与开发 | 测试与发布 | +|---|---|---|---| +| 7 月 27 日 | 确定内容负责人、访谈名单、五通道处理人 | 冻结范围;记录生产基线与 25 个提交差异 | 确定测试机、测试账号角色和四类发布单元清单 | +| 7 月 28 日 | 启动 4 组访谈;确认空间/授权矩阵;形成 30 题回归基线 | 配置知识空间;筛选可补向量内容;精简入口和工单文案 | 生成 HBuilderX 5.22 自定义基座并开始权限测试 | +| 7 月 29 日 | 完成 24 人访谈;首批内容去重、脱敏、分类 | 优先补齐合格制度/SOP 向量;完成政策快捷入口和直通车配置 | 复核 MySQL/Qdrant 一致性,完成第一轮 App 主路径和反例测试 | +| 7 月 30 日 | 审核不少于 30 道政策题和至少 5 个对练场景 | 生成首个 Android 内测 APK | 执行补齐前后问答对照,以及安装、登录、权限、ASR/TTS、附件和五通道回归 | +| 7 月 31 日 | 业务签认首批内容和已知问题 | 冻结 release commit,修复阻断缺陷并生成 RC APK | 按发布单元执行预检/发布/回退验证;完整发布时运行完整 `release-preflight` | +| 8 月 1 日 | 选定一个项目开展受控内测 | 只处理阻断故障,不临时扩需求 | 现场安装、冒烟、问题登记和 Go/No-Go 决策 | + +## 6. 安装包验收清单 + +### 6.1 安装与原生能力 + +- [ ] APK 文件名、版本号、`versionCode`、SHA-256 和构建源提交可追溯。 +- [ ] 全新安装、覆盖安装、卸载重装均可正常启动。 +- [x] 干净首次启动显示原生隐私弹窗,服务协议和隐私政策链接均可通过系统浏览器访问。 +- [ ] 用户实际拒绝后退出、同意后才进入登录,并保留相应验收证据。 +- [ ] 同意麦克风后可录音;拒绝后不循环申请,并提供明确的文字或文件降级。 +- [ ] 图片、视频、文档和音频选择取消后不残留错误状态。 +- [ ] 前后台切换、网络断开和恢复不出现白屏或重复提交。 +- [ ] 图标、启动图、状态栏和底部手势区显示正常。 + +### 6.2 核心业务路径 + +- [ ] 手机号登录、验证码错误、登录失效重登符合预期。 +- [ ] 查/练/问在 `390×844` 首屏无需滚动即可进入;学、大喇叭和直通车现有入口可达。 +- [ ] 至少 5 个正式生活顾问场景可开始、完成、评分并进入主管复盘。 +- [ ] “问制度”标准问题有引用,无依据问题明确拒绝编造。 +- [ ] 固定 30 题已经保留向量补齐前后结果,业务人工抽检可用率不低于 80%。 +- [ ] Qdrant 点数与 MySQL 已向量化片段数一致,向量维度与当前模型匹配;图片/视频待处理内容未被强行向量化。 +- [ ] 录音 ASR、答案 TTS 和设备语音降级可恢复。 +- [ ] 五个直通车通道均可提交,匿名展示、角色收件和一次回复符合权限。 +- [ ] 系统不显示任何未经外部系统确认的工单创建、派发或送达状态。 + +### 6.3 发布门禁 + +- [ ] App 静态资源预检与 App-Plus 构建通过。 +- [ ] APK、后端 JAR、管理端/H5 和 schema 的变更范围、构建源、验证结果与回退物已逐项签认。 +- [ ] 若本轮宣称完整包发布,管理端、移动端、后端和 schema 的完整 `release-preflight` 已通过。 +- [ ] 关键自动化、类型检查和受影响模块测试通过。 +- [ ] 当前正式 HTTPS API 健康检查通过。 +- [ ] 无阻断级缺陷;一般问题已进入已知问题清单并有负责人。 +- [ ] 上一可用版本和回退步骤已经验证。 + +## 7. Go/No-Go 标准 + +只有同时满足以下条件,8 月 1 日才可对内发布测试 APK: + +- APK 可安装、可启动、可登录,核心路径全部通过; +- 学/练、政策问答和五通道直通车无阻断缺陷; +- 知识空间越权命中为 0,回答来源可追溯,固定 30 题人工抽检可用率不低于 80%; +- 密钥、手机号、验证码和内部账号信息未进入安装包、仓库、截图或测试报告; +- 四类发布单元已经签认;实际变更项预检通过并具备可执行回退方案。 + +出现以下任一情况应暂停发布: + +- 安装或启动失败; +- 登录、录音、政策问答或直通车主链路不可用; +- 发现跨租户、跨空间或跨角色数据泄漏; +- 安装包包含服务端密钥; +- 无法确认当前 APK、后端或数据库版本; +- 无可用回退包。 + +## 8. 主要风险与应对 + +| 风险 | 影响 | 应对 | +|---|---|---| +| 正式内容审核来不及 | 测试版只有技术壳,无法验证业务价值 | 先保证 30 道政策题和 5 个高频场景,不用 seed 冒充正式内容 | +| 自定义基座或签名资产延迟 | 无法形成可信安装包 | 7 月 28 日前完成资产清单和首轮基座,缺失项当天升级 | +| 24 人访谈完成但提炼不足 | 内容大量堆积,无法发布 | 访谈与提炼分开记账,只发布审核完成的首批内容 | +| 25 个提交一次性进入生产 | 混入未经真机或业务验收的原生、实时对练和其他变更 | 从 release commit 生成按发布单元的差异清单,逐项决定发布、暂缓和回退 | +| 盲目补齐全部向量 | 图片/视频低质量解析被放大,检索噪声增加 | 制度/SOP 优先,图片/视频先验正文和状态;用固定问题集验证收益 | +| 分类表有数据但检索未使用 | 形成“已分类即生效”的假完成 | 区分内容治理分类与检索过滤,后者另做契约、SQL、payload 和存量索引设计 | +| 知识空间授权错误 | 误查或泄漏 | 每个空间执行授权正例、越权反例和应用绑定验证 | +| 直通车处理人未绑定 | 员工提交后无人处理 | 8 月 1 日前五类通道必须有真实负责人和替补 | +| 临时新增视频或工单需求 | 破坏节点和验收边界 | 统一进入后续需求池,不插入本轮发布 | + +## 9. 8 月 1 日后计划 + +### 9.1 第一周:内测修正与内容扩充 + +- 汇总内测问题,优先修复阻断缺陷、权限问题和高频误答。 +- 完成 24 人访谈成果的去重、脱敏、分类和业务审核。 +- 按真实查询日志补充政策问答、业务 SOP 和知识缺口。 +- 对剩余合格片段分批补向量,持续保持 MySQL 已向量化数量与同租户 Qdrant 点数一致。 +- 扩充正式对练场景,不降低来源、红线和审核门槛。 +- 继续采用人工每日匿名简报,验证管理层实际使用方式。 + +### 9.2 后续专项 + +- 若人工简报证明有效,再单独设计自动摘要、管理群通知、负责人状态和审计,不直接把群聊当作业务事实源。 +- 若业务确认需要按文档分类收窄检索,再单独实现分类请求契约、附件分类关联、Qdrant payload 和存量重建;不把空间名称过滤冒充文档分类过滤。 +- 内部流程和目标系统完成对接后,再单独立项自动工单与正式回执。 +- 视频制作能力继续保持不在当前系统范围;如未来重新提出,应另做需求、成本、版权和供应商评估。 +- 准备正式试点:选定 1–2 个项目、至少 20 名正式员工和明确起止日期,产生真实训练、人工校准、SOP 评审、满意度和案例样本后运行严格门禁。 + +## 10. 责任确认 + +发布前必须补齐以下实名责任人,但不得在公开测试材料中记录账号密钥或个人敏感信息;QA 与 Go/No-Go 决策人负责最终签认: + +- 项目总负责人; +- 产品范围负责人; +- Android 构建与签名负责人; +- 后端与数据库发布负责人; +- 知识空间管理员; +- HRBP 访谈负责人; +- 政策内容审核人; +- 生活顾问场景业务审核人; +- 五通道处理人及替补; + +## 11. 事实源 + +- 会议输入:`帮道- 跟进的意见.txt`; +- 对照分析输入:`下一步计划-跟进意见对齐-20260727.md`; +- 2026-07-27 生产只读查询:知识片段/向量/Qdrant/分类/直通车计数快照(不包含密钥或个人数据); +- 当前实现与剩余门槛:`docs/BRD_IMPLEMENTATION_AUDIT.md`; +- 发布状态与变更记录:`docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md`、`CHANGELOG.md`; +- Android 出包边界:`docs/MOBILE_APP_PACKAGING.md`; +- 原生真机验收:`docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`; +- 知识空间运行:`docs/KNOWLEDGE_PLATFORM_RUNBOOK.md`; +- 接口契约:`docs/API_INTEGRATION.md`; +- 正式试点:`docs/P0_PILOT_ACCEPTANCE_CHECKLIST.md`; +- 阶段一陪练范围:`docs/AI陪练完整交付计划-20260724.md`。 diff --git a/docs/生活顾问调研转化清单-20260728.md b/docs/生活顾问调研转化清单-20260728.md new file mode 100644 index 00000000..9a4a92e1 --- /dev/null +++ b/docs/生活顾问调研转化清单-20260728.md @@ -0,0 +1,34 @@ +# 生活顾问调研转化清单 + +> 更新日期:2026-07-28 +> +> 来源状态:已收到 1 份生活顾问访谈,目标为 24 份。 +> +> 数据边界:原始访谈保留在项目仓库外,不复制姓名、联系方式、家庭情况、健康信息、门牌、门禁或密码等个人与住户敏感信息。本清单只保存脱敏后的业务候选项。 + +## 使用方式 + +当前材料只适合进入“候选池”,不能直接作为正式 SOP、知识答案或对练评分标准。每个候选项必须补充公司制度或业务专家依据,完成来源、适用范围、风险红线和审核人后,才能发布到知识空间或训练任务。 + +| 候选主题 | 建议产物 | 当前可提炼能力 | 必补依据与审核 | 状态 | +|---|---|---|---|---| +| 不同业态的巡检方法 | SOP + 对练场景 | 高层、别墅、商铺按动线、天气和客流调整巡检 | 项目巡检制度、频次、留痕要求;项目负责人审核 | `PENDING_REVIEW` | +| 大件与装修垃圾协调 | 政策知识 + 情景对练 | 识别责任主体、解释规则、协调清运 | 属地规定、园区公示、收费与责任边界;法务/运营审核 | `PENDING_REVIEW` | +| 雨季防汛与空置房巡查 | SOP + 每日题 | 预警、巡查、上报、闭环 | 防汛预案、空置房授权、紧急处置层级;工程/品质审核 | `PENDING_REVIEW` | +| 邻里与停车冲突调解 | 多轮对练 | 倾听、降级冲突、事实记录、升级处理 | 客诉制度、报警/上报边界、禁用承诺;客服/法务审核 | `PENDING_REVIEW` | +| 物业费沟通与分期协调 | 话术库 + 对练 | 解释费用、处理异议、记录约定 | 收费政策、优惠权限、分期审批;财务/客服审核 | `PENDING_REVIEW` | +| 突发事件响应 | SOP + 应急对练 | 先保安全、再报告、按预案协同 | 消防、治安、医疗等专项预案;安全负责人审核 | `PENDING_REVIEW` | +| 政策文件快速理解 | 知识卡 + 问答题 | 摘要、适用对象、办理步骤和引用来源 | 正式文件、发布日期、有效期和适用项目;知识运营审核 | `PENDING_REVIEW` | +| 数字化工具使用辅导 | 操作卡 + 新人练习 | 常用系统操作、异常上报和结果确认 | 当前系统截图、权限边界、版本号;系统管理员审核 | `PENDING_REVIEW` | + +## 进入 8 月 1 日内容包的条件 + +1. 只选择能在 2026-07-30 前补齐权威来源和审核人的主题。 +2. 至少形成 5 个正式场景;每个场景必须有角色、目标、Rubric、红线、来源和版本号。 +3. 居民个案必须改写为合成情境,不得保留可识别的人、房、健康或访问凭据。 +4. 涉及费用、消防、治安、医疗、隐私和钥匙/门禁的内容必须经过对应业务或法务审核;AI 不替代现场预案和授权。 +5. 未审核项只保留在候选池,不进入员工可见知识库、题库、对练或模型提示词。 + +## 后续采集模板 + +后续 23 份访谈统一只提炼以下字段:岗位/业态、任务、触发条件、标准动作、例外、升级路径、依据、常见错误、可训练点、审核人。原始录音和纪要按公司授权存放,不把个人隐私或住户案例明文提交到本仓库。 diff --git a/frontend/public/legal/legal.css b/frontend/public/legal/legal.css new file mode 100644 index 00000000..b1fb2328 --- /dev/null +++ b/frontend/public/legal/legal.css @@ -0,0 +1,194 @@ +:root { + color-scheme: light; + font-family: + "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif; + color: #202124; + background: #f5f7f8; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-width: 320px; + line-height: 1.75; + background: + radial-gradient(circle at 12% 0%, rgba(210, 0, 31, 0.08), transparent 34rem), + #f5f7f8; +} + +a { + color: #b9001c; + text-underline-offset: 0.18em; +} + +.page { + width: min(100% - 32px, 820px); + margin: 36px auto; + padding: 48px clamp(24px, 6vw, 64px); + background: #fff; + border: 1px solid #e5e8eb; + border-radius: 24px; + box-shadow: 0 18px 56px rgba(29, 38, 48, 0.08); +} + +.eyebrow { + margin: 0 0 8px; + color: #d2001f; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.14em; +} + +h1 { + margin: 0; + color: #17191b; + font-size: clamp(30px, 7vw, 48px); + line-height: 1.22; + letter-spacing: -0.03em; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + margin: 20px 0 0; + padding: 14px 18px; + color: #5a626b; + font-size: 14px; + background: #f7f8f9; + border-radius: 14px; +} + +.lead { + margin: 30px 0; + color: #3d4349; + font-size: 17px; +} + +.notice { + margin: 28px 0; + padding: 18px 20px; + color: #4e3a10; + background: #fff8e8; + border: 1px solid #f4dda6; + border-radius: 14px; +} + +h2 { + margin: 38px 0 12px; + padding-top: 4px; + color: #202124; + font-size: 22px; + line-height: 1.45; +} + +h3 { + margin: 24px 0 8px; + color: #30353a; + font-size: 17px; +} + +p, +li { + color: #3d4349; +} + +ul, +ol { + padding-left: 1.4em; +} + +li + li { + margin-top: 8px; +} + +.table-wrap { + overflow-x: auto; + margin: 18px 0 26px; + border: 1px solid #e5e8eb; + border-radius: 14px; +} + +table { + width: 100%; + min-width: 620px; + border-collapse: collapse; + font-size: 14px; +} + +th, +td { + padding: 14px 16px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid #eceff1; +} + +th { + color: #25292d; + background: #f7f8f9; +} + +tr:last-child td { + border-bottom: 0; +} + +.contact { + padding: 18px 20px; + background: #f7f8f9; + border-radius: 14px; +} + +.footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; + margin-top: 48px; + padding-top: 24px; + color: #7a828a; + font-size: 13px; + border-top: 1px solid #eceff1; +} + +@media (max-width: 560px) { + .page { + width: 100%; + margin: 0; + padding: 32px 20px 44px; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .lead { + font-size: 16px; + } + + h2 { + margin-top: 32px; + font-size: 20px; + } +} + +@media print { + body { + background: #fff; + } + + .page { + width: 100%; + margin: 0; + padding: 0; + border: 0; + box-shadow: none; + } +} diff --git a/frontend/public/legal/privacy.html b/frontend/public/legal/privacy.html new file mode 100644 index 00000000..46bee2f9 --- /dev/null +++ b/frontend/public/legal/privacy.html @@ -0,0 +1,169 @@ + + + + + + + + 帮道隐私政策 + + + +

+

帮道 · 个人信息保护

+

帮道隐私政策

+
+ 版本:2026-07-28 + 生效日期:2026-07-28 + 适用范围:帮道员工端 Android 受控内测 +
+ +

+ 南京银城物业服务有限公司(以下简称“我们”)重视你的个人信息和隐私安全。本政策说明帮道在提供登录、知识学习、AI 对练、语音和工作辅助等服务时,如何处理和保护个人信息,以及你如何行使相关权利。 +

+ +
+ 请不要在提问、录音、图片、视频、文档或工作记录中提交与当前业务无关的业主或他人敏感信息。涉及住户姓名、手机号、房号、证件、健康、财务等信息时,应先按公司要求脱敏。 +
+ +

一、我们处理哪些信息

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
信息类别具体内容主要目的
登录与账号信息手机号、短信验证码校验结果、登录状态和账号标识完成身份验证、账号安全和登录状态维护
组织与工作身份员工标识、姓名、在职状态、部门、岗位、项目和主管范围确认使用资格,提供岗位、项目和权限范围内的内容
你主动提交的内容文字、语音、转写文本、图片、视频、文件、训练回答、工作记录、反馈和投稿提供问答、转写、训练、评分、整理、受控存储和人工处理
学习与训练记录场景、对话回合、完成状态、AI 评分、人工复核、错题、学习进度和主管复盘展示学习结果、改进建议和授权范围内的团队辅导
服务与安全信息App/系统版本、网络状态、访问时间、接口结果、必要的设备标识(如 Android ID、OAID,以系统实际可用为限)、操作和错误日志保障运行安全、排查故障、防止越权和重复提交,以及由 App 引擎提供基础运行分析
+
+ +

+ 我们不会因启动 App 自动读取通讯录、精确位置、短信内容或通话记录。若未来新增需要处理其他个人信息的功能,我们会通过页面提示、政策更新或单独同意等适当方式告知。 +

+ +

二、设备权限

+

以下权限仅在你主动使用相应功能时申请;拒绝后仍可使用不依赖该权限的功能:

+
    +
  • 麦克风:用于录制训练、提问、面试或反馈语音,并完成语音转文字或实时语音交互。
  • +
  • 相机:用于你主动拍摄并提交本次问题、资料或成果所需的图片、视频。
  • +
  • 照片、视频和文件:用于你主动选择并上传附件,以及在授权后保存生成内容。
  • +
+

+ 设备信息和外部存储权限被配置为启动时不申请。你可以在手机系统设置中查看或撤回权限;撤回后,相应功能可能不可用,但不会影响其他基础功能。 +

+ +

三、处理依据与使用方式

+

我们在下列范围内处理个人信息:

+
    +
  • 为履行你作为一方的协议、提供你主动选择的功能所必需;
  • +
  • 根据依法制定的劳动规章制度和授权范围实施必要的人力资源与学习管理;
  • +
  • 基于你的知情同意处理需要授权的设备权限、语音或其他主动提交内容;
  • +
  • 履行法律义务、保障网络和数据安全、处理投诉或保护合法权益。
  • +
+

+ 我们按照目的明确、最小必要原则使用信息,不会把 AI 评分自动作为招聘录用、绩效、任职资格或奖惩的唯一依据。涉及个人权益的重要决定应保留人工复核。 +

+ +

四、AI 与第三方服务

+
    +
  1. + 本 App 基于 DCloud uni-app(5+ App)开发。App 运行期间,DCloud App 引擎可能处理设备标识、应用启动数据和异常错误日志,用于提供基础运行、统计分析和性能改进;具体规则见 + 《DCloud App 引擎隐私政策》。 +
  2. +
  3. 为提供大模型问答、语音识别、语音合成或实时语音功能,系统可能根据当前启用配置,将完成该次请求所必要的文本、音频或图片发送给受约束的技术服务提供方,例如阿里云百炼等。
  4. +
  5. 短信验证码可能由经配置的短信服务商发送。文件和业务数据存储在受控服务器与对象存储中,知识检索组件仅在授权范围内工作。
  6. +
  7. 我们要求受托处理方仅按约定目的处理信息并采取安全措施。未经另行告知和取得必要授权,我们不会出售个人信息,也不会将业务输入用于训练面向公众的通用基础模型。
  8. +
  9. “查全网”与企业知识问答使用不同入口;只有在你明确选择并同意外部查询后,才会向已配置的公网服务发送该次查询所需内容。
  10. +
+ +

五、信息保存

+
    +
  • 短期问答会话按当前产品规则保留最近必要轮次,并在持续不活跃约 30 分钟后过期;运行审计不保存原始问题、答案或附件。
  • +
  • 普通语音转写只在完成本次处理所需范围内使用;正式训练、案例、面试、投稿或工作记录中明确提示保存的录音及结果,按对应业务记录规则保留并受权限控制。
  • +
  • 账号、组织、训练、投稿、反馈和审计记录在劳动关系存续、业务管理、争议处理和法律要求所需的最短期限内保存。
  • +
  • 超过保存期限后,我们会删除或匿名化处理;法律法规要求继续保存,或技术上暂时难以删除的,将停止除存储和安全保护外的其他处理。
  • +
+ +

六、信息共享、转移与公开

+
    +
  1. 除受托技术服务、履行法律义务、保护安全或经你另行同意外,我们不会向无关第三方提供个人信息。
  2. +
  3. 主管或运营人员只能在其组织、项目和职责授权范围内查看相应记录。业务匿名反馈会向处理人员隐藏提交人展示,但系统仍为本人查询、幂等和安全审计保留内部账号标识。
  4. +
  5. 如发生合并、重组或资产转移并涉及个人信息,我们会要求承接方继续受本政策约束,否则将重新取得必要授权。
  6. +
  7. 我们不会主动公开个人信息。依法必须提供或公开时,将按法律要求处理。
  8. +
  9. 当前服务不以向境外提供个人信息为目的;如未来确需跨境处理,将依法另行告知并履行相应程序。
  10. +
+ +

七、安全保护

+

+ 我们采取身份认证、最小权限、租户和项目隔离、传输加密、受控文件访问、日志审计、备份及安全检查等措施保护信息。互联网服务不存在绝对安全;发生或可能发生泄露、篡改、丢失时,我们将依法采取补救、报告和通知措施。 +

+ +

八、你的权利

+

在法律规定范围内,你可以:

+
    +
  • 查阅、复制、更正或补充你的个人信息;
  • +
  • 删除你有权删除的个人内容,或要求我们删除不再必要、违法或超出约定处理的信息;
  • +
  • 撤回基于同意的设备权限或处理授权;撤回不影响此前基于同意已进行处理的效力;
  • +
  • 请求说明本政策和个人信息处理规则;
  • +
  • 对 AI 评分或其他可能影响权益的结果申请人工复核;
  • +
  • 在不影响法定义务和必要业务记录的前提下申请停用账号。
  • +
+

+ 为保障安全,我们可能先核验你的身份。部分组织或劳动管理信息来源于公司授权系统,需由有权的人力资源或组织管理人员更正。 +

+ +

九、未成年人

+

+ 帮道员工端不面向不满十八周岁的未成年人提供服务。若因特殊用工或实习安排涉及未成年人,应由所在单位先完成适用的授权与保护措施。 +

+ +

十、政策更新

+

+ 功能、处理方式或法律要求发生变化时,我们可能更新本政策。重大变化将通过 App 弹窗、页面提示或其他适当方式告知;依法需要重新取得同意的,我们会另行征得同意。 +

+ +

十一、联系我们

+
+

个人信息处理者:南京银城物业服务有限公司

+

服务热线:400 828 9555

+

+ 你也可以通过所在单位人力资源或信息化管理部门提出个人信息请求、投诉或建议。我们将在核验身份后依法处理并反馈。 +

+
+ + +
+ + diff --git a/frontend/public/legal/terms.html b/frontend/public/legal/terms.html new file mode 100644 index 00000000..0a585cb2 --- /dev/null +++ b/frontend/public/legal/terms.html @@ -0,0 +1,109 @@ + + + + + + + + 帮道服务协议 + + + +
+

帮道 · 受控内测

+

帮道服务协议

+
+ 版本:2026-07-28 + 生效日期:2026-07-28 + 适用范围:帮道员工端 Android 受控内测 +
+ +

+ 欢迎使用“帮道”。本服务由南京银城物业服务有限公司(以下简称“我们”)面向获得授权的员工及相关业务人员提供。请在使用前认真阅读并理解本协议,特别是加粗或提示说明的内容。 +

+ +
+ 帮道包含人工智能辅助功能。模型生成的回答、训练评分、建议或摘要可能存在遗漏或偏差,只能作为学习和工作辅助,不能替代公司正式制度、主管决定、专业意见或人工复核。 +
+ +

一、协议范围

+
    +
  1. 本协议适用于帮道 Android App、移动网页及与其直接关联的服务。
  2. +
  3. 帮道当前用于受控内测,主要提供知识查询、岗位学习、AI 对练、语音转写、公司消息、成果投稿、工作辅助和意见反馈等能力。
  4. +
  5. 具体功能以当前账号权限、所在组织、岗位、项目范围和实际开放状态为准。页面中显示的后续规划不构成服务承诺。
  6. +
  7. 《帮道隐私政策》是本协议的重要组成部分。
  8. +
+ +

二、账号与身份

+
    +
  1. 你应使用本人获授权的手机号和组织身份登录,不得冒用、借用或转让账号。
  2. +
  3. 验证码、登录凭证及设备访问权限应由本人妥善保管。发现账号异常时,应立即停止使用并联系所在单位人力资源或信息化管理部门。
  4. +
  5. 岗位、部门、项目和主管范围以经授权的组织系统数据为准。你不得通过修改客户端参数绕过身份或数据权限。
  6. +
  7. 离职、调岗、停用或权限变化后,我们可根据组织授权同步调整或终止相应访问权限。
  8. +
+ +

三、服务使用规则

+

使用帮道时,你应遵守法律法规、公司制度和职业规范,不得实施下列行为:

+
    +
  • 上传或传播违法、有害、歧视、侮辱、虚假或侵犯他人合法权益的内容;
  • +
  • 泄露业主、客户、同事或合作方的姓名、手机号、房号、证件、健康、财务等不必要的个人信息;
  • +
  • 上传无权使用的文件、录音、图片、视频、商业秘密或受知识产权保护的内容;
  • +
  • 探测、攻击、干扰系统,绕过鉴权、租户、项目或岗位权限,批量抓取非本人有权访问的数据;
  • +
  • 将 AI 输出伪装成公司正式通知、审批结果、真实工单、已派发任务或专业结论;
  • +
  • 利用本服务从事其他违反法律法规、本协议或公司管理要求的活动。
  • +
+ +

四、人工智能功能说明

+
    +
  1. AI 内容由模型基于你的输入、授权知识、训练场景或公开信息生成,存在不准确、不完整或时效滞后的可能。
  2. +
  3. 涉及公司制度、收费、财务、人事、安全、客户权益或其他重要事项时,应核对引用原文并以正式文件或有权人员确认为准。
  4. +
  5. 训练评分用于学习反馈,不自动构成绩效、任职资格、招聘录用、奖惩或其他对个人权益有重大影响的决定。
  6. +
  7. 需要写入、上报或对外发送的内容,应由你在确认页面核对并主动提交;系统不会仅凭语音转写或模型推断自动替你完成正式操作。
  8. +
+ +

五、用户内容与知识产权

+
    +
  1. 你对依法享有权利的原创内容保留相应权利,并保证提交内容来源合法、授权充分。
  2. +
  3. 为向你提供转写、检索、训练、存储、展示和安全审计服务,你同意我们在必要范围内处理你主动提交的内容。
  4. +
  5. 公司制度、SOP、培训资料、消息及其他企业内容仅限在授权范围内使用,不得擅自复制、传播或用于与工作无关的目的。
  6. +
  7. 未经明确说明和另行授权,我们不会将你在帮道中的业务输入用于训练面向公众的通用基础模型。
  8. +
+ +

六、服务变更、中断与终止

+
    +
  1. 受控内测期间,功能、入口、模型、容量或可用范围可能调整。我们会尽合理努力保障服务稳定,但不承诺完全无中断或无错误。
  2. +
  3. 因维护、网络、第三方服务、不可抗力、安全风险或监管要求导致的暂停,我们将根据实际情况采取提示、降级或恢复措施。
  4. +
  5. 如你违反本协议、超出授权范围或存在安全风险,我们可限制功能、暂停账号并保留必要审计记录。
  6. +
+ +

七、责任边界

+
    +
  1. 因你提交错误、侵权或超权限内容,或未按提示进行人工核验造成的后果,由责任方依法承担。
  2. +
  3. 法律允许的范围内,我们不对因合理维护、第三方故障、不可抗力或非我们可控制原因造成的间接损失承担责任。
  4. +
  5. 本条不排除法律法规规定不得限制或免除的责任。
  6. +
+ +

八、协议更新

+

+ 我们可能根据功能、业务或法律要求更新本协议。发生重要变化时,将通过 App 弹窗、页面提示或其他适当方式告知。更新后的版本自标明的生效日期起适用;依法需要重新取得同意的,我们将另行征得同意。 +

+ +

九、适用法律与争议处理

+

+ 本协议的订立、履行和解释适用中华人民共和国法律。争议应先友好协商;协商不成的,可依法向有管辖权的人民法院提起诉讼。 +

+ +

十、联系我们

+
+

服务提供者:南京银城物业服务有限公司

+

服务热线:400 828 9555

+

员工也可通过所在单位人力资源或信息化管理部门反馈账号、服务或协议问题。

+
+ + +
+ + diff --git a/frontend/src/views/knowledge/sop.vue b/frontend/src/views/knowledge/sop.vue index 825e43ba..50f486e9 100644 --- a/frontend/src/views/knowledge/sop.vue +++ b/frontend/src/views/knowledge/sop.vue @@ -208,7 +208,7 @@ const sopSeeds: Record< > = { '投诉处理 SOP': { question: '业主投诉漏水,管家第一步怎么处理?', - answer: '先安抚业主情绪并确认影响范围;同步记录房号、报修时间、现场照片;再创建工单并明确首次反馈时限。', + answer: '先安抚业主情绪并确认影响范围;同步记录房号、报修时间、现场照片;再指导员工到既有工单系统登记,并明确首次反馈时限。', reference: '投诉处理 SOP v1.0 第 2、3、5 条', docs: [ { title: '投诉处理 SOP v1.0', desc: '安抚、记录、派单、反馈时限', hit: '引用 3 段', type: 'pill-success' }, @@ -218,7 +218,7 @@ const sopSeeds: Record< snippets: [ { title: '第 2 条:先安抚', text: '接到投诉后先表达理解,避免争辩责任归属。' }, { title: '第 3 条:确认事实', text: '记录房号、联系人、发生时间、现场照片和影响范围。' }, - { title: '第 5 条:明确反馈', text: '创建工单后告知首次反馈时限和责任人。' } + { title: '第 5 条:明确反馈', text: '在既有工单系统登记后,告知首次反馈时限和责任人。' } ], training: ['题 1:漏水投诉首响时限如何表述?', '题 2:哪些事实必须在工单中记录?'] }, diff --git a/mobile-uni/androidPrivacy.json.example b/mobile-uni/androidPrivacy.json.example index a6cf967d..10c74d8f 100644 --- a/mobile-uni/androidPrivacy.json.example +++ b/mobile-uni/androidPrivacy.json.example @@ -1,5 +1,5 @@ { - "version": "1", + "version": "20260728", "prompt": "template", "title": "服务协议和隐私政策", "message": "  请你审慎阅读并充分理解《服务协议》和《隐私政策》。为了提供登录、录音转写、图片/视频/文件上传和故障排查服务,本应用会在你使用相应功能时请求必要的设备和文件访问权限。你可阅读《服务协议》和《隐私政策》了解详细信息。点击“同意并继续”即表示你同意上述协议。", diff --git a/mobile-uni/package-lock.json b/mobile-uni/package-lock.json index 1833011d..69529b69 100644 --- a/mobile-uni/package-lock.json +++ b/mobile-uni/package-lock.json @@ -1,12 +1,12 @@ { "name": "aihr-mobile-uni", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aihr-mobile-uni", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@dcloudio/uni-app": "3.0.0-alpha-5020220260725001", "@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001", diff --git a/mobile-uni/package.json b/mobile-uni/package.json index 7cbfbb9b..7009a6ba 100644 --- a/mobile-uni/package.json +++ b/mobile-uni/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "aihr-mobile-uni", - "version": "0.1.5", + "version": "0.1.6", "private": true, "type": "module", "scripts": { @@ -10,8 +10,10 @@ "build:h5": "uni build -p h5", "build:app": "uni build -p app", "generate:app-assets": "sh scripts/generate-app-assets.sh", + "configure:app-privacy": "node scripts/configure-android-privacy.mjs", "verify:app-assets": "node scripts/verify-app-assets.mjs", - "verify:app-release": "node scripts/verify-app-assets.mjs --require-privacy", + "verify:android-apk": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-android-apk.ps1", + "verify:app-release": "node scripts/verify-app-assets.mjs --require-privacy --check-legal-urls", "test:unit": "node --test tests/*.test.mjs", "typecheck": "vue-tsc --noEmit" }, diff --git a/mobile-uni/scripts/app-legal-config.mjs b/mobile-uni/scripts/app-legal-config.mjs new file mode 100644 index 00000000..010953e5 --- /dev/null +++ b/mobile-uni/scripts/app-legal-config.mjs @@ -0,0 +1,116 @@ +import { isIP } from 'node:net'; + +const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i; +const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i; + +const isPrivateIpv4 = (hostname) => { + const parts = hostname.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + return parts[0] === 0 + || parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168) + || (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2)) + || (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) + || (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) + || (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) + || parts[0] >= 224; +}; + +const isPrivateHostname = (hostname) => { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const ipVersion = isIP(normalized); + return normalized === 'localhost' + || normalized.endsWith('.localhost') + || (ipVersion === 6 && ( + normalized === '::1' + || /^f[cd]/.test(normalized) + || normalized.startsWith('fe80:') + )) + || (ipVersion === 4 && isPrivateIpv4(normalized)); +}; + +export const normalizeLegalUrl = (rawValue, label = 'legal URL') => { + const value = String(rawValue || '').trim(); + if (!value) { + throw new Error(`${label} is required`); + } + if (PLACEHOLDER_PATTERN.test(value)) { + throw new Error(`${label} must not use a placeholder or example domain`); + } + + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a valid absolute URL`); + } + if (parsed.protocol !== 'https:') { + throw new Error(`${label} must use HTTPS`); + } + if (parsed.username || parsed.password) { + throw new Error(`${label} must not contain embedded credentials`); + } + if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) { + throw new Error(`${label} must not use a reserved hostname`); + } + if (isPrivateHostname(parsed.hostname)) { + throw new Error(`${label} must use a public hostname`); + } + return parsed.toString(); +}; + +export const validateLegalUrlPair = (termsValue, privacyValue) => { + const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL'); + const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL'); + if (termsUrl === privacyUrl) { + throw new Error('service agreement URL and privacy policy URL must be different'); + } + return { termsUrl, privacyUrl }; +}; + +const extractLinks = (message) => + [...String(message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]); + +export const validateAndroidPrivacyDocument = (privacy) => { + if (!privacy || typeof privacy !== 'object') { + throw new Error('androidPrivacy.json must contain a JSON object'); + } + if (privacy.prompt !== 'template') { + throw new Error('androidPrivacy.json must use prompt=template'); + } + if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) { + throw new Error('androidPrivacy.json version must be a positive numeric policy version'); + } + if (privacy.hrefLoader !== 'system') { + throw new Error('androidPrivacy.json must open legal links with hrefLoader=system'); + } + + const primaryLinks = extractLinks(privacy.message); + const secondaryLinks = extractLinks(privacy.second?.message); + if (primaryLinks.length !== 2 || secondaryLinks.length !== 2) { + throw new Error('androidPrivacy.json must contain two legal links in both prompts'); + } + + const primary = validateLegalUrlPair(primaryLinks[0], primaryLinks[1]); + const secondary = validateLegalUrlPair(secondaryLinks[0], secondaryLinks[1]); + if (primary.termsUrl !== secondary.termsUrl || primary.privacyUrl !== secondary.privacyUrl) { + throw new Error('androidPrivacy.json primary and secondary prompts must use the same legal links'); + } + return primary; +}; + +export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => { + const { termsUrl, privacyUrl } = validateLegalUrlPair(termsValue, privacyValue); + const rendered = String(templateText) + .replaceAll('__TERMS_URL__', termsUrl) + .replaceAll('__PRIVACY_URL__', privacyUrl); + const privacy = JSON.parse(rendered); + validateAndroidPrivacyDocument(privacy); + return `${JSON.stringify(privacy, null, 2)}\n`; +}; diff --git a/mobile-uni/scripts/configure-android-privacy.mjs b/mobile-uni/scripts/configure-android-privacy.mjs new file mode 100644 index 00000000..42415697 --- /dev/null +++ b/mobile-uni/scripts/configure-android-privacy.mjs @@ -0,0 +1,30 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { renderAndroidPrivacy } from './app-legal-config.mjs'; + +const projectRoot = resolve(import.meta.dirname, '..'); +const templatePath = resolve(projectRoot, 'androidPrivacy.json.example'); +// uni-app CLI uses src/ as UNI_INPUT_DIR. The App-Plus compiler only copies +// androidPrivacy.json from that input directory into the native build resource. +const outputPath = resolve(projectRoot, 'src', 'androidPrivacy.json'); + +const argumentValue = (name) => { + const prefix = `--${name}=`; + return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length); +}; + +const termsUrl = argumentValue('terms-url') || process.env.VITE_APP_TERMS_URL; +const privacyUrl = argumentValue('privacy-url') || process.env.VITE_APP_PRIVACY_URL; + +try { + const rendered = renderAndroidPrivacy( + readFileSync(templatePath, 'utf8'), + termsUrl, + privacyUrl + ); + writeFileSync(outputPath, rendered, 'utf8'); + console.log('src/androidPrivacy.json configured from final public legal URLs.'); +} catch (error) { + console.error(`Android privacy configuration failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/mobile-uni/scripts/verify-android-apk.ps1 b/mobile-uni/scripts/verify-android-apk.ps1 new file mode 100644 index 00000000..b7adfd43 --- /dev/null +++ b/mobile-uni/scripts/verify-android-apk.ps1 @@ -0,0 +1,266 @@ +[CmdletBinding()] +param( + [string]$ApkPath, + [ValidateSet('custom-base', 'dcloud-test', 'release')] + [string]$BuildKind = 'custom-base', + [string]$HBuilderHome, + [string]$ExpectedSignerSha256, + [string]$ExpectedTermsUrl, + [string]$ExpectedPrivacyUrl +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = Split-Path -Parent $scriptDirectory + +if ([string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) { + $ExpectedTermsUrl = $env:VITE_APP_TERMS_URL +} +if ([string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)) { + $ExpectedPrivacyUrl = $env:VITE_APP_PRIVACY_URL +} +if ( + [string]::IsNullOrWhiteSpace($ExpectedTermsUrl) -xor + [string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl) +) { + throw 'ExpectedTermsUrl and ExpectedPrivacyUrl must be provided together.' +} + +if ([string]::IsNullOrWhiteSpace($ApkPath)) { + $ApkPath = Join-Path $projectRoot 'dist\debug\android_debug.apk' +} +$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path +if ([IO.Path]::GetExtension($resolvedApkPath) -ne '.apk') { + throw "Expected an .apk file: $resolvedApkPath" +} +if ((Get-Item -LiteralPath $resolvedApkPath).Length -lt 1MB) { + throw "APK is unexpectedly small: $resolvedApkPath" +} + +$sourceManifestPath = Join-Path $projectRoot 'src\manifest.json' +$sourceManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourceManifestPath | ConvertFrom-Json +$sourceAndroid = $sourceManifest.'app-plus'.distribute.android + +$candidateHomes = @() +if (-not [string]::IsNullOrWhiteSpace($HBuilderHome)) { + $candidateHomes += $HBuilderHome +} +if (-not [string]::IsNullOrWhiteSpace($env:HBUILDERX_HOME)) { + $candidateHomes += $env:HBUILDERX_HOME +} +$candidateHomes += 'D:\HBuilderX' +$resolvedHBuilderHome = $candidateHomes | + Where-Object { Test-Path -LiteralPath (Join-Path $_ 'plugins\app-safe-pack\apktool.jar') } | + Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($resolvedHBuilderHome)) { + throw 'HBuilderX app-safe-pack tools were not found. Pass -HBuilderHome or set HBUILDERX_HOME.' +} + +$java = Get-Command java -ErrorAction Stop +$apktoolJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apktool.jar' +$apksignerJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apksigner.jar' +if (-not (Test-Path -LiteralPath $apksignerJar)) { + throw "Missing APK signer verifier: $apksignerJar" +} + +$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$decodePath = [IO.Path]::GetFullPath( + (Join-Path $temporaryRoot ('bangdao-apk-verify-' + [Guid]::NewGuid().ToString('N'))) +) +if (-not $decodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use a temporary path outside the system temp directory: $decodePath" +} + +try { + & $java.Source -jar $apktoolJar d -s -o $decodePath $resolvedApkPath 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "apktool failed to decode $resolvedApkPath" + } + + $decodedManifestPath = Join-Path $decodePath 'AndroidManifest.xml' + $apktoolMetadataPath = Join-Path $decodePath 'apktool.yml' + [xml]$decodedManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath + $decodedManifestText = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath + $apktoolMetadata = Get-Content -Raw -Encoding UTF8 -LiteralPath $apktoolMetadataPath + + $packageName = $decodedManifest.manifest.package + $targetSdkMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*targetSdkVersion:\s*(\d+)\s*$') + $versionCodeMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionCode:\s*(\d+)\s*$') + $versionNameMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionName:\s*(\S+)\s*$') + if (-not $targetSdkMatch.Success -or -not $versionCodeMatch.Success -or -not $versionNameMatch.Success) { + throw 'APK metadata is missing targetSdkVersion, versionCode, or versionName.' + } + + $targetSdkVersion = [int]$targetSdkMatch.Groups[1].Value + $versionCode = $versionCodeMatch.Groups[1].Value + $versionName = $versionNameMatch.Groups[1].Value.Trim("'`"") + if ($packageName -ne $sourceAndroid.packagename) { + throw "APK package mismatch: got $packageName, expected $($sourceAndroid.packagename)" + } + if ($targetSdkVersion -ne [int]$sourceAndroid.targetSdkVersion) { + throw "APK targetSdkVersion mismatch: got $targetSdkVersion, expected $($sourceAndroid.targetSdkVersion)" + } + if ($versionCode -ne [string]$sourceManifest.versionCode) { + throw "APK versionCode mismatch: got $versionCode, expected $($sourceManifest.versionCode)" + } + if ($versionName -ne [string]$sourceManifest.versionName) { + throw "APK versionName mismatch: got $versionName, expected $($sourceManifest.versionName)" + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) { + if ($BuildKind -eq 'custom-base') { + # A DCloud custom base APK contains only the native debug runtime. HBuilderX + # syncs the compiled www resources separately when it runs the app on a + # device, so the legal links cannot truthfully be asserted from the APK. + $compiledPrivacyPath = Join-Path $projectRoot 'dist\build\app\androidPrivacy.json' + if (-not (Test-Path -LiteralPath $compiledPrivacyPath)) { + throw 'Compiled App resources are missing androidPrivacy.json; run build:app first.' + } + $privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $compiledPrivacyPath + } + else { + $bundledPrivacyFiles = @( + Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') ` + -Recurse -File -Filter 'androidPrivacy.json' + ) + if ($bundledPrivacyFiles.Count -ne 1) { + throw "Expected exactly one bundled androidPrivacy.json, found $($bundledPrivacyFiles.Count)." + } + $privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $bundledPrivacyFiles[0].FullName + } + + if (-not $privacyJson.Contains($ExpectedTermsUrl)) { + throw 'Packaged privacy configuration is missing the expected service agreement URL.' + } + if (-not $privacyJson.Contains($ExpectedPrivacyUrl)) { + throw 'Packaged privacy configuration is missing the expected privacy policy URL.' + } + $privacyConfig = $privacyJson | ConvertFrom-Json + if ($privacyConfig.prompt -ne 'template') { + throw 'Packaged privacy configuration must enable the DCloud native template prompt.' + } + } + + if ($BuildKind -ne 'custom-base') { + $bundledAppServiceFiles = @( + Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') ` + -Recurse -File -Filter 'app-service.js' + ) + if ($bundledAppServiceFiles.Count -ne 1) { + throw "Expected exactly one bundled app-service.js, found $($bundledAppServiceFiles.Count)." + } + $bundledAppServiceSha256 = ( + Get-FileHash -Algorithm SHA256 -LiteralPath $bundledAppServiceFiles[0].FullName + ).Hash + $compiledAppServiceCandidates = @( + (Join-Path $projectRoot 'dist\build\app\app-service.js'), + (Join-Path $projectRoot 'dist\build\app-plus\app-service.js') + ) | Where-Object { Test-Path -LiteralPath $_ } + if ($compiledAppServiceCandidates.Count -eq 0) { + throw 'Compiled App resources are missing app-service.js; run build:app or HBuilderX pack first.' + } + $matchingCompiledAppService = $compiledAppServiceCandidates | + Where-Object { + (Get-FileHash -Algorithm SHA256 -LiteralPath $_).Hash -eq $bundledAppServiceSha256 + } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($matchingCompiledAppService)) { + throw 'APK app-service.js does not match the current compiled App resource.' + } + } + + $sensitiveFiles = @( + Get-ChildItem -LiteralPath $decodePath -Recurse -File | + Where-Object { + $_.Name -match '^(?:\.env(?:\..*)?|id_rsa)$' -or + $_.Extension -match '^\.(?:jks|keystore|p12|pfx|pem)$' + } + ) + if ($sensitiveFiles.Count -gt 0) { + throw "APK contains a sensitive file: $($sensitiveFiles[0].Name)" + } + $sensitiveRules = [ordered]@{ + 'private key' = 'BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY' + 'cloud access key' = '\b(?:AKIA|ASIA)[A-Z0-9]{16}\b' + 'GitHub token' = '\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b' + 'model API key' = '\bsk-[A-Za-z0-9_-]{20,}\b' + 'mobile number' = '(?&1 | Out-String + if ($LASTEXITCODE -ne 0 -or $signatureOutput -notmatch 'Verified using v2 scheme \(APK Signature Scheme v2\): true') { + throw 'APK signature verification failed or APK Signature Scheme v2 is missing.' + } + if ($signatureOutput -notmatch 'Number of signers:\s*1') { + throw 'APK must have exactly one signer.' + } + $signerMatch = [regex]::Match($signatureOutput, 'Signer #1 certificate SHA-256 digest:\s*([0-9a-fA-F]+)') + if (-not $signerMatch.Success) { + throw 'APK signer certificate SHA-256 digest could not be read.' + } + $signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant() + if ( + $BuildKind -eq 'release' -and + $signerSha256 -ne $ExpectedSignerSha256.Replace(':', '').ToUpperInvariant() + ) { + throw "APK signer mismatch: got $signerSha256" + } + + $apkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedApkPath).Hash + Write-Output 'Android APK verification passed.' + Write-Output " kind: $BuildKind" + Write-Output " package: $packageName" + Write-Output " version: $versionName ($versionCode)" + Write-Output " targetSdkVersion: $targetSdkVersion" + Write-Output " usesCleartextTraffic: $usesCleartextTraffic" + Write-Output " signer SHA-256: $signerSha256" + Write-Output " APK SHA-256: $apkSha256" + Write-Output " path: $resolvedApkPath" +} +finally { + if (Test-Path -LiteralPath $decodePath) { + $resolvedDecodePath = [IO.Path]::GetFullPath($decodePath) + if ( + $resolvedDecodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -and + $resolvedDecodePath -ne $temporaryRoot + ) { + Remove-Item -LiteralPath $resolvedDecodePath -Recurse -Force + } + } +} diff --git a/mobile-uni/scripts/verify-app-assets.mjs b/mobile-uni/scripts/verify-app-assets.mjs index bae7b69e..5f137209 100644 --- a/mobile-uni/scripts/verify-app-assets.mjs +++ b/mobile-uni/scripts/verify-app-assets.mjs @@ -1,13 +1,77 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { + validateAndroidPrivacyDocument, + validateLegalUrlPair +} from './app-legal-config.mjs'; const projectRoot = resolve(import.meta.dirname, '..'); const manifestPath = resolve(projectRoot, 'src/manifest.json'); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); const requirePrivacy = process.argv.includes('--require-privacy'); +const checkLegalUrls = process.argv.includes('--check-legal-urls'); const failures = []; +let releaseLegalUrls; const fail = (message) => failures.push(message); +const compiledAppRoot = resolve(projectRoot, 'dist/build/app'); +const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']); +const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i; +const releaseSensitiveContentPatterns = [ + ['private key', /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/], + ['cloud access key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/], + ['GitHub token', /\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b/], + ['model API key', /\bsk-[A-Za-z0-9_-]{20,}\b/], + ['mobile number', /(? { + if (!existsSync(root)) return []; + const result = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolutePath = resolve(directory, entry.name); + if (entry.isDirectory()) { + visit(absolutePath); + } else if (releaseTextExtensions.has(entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase())) { + result.push(absolutePath); + } + } + }; + visit(root); + return result; +}; + +const scanCompiledAppForSensitiveValues = () => { + if (!existsSync(compiledAppRoot)) { + fail('compiled App resource directory is missing; run build:app before release verification'); + return; + } + const allFiles = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolutePath = resolve(directory, entry.name); + if (entry.isDirectory()) visit(absolutePath); + else allFiles.push(absolutePath); + } + }; + visit(compiledAppRoot); + for (const file of allFiles) { + const relativePath = relative(compiledAppRoot, file); + if (releaseSensitiveFilePattern.test(relativePath)) { + fail(`compiled App contains a sensitive file: ${relativePath}`); + } + } + for (const file of compiledTextFiles(compiledAppRoot)) { + const content = readFileSync(file, 'utf8'); + for (const [label, pattern] of releaseSensitiveContentPatterns) { + if (pattern.test(content)) { + fail(`compiled App contains a possible ${label}: ${relative(compiledAppRoot, file)}`); + } + } + } +}; const readPng = (relativePath) => { if (typeof relativePath !== 'string' || !relativePath) { fail('manifest is missing an App asset path'); @@ -49,10 +113,54 @@ const app = manifest['app-plus']; const distribute = app?.distribute; const icons = distribute?.icons; const splashscreen = distribute?.splashscreen; +const android = distribute?.android; +const modules = app?.modules; if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) { fail('app-plus.screenOrientation must include portrait-primary'); } +if (splashscreen?.useOriginalMsgbox !== true) { + fail('app-plus.distribute.splashscreen.useOriginalMsgbox must enable the native privacy prompt'); +} +if (app?.ssl?.untrustedca !== 'refuse') { + fail('app-plus.ssl.untrustedca must remain refuse'); +} +if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) { + fail('versionName must use semantic numeric form such as 0.1.6'); +} +if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) { + fail('versionCode must be a positive integer'); +} +if (android?.packagename !== 'com.yincheng.wygj') { + fail('Android package name must remain com.yincheng.wygj'); +} +if (android?.targetSdkVersion !== 35) { + fail('Android targetSdkVersion must be 35 for the August 1 test package'); +} +const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a']; +const actualAndroidAbis = Array.isArray(android?.abiFilters) + ? [...android.abiFilters].sort() + : []; +if (JSON.stringify(actualAndroidAbis) !== JSON.stringify(expectedAndroidAbis)) { + fail('Android abiFilters must include exactly armeabi-v7a and arm64-v8a'); +} +if (android?.permissionExternalStorage?.request !== 'none') { + fail('Android external storage permission must not be requested at startup'); +} +if (android?.permissionPhoneState?.request !== 'none') { + fail('Android phone state permission must not be requested at startup'); +} +if (!Object.hasOwn(modules || {}, 'Camera') || !Object.hasOwn(modules || {}, 'Record')) { + fail('App modules must include Camera and Record for user-triggered media features'); +} +const declaredAndroidPermissions = Array.isArray(android?.permissions) + ? android.permissions.join('\n') + : ''; +for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission.MODIFY_AUDIO_SETTINGS']) { + if (!declaredAndroidPermissions.includes(permission)) { + fail(`Android permissions must include ${permission}`); + } +} const androidIcons = [ ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192] @@ -85,21 +193,102 @@ const splashPaths = [ ]; for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height); -const privacyPath = resolve(projectRoot, 'androidPrivacy.json'); +const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json'); +const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json'); if (requirePrivacy) { + if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') { + fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds'); + } + if (String(process.env.VITE_DEV_SMS_HINT_VALUE || '').trim()) { + fail('VITE_DEV_SMS_HINT_VALUE must not be present for release builds'); + } + try { + releaseLegalUrls = validateLegalUrlPair( + process.env.VITE_APP_TERMS_URL, + process.env.VITE_APP_PRIVACY_URL + ); + } catch (error) { + fail(`release build legal URL environment is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + if (!existsSync(privacyPath)) { - fail('androidPrivacy.json is required for release; copy androidPrivacy.json.example and replace both legal links'); + fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs'); } else { try { const privacy = JSON.parse(readFileSync(privacyPath, 'utf8')); - const links = [...String(privacy.message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]); - if (privacy.prompt !== 'template' || links.length < 2 || links.some((link) => !/^https:\/\//.test(link) || link.includes('__'))) { - fail('androidPrivacy.json must use prompt=template and two final HTTPS legal links'); + const privacyLegalUrls = validateAndroidPrivacyDocument(privacy); + if ( + releaseLegalUrls + && ( + privacyLegalUrls.termsUrl !== releaseLegalUrls.termsUrl + || privacyLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl + ) + ) { + fail('androidPrivacy.json legal links must match VITE_APP_TERMS_URL and VITE_APP_PRIVACY_URL'); } - } catch { - fail('androidPrivacy.json must be valid JSON without comments'); + releaseLegalUrls = privacyLegalUrls; + } catch (error) { + fail(`androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); } } + + if (!existsSync(compiledPrivacyPath)) { + fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-privacy'); + } else { + try { + const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8')); + const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy); + if ( + releaseLegalUrls + && ( + compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl + || compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl + ) + ) { + fail('compiled App androidPrivacy.json must match the source privacy configuration'); + } + } catch (error) { + fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + } + scanCompiledAppForSensitiveValues(); +} + +const checkRemoteLegalPage = async (url, label, expectedText) => { + try { + const response = await fetch(url, { + method: 'GET', + redirect: 'follow', + signal: AbortSignal.timeout(10000), + headers: { 'user-agent': 'Bangdao-App-Release-Preflight/1.0' } + }); + if (!response.ok) { + fail(`${label} returned HTTP ${response.status}`); + return; + } + if (!String(response.url).startsWith('https://')) { + fail(`${label} redirected away from HTTPS`); + return; + } + const contentType = String(response.headers.get('content-type') || '').toLowerCase(); + if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) { + fail(`${label} must return an HTML document`); + return; + } + const body = await response.text(); + if (body.trim().length < 100 || !expectedText.test(body)) { + fail(`${label} does not contain recognizable final legal content`); + } + } catch (error) { + fail(`${label} is not publicly reachable: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +if (requirePrivacy && checkLegalUrls && releaseLegalUrls) { + await Promise.all([ + checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/), + checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/) + ]); } if (failures.length) { diff --git a/mobile-uni/src/env.d.ts b/mobile-uni/src/env.d.ts index dccf9ec1..f3aac943 100644 --- a/mobile-uni/src/env.d.ts +++ b/mobile-uni/src/env.d.ts @@ -1,7 +1,12 @@ /// interface ImportMetaEnv { + readonly DEV: boolean; readonly VITE_API_BASE?: string; + readonly VITE_APP_TERMS_URL?: string; + readonly VITE_APP_PRIVACY_URL?: string; + readonly VITE_DEV_SMS_HINT_ENABLED?: string; + readonly VITE_DEV_SMS_HINT_VALUE?: string; } interface ImportMeta { diff --git a/mobile-uni/src/manifest.json b/mobile-uni/src/manifest.json index aabd02c3..82e55404 100644 --- a/mobile-uni/src/manifest.json +++ b/mobile-uni/src/manifest.json @@ -3,8 +3,8 @@ "appid" : "__UNI__B36D8BE", "description" : "帮道员工端", "vueVersion" : "3", - "versionName" : "0.1.5", - "versionCode" : "105", + "versionName" : "0.1.6", + "versionCode" : "106", "uniStatistics" : { "enable" : false }, @@ -59,6 +59,7 @@ }, "splashscreen" : { "iosStyle" : "default", + "useOriginalMsgbox" : true, "ios" : { "iphone" : { "retina40" : "src/static/app/splash/ios-iphone-retina40.png", @@ -87,7 +88,14 @@ }, "android" : { "packagename" : "com.yincheng.wygj", + "targetSdkVersion" : 35, "abiFilters" : [ "armeabi-v7a", "arm64-v8a" ], + "permissionExternalStorage" : { + "request" : "none" + }, + "permissionPhoneState" : { + "request" : "none" + }, "permissions" : [ "", "" diff --git a/mobile-uni/src/pages/auth/login/index.vue b/mobile-uni/src/pages/auth/login/index.vue index 20e59fa7..89ff065b 100644 --- a/mobile-uni/src/pages/auth/login/index.vue +++ b/mobile-uni/src/pages/auth/login/index.vue @@ -74,9 +74,9 @@
- + - 测试验证码: 123456 + 测试验证码: {{ devSmsHintValue }} 快捷填入 @@ -93,12 +93,25 @@ /> - - - + + + + + + 已阅读并同意 - 已阅读并同意《服务协议》与《隐私政策》 + 《服务协议》 + 与 + 《隐私政策》 + + 协议地址尚未完成法务配置,当前暂停验证码发送与登录。 + @@ -109,6 +122,11 @@ import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import type { LoginResult } from '@/types/api'; import { apiRequest } from '@/services/api'; import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth } from '@/services/auth'; +import { + legalDocumentUrl, + legalLinksReady, + type LegalDocumentKind +} from '@/services/legal'; import { resetPageScroll } from '@/services/navigation'; import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position'; @@ -135,8 +153,14 @@ const formRules = { const sending = ref(false); const loggingIn = ref(false); const countdown = ref(0); -const agreed = ref(true); +const agreed = ref(false); const message = ref(''); +const devSmsHintValue = import.meta.env.DEV + ? String(import.meta.env.VITE_DEV_SMS_HINT_VALUE || '').replace(/\D/g, '').slice(0, 6) + : ''; +const showDevSmsHint = import.meta.env.DEV + && import.meta.env.VITE_DEV_SMS_HINT_ENABLED === 'true' + && /^\d{4,6}$/.test(devSmsHintValue); let timer: ReturnType | null = null; const inputStyles = { @@ -166,8 +190,9 @@ const startCountdown = () => { }; const fillDevCredentials = () => { - formData.smsCode = '123456'; - message.value = '已自动填入测试验证码 123456'; + if (!showDevSmsHint) return; + formData.smsCode = devSmsHintValue; + message.value = '已自动填入本地测试验证码'; }; const normalizeDigits = (value: string | number, maxLength: number) => @@ -183,6 +208,33 @@ const onSmsCodeInput = (value: string | number) => { formData.smsCode = normalizeDigits(value, 6); }; +const ensureLegalConsent = () => { + if (!legalLinksReady) { + message.value = '服务协议和隐私政策地址尚未配置,暂不能发送验证码或登录'; + return false; + } + if (!agreed.value) { + message.value = '请先阅读并勾选同意《服务协议》与《隐私政策》'; + return false; + } + return true; +}; + +const openLegalDocument = (kind: LegalDocumentKind) => { + const url = legalDocumentUrl(kind); + if (!url) { + message.value = '协议地址尚未完成法务配置'; + return; + } + if (typeof plus !== 'undefined') { + plus.runtime.openURL(url); + return; + } + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + } +}; + const goAfterLogin = async () => { const redirect = consumeLoginRedirect() || '/pages/user/today/index'; goToMobileTarget(await nextUserRouteAfterLogin(redirect)); @@ -190,6 +242,7 @@ const goAfterLogin = async () => { const sendCode = async () => { if (sending.value || countdown.value > 0) return; + if (!ensureLegalConsent()) return; try { await loginForm.value?.validateField(['phone']); } catch { @@ -215,10 +268,7 @@ const sendCode = async () => { const login = async () => { if (loggingIn.value) return; - if (!agreed.value) { - message.value = '请先勾选同意《服务协议》与《隐私政策》'; - return; - } + if (!ensureLegalConsent()) return; try { await loginForm.value?.validate(); } catch { @@ -616,9 +666,16 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: 2px; max-width: 720px; margin: 18px auto 0; + flex-wrap: wrap; +} + +.agreement-consent { + display: inline-flex; + align-items: center; + gap: 8px; cursor: pointer; } @@ -647,6 +704,24 @@ onUnmounted(() => { line-height: 1.4; } +.agreement-link { + color: var(--employee-primary, #e60012); + font-size: 12px; + font-weight: 650; + line-height: 1.4; + cursor: pointer; +} + +.agreement-error { + display: block; + max-width: 720px; + margin: 8px auto 0; + color: #b45309; + font-size: 11px; + line-height: 1.5; + text-align: center; +} + @media (max-width: 375px) { .login-shell { padding-right: 20px; diff --git a/mobile-uni/src/pages/user/direct/index.vue b/mobile-uni/src/pages/user/direct/index.vue index 97ce1816..ea98c0f9 100644 --- a/mobile-uni/src/pages/user/direct/index.vue +++ b/mobile-uni/src/pages/user/direct/index.vue @@ -426,7 +426,7 @@ const submit = async () => { form.requestId = ''; form.content = ''; voiceHint.value = ''; - submitMessage.value = '反馈已提交,可在“我的反馈”查看处理结果。'; + submitMessage.value = '已收到,当前状态为“待处理”;可在“我的反馈”查看人工正式回复。'; await loadMine(true); } catch (error) { submitFailed.value = true; diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index 5ceed312..613a9ab1 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -517,7 +517,11 @@ type ChatMsg = UserMsg | MasterMsg | SystemMsg | PendingMsg; const positionProfile = ref(getSelectedPositionProfile()); const positionLabel = ref(getSelectedPosition()); -const quickQuestions = computed(() => positionProfile.value.quickQuestions); +const policyQuickQuestion = '问制度:请说明公司请假和考勤的适用规定'; +const quickQuestions = computed(() => [ + policyQuickQuestion, + ...positionProfile.value.quickQuestions.filter((item) => item !== policyQuickQuestion) +].slice(0, 4)); const quickQuestionPlaceholder = computed(() => `例如: ${quickQuestions.value[0] || '岗位SOP怎么处理?'}`); const messages = ref([]); diff --git a/mobile-uni/src/services/legal.ts b/mobile-uni/src/services/legal.ts new file mode 100644 index 00000000..79b53b38 --- /dev/null +++ b/mobile-uni/src/services/legal.ts @@ -0,0 +1,81 @@ +export type LegalDocumentKind = 'terms' | 'privacy'; + +const placeholderPattern = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i; +const reservedHostnamePattern = /(?:^|\.)(?:example|invalid|test)$/i; + +const ipv4Parts = (hostname: string) => { + const parts = hostname.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + return parts; +}; + +const isReservedIpv4 = (parts: number[]) => { + return parts[0] === 0 + || parts[0] === 10 + || parts[0] === 127 + || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) + || (parts[0] === 169 && parts[1] === 254) + || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + || (parts[0] === 192 && parts[1] === 168) + || (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2)) + || (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) + || (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) + || (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) + || parts[0] >= 224; +}; + +const normalizePublicHttpsUrl = (rawValue?: string) => { + const value = String(rawValue || '').trim(); + if (!value || placeholderPattern.test(value)) return ''; + if (/[\u0000-\u0020\u007f]/.test(value)) return ''; + + const match = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(value); + if (!match || match[1].includes('@')) return ''; + const authority = match[1]; + let hostname = ''; + if (authority.startsWith('[')) { + const end = authority.indexOf(']'); + if (end <= 1 || (authority.slice(end + 1) && !/^:\d+$/.test(authority.slice(end + 1)))) return ''; + hostname = authority.slice(1, end).toLowerCase(); + } else { + const hostAndPort = authority.toLowerCase(); + const colon = hostAndPort.lastIndexOf(':'); + if (colon >= 0) { + if (!/^\d+$/.test(hostAndPort.slice(colon + 1)) || hostAndPort.slice(0, colon).includes(':')) return ''; + hostname = hostAndPort.slice(0, colon); + } else { + hostname = hostAndPort; + } + } + + const numericIpv4 = /^[0-9.]+$/.test(hostname); + const parsedIpv4 = numericIpv4 ? ipv4Parts(hostname) : null; + if ( + !hostname + || reservedHostnamePattern.test(hostname) + || hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || (numericIpv4 && (!parsedIpv4 || isReservedIpv4(parsedIpv4))) + || (hostname.includes(':') && (!/^[0-9a-f:]+$/i.test(hostname) || /^f[cd]/.test(hostname) || hostname.startsWith('fe80:'))) + || (!numericIpv4 && !hostname.includes(':') && ( + !hostname.includes('.') + || !/^[a-z0-9.-]+$/i.test(hostname) + || hostname.startsWith('.') + || hostname.endsWith('.') + )) + ) return ''; + return value; +}; + +const termsUrl = normalizePublicHttpsUrl(import.meta.env.VITE_APP_TERMS_URL); +const privacyUrl = normalizePublicHttpsUrl(import.meta.env.VITE_APP_PRIVACY_URL); + +export const legalLinksReady = Boolean(termsUrl && privacyUrl && termsUrl !== privacyUrl); + +export const legalDocumentUrl = (kind: LegalDocumentKind) => { + if (!legalLinksReady) return ''; + return kind === 'terms' ? termsUrl : privacyUrl; +}; diff --git a/mobile-uni/tests/app-packaging.test.mjs b/mobile-uni/tests/app-packaging.test.mjs index 875d52a8..717ed361 100644 --- a/mobile-uni/tests/app-packaging.test.mjs +++ b/mobile-uni/tests/app-packaging.test.mjs @@ -4,6 +4,11 @@ import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { + renderAndroidPrivacy, + validateAndroidPrivacyDocument, + validateLegalUrlPair +} from '../scripts/app-legal-config.mjs'; const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const readJson = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), 'utf8')); @@ -30,3 +35,128 @@ test('App 平台编译器已声明且与其他 uni 包同版本,避免 build:a assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-h5']); assert.equal(appPlus, pkg.devDependencies['@dcloudio/vite-plugin-uni']); }); + +test('Android 自定义基座显式面向 Android 15 权限模型', async () => { + const manifest = await readJson('../src/manifest.json'); + const android = manifest['app-plus']?.distribute?.android; + + assert.equal(android?.packagename, 'com.yincheng.wygj'); + assert.equal(android?.targetSdkVersion, 35, '不得回退到 DCloud 默认的 targetSdkVersion 28 兼容模式'); +}); + +test('Android 启动阶段不主动索取设备信息或外部存储权限', async () => { + const manifest = await readJson('../src/manifest.json'); + const android = manifest['app-plus']?.distribute?.android; + const modules = manifest['app-plus']?.modules; + + assert.equal(manifest['app-plus']?.distribute?.splashscreen?.useOriginalMsgbox, true); + assert.equal(android?.permissionPhoneState?.request, 'none'); + assert.equal(android?.permissionExternalStorage?.request, 'none'); + assert.ok(Object.hasOwn(modules || {}, 'Camera'), '媒体功能仍需按用户操作动态申请相机权限'); + assert.ok(Object.hasOwn(modules || {}, 'Record'), '语音功能仍需按用户操作动态申请录音权限'); +}); + +test('正式法务地址必须使用不同的公网 HTTPS 页面', () => { + assert.throws(() => validateLegalUrlPair('', 'https://legal.example.cn/privacy'), /required/); + assert.throws( + () => validateLegalUrlPair('http://legal.example.cn/terms', 'https://legal.example.cn/privacy'), + /HTTPS/ + ); + assert.throws( + () => validateLegalUrlPair('https://127.0.0.1/terms', 'https://legal.example.cn/privacy'), + /public hostname/ + ); + assert.throws( + () => validateLegalUrlPair('https://legal.example.cn/document', 'https://legal.example.cn/document'), + /must be different/ + ); + assert.throws( + () => validateLegalUrlPair('https://198.18.0.1/terms', 'https://legal.example.cn/privacy'), + /public hostname/ + ); + assert.throws( + () => validateLegalUrlPair('https://legal.invalid/terms', 'https://legal.example.cn/privacy'), + /reserved hostname/ + ); + assert.doesNotThrow(() => validateLegalUrlPair( + 'https://198.51.99.1/terms', + 'https://fd-company.cn/privacy' + )); + assert.doesNotThrow(() => validateLegalUrlPair( + 'https://fd-company.cn/terms', + 'https://fd-company.cn/privacy' + )); +}); + +test('Android 隐私模板的首次与二次弹窗使用同一组最终链接', async () => { + const template = await readFile(new URL('../androidPrivacy.json.example', import.meta.url), 'utf8'); + const rendered = renderAndroidPrivacy( + template, + 'https://legal.company.cn/bangdao/terms', + 'https://legal.company.cn/bangdao/privacy' + ); + const privacy = JSON.parse(rendered); + + assert.deepEqual(validateAndroidPrivacyDocument(privacy), { + termsUrl: 'https://legal.company.cn/bangdao/terms', + privacyUrl: 'https://legal.company.cn/bangdao/privacy' + }); + assert.doesNotMatch(rendered, /__TERMS_URL__|__PRIVACY_URL__/); + + privacy.second.message = privacy.second.message.replace('/privacy', '/other-privacy'); + assert.throws(() => validateAndroidPrivacyDocument(privacy), /must use the same legal links/); +}); + +test('Android 隐私配置写入 uni-app CLI 输入目录并校验编译产物', async () => { + const configureScript = await readFile( + new URL('../scripts/configure-android-privacy.mjs', import.meta.url), + 'utf8' + ); + const verifier = await readFile( + new URL('../scripts/verify-app-assets.mjs', import.meta.url), + 'utf8' + ); + + assert.match(configureScript, /resolve\(projectRoot, 'src', 'androidPrivacy\.json'\)/); + assert.match(verifier, /dist\/build\/app\/androidPrivacy\.json/); +}); + +test('App 登录页法务地址校验不依赖浏览器 URL 构造器', async () => { + const legalService = await readFile( + new URL('../src/services/legal.ts', import.meta.url), + 'utf8' + ); + + assert.doesNotMatch(legalService, /new URL\(/); + assert.match(legalService, /\^https:\\\/\\\/\(\[\^\/\?#\]\+\)/); + assert.match(legalService, /legalLinksReady/); +}); + +test('APK 门禁区分自定义调试基座与内置业务资源的测试包', async () => { + const verifier = await readFile( + new URL('../scripts/verify-android-apk.ps1', import.meta.url), + 'utf8' + ); + + assert.match(verifier, /ValidateSet\('custom-base', 'dcloud-test', 'release'\)/); + assert.match(verifier, /assets\\apps/); + assert.match(verifier, /bundled androidPrivacy\.json/); + assert.match(verifier, /DCloud custom base APK contains only the native debug runtime/); + assert.match(verifier, /dist\\build\\app\\app-service\.js/); + assert.match(verifier, /dist\\build\\app-plus\\app-service\.js/); + assert.match(verifier, /fixed test SMS code/); + assert.match(verifier, /'mobile number'\s*=/); +}); + +test('8 月 1 日 RC 脚本默认拒绝脏工作区且记录完整构建证据', async () => { + const script = await readFile( + new URL('../../scripts/prepare-aug1-rc.ps1', import.meta.url), + 'utf8' + ); + + assert.match(script, /requires a clean worktree/); + assert.match(script, /AllowDirtyRehearsal/); + assert.match(script, /RC build changed the previously clean worktree/); + assert.match(script, /releaseEligible = \$releaseEligible/); + assert.match(script, /APK metadata, legal links, signature, content match and sensitive-value scan/); +}); diff --git a/mobile-uni/tests/candidate-view.test.mjs b/mobile-uni/tests/candidate-view.test.mjs index 9b4aaf48..858357e9 100644 --- a/mobile-uni/tests/candidate-view.test.mjs +++ b/mobile-uni/tests/candidate-view.test.mjs @@ -92,9 +92,9 @@ test('候选端刷新失败不保留上一轮真实记录', async () => { pageSource('../src/pages/candidate/progress/index.vue') ]); - assert.match(home, /catch \(error\) \{\n\s*clearData\(\);/); - assert.match(materials, /catch \(error\) \{\n\s*clearData\(\);/); - assert.match(progress, /catch \(error\) \{\n\s*clearData\(\);/); + assert.match(home, /catch \(error\) \{\r?\n\s*clearData\(\);/); + assert.match(materials, /catch \(error\) \{\r?\n\s*clearData\(\);/); + assert.match(progress, /catch \(error\) \{\r?\n\s*clearData\(\);/); }); test('面试、资料、进度和预习页各自只承载一类真实任务', async () => { @@ -136,7 +136,7 @@ test('五个候选页面登录都使用 redirectTo 并保留当前任务页', as ]); sources.forEach((source) => { - const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || ''; + const goLogin = source.match(/const goLogin = \(\) => \{\r?\n[\s\S]*?\r?\n\};/)?.[0] || ''; assert.match(goLogin, /rememberLoginRedirect\(currentPath\)/); assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/); assert.doesNotMatch(goLogin, /uni\.navigateTo\(/); diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index a89f7ead..9f075290 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -161,6 +161,28 @@ test('移动端短信登录不再让客户端选择租户', async () => { assert.doesNotMatch(loginRequest, /tenantId:/); }); +test('登录前协议默认不勾选且协议未配置时禁止发送验证码和登录', async () => { + const source = await pageSource('../src/pages/auth/login/index.vue'); + + assert.match(source, /const agreed = ref\(false\)/); + assert.match(source, /openLegalDocument\('terms'\)/); + assert.match(source, /openLegalDocument\('privacy'\)/); + assert.match(source, /const ensureLegalConsent = \(\) =>/); + assert.match(source, /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/); + assert.match(source, /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/); + assert.match(source, /协议地址尚未完成法务配置/); +}); + +test('测试验证码提示必须由开发环境显式开关启用', async () => { + const source = await pageSource('../src/pages/auth/login/index.vue'); + + assert.match(source, /v-if="showDevSmsHint"/); + assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_VALUE/); + assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_ENABLED === 'true'/); + assert.match(source, /\/\^\\d\{4,6\}\$\/\.test\(devSmsHintValue\)/); + assert.doesNotMatch(source, /123456/); +}); + test('共享视觉基座包含员工端主色和卡片圆角 token', async () => { const source = await pageSource('../src/styles/base.css'); @@ -691,9 +713,9 @@ test('直通车页支持语音输入并管控切页清理与字数上限', async assert.match(source, /slice\(0, MAX_CONTENT_LENGTH\)/); // stop→onCapture 异步窗口:stopping 状态禁用重复 stop,避免 App 端二次 stop 使录音丢失。 - const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\n/); + const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/); assert.match(stopBody, /voiceStopping\.value = true/); - const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\n/); + const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/); assert.match(toggleBody, /voiceStopping\.value/); // 切页清理:onHide/onUnload 各自函数体内必须直接调用 teardownVoice(不允许跨函数误匹配)。 @@ -705,7 +727,7 @@ test('直通车页支持语音输入并管控切页清理与字数上限', async assert.match(onUnloadBody, /teardownVoice\(\)/); // teardown 必须同时处理:generation 递增、转写 abort、录音 cancel、状态复位、计时器停止。 - const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\n/); + const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\r?\n/); assert.match(teardownBody, /voiceGeneration \+= 1/); assert.match(teardownBody, /abortVoiceTranscription\?\.\(\)/); assert.match(teardownBody, /voiceCapture\?\.cancel\(\)/); diff --git a/mobile-uni/tests/p0-test-package-scope.test.mjs b/mobile-uni/tests/p0-test-package-scope.test.mjs new file mode 100644 index 00000000..dcc9f7a8 --- /dev/null +++ b/mobile-uni/tests/p0-test-package-scope.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + +test('问师傅首屏提供政策制度快捷问题且继续走统一 Agent 请求', async () => { + const [page, service] = await Promise.all([ + read('../src/pages/user/sop/index.vue'), + read('../src/services/agent.ts') + ]); + + assert.match(page, /const policyQuickQuestion = '问制度:请说明公司请假和考勤的适用规定'/); + assert.match(page, /void sendQuestion\(value\)/); + assert.match(service, /\/api\/aihr\/agent\/messages/); + assert.doesNotMatch(page, /toolCode:\s*['"]POLICY/); +}); + +test('直通车系统回执明确区分已收到、待处理和人工正式回复', async () => { + const page = await read('../src/pages/user/direct/index.vue'); + + assert.match(page, /已收到,当前状态为“待处理”;可在“我的反馈”查看人工正式回复/); + assert.match(page, /item\.status === 'REPLIED' \? '已回复' : '待处理'/); + assert.match(page, /正式回复<\/text>/); +}); + +test('内置兜底与管理端演示不宣称帮道自动创建或派发工单', async () => { + const [sopSeed, modelSeed, adminDemo] = await Promise.all([ + read('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java'), + read('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java'), + read('../../frontend/src/views/knowledge/sop.vue') + ]); + const combined = [sopSeed, modelSeed, adminDemo].join('\n'); + + assert.match(combined, /既有工单系统/); + assert.doesNotMatch(combined, /再创建工单/); + assert.doesNotMatch(combined, /再生成工单闭环/); + assert.match(modelSeed, /当前系统不会代为创建或派发工单/); +}); diff --git a/mobile-uni/tests/supervisor-view.test.mjs b/mobile-uni/tests/supervisor-view.test.mjs index e2549edd..3cd9b8bb 100644 --- a/mobile-uni/tests/supervisor-view.test.mjs +++ b/mobile-uni/tests/supervisor-view.test.mjs @@ -559,7 +559,7 @@ test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () => ]; pages.forEach((source) => { - const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || ''; + const goLogin = source.match(/const goLogin = \(\) => \{\r?\n[\s\S]*?\r?\n\};/)?.[0] || ''; assert.match(goLogin, /rememberLoginRedirect\(/); assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/); assert.doesNotMatch(goLogin, /uni\.navigateTo\(/); diff --git a/mobile-uni/tests/work-report.test.mjs b/mobile-uni/tests/work-report.test.mjs index db0e789b..7408cece 100644 --- a/mobile-uni/tests/work-report.test.mjs +++ b/mobile-uni/tests/work-report.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); @@ -141,13 +142,13 @@ test('工作上报服务声明整理契约并在正式提交携带 requestId', a test('幂等键与异步账号守卫是可执行行为,不只是源码字符串', async (context) => { const { createServer } = await import('vite'); - const root = new URL('../', import.meta.url).pathname; + const root = fileURLToPath(new URL('../', import.meta.url)); const server = await createServer({ root, configFile: false, appType: 'custom', logLevel: 'silent', - resolve: { alias: { '@': new URL('../src', import.meta.url).pathname } }, + resolve: { alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) } }, server: { middlewareMode: true } }); context.after(() => server.close()); diff --git a/scripts/prepare-aug1-rc.ps1 b/scripts/prepare-aug1-rc.ps1 new file mode 100644 index 00000000..577d2b2d --- /dev/null +++ b/scripts/prepare-aug1-rc.ps1 @@ -0,0 +1,235 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ApkPath, + [ValidateSet('dcloud-test', 'release')] + [string]$BuildKind = 'dcloud-test', + [string]$ExpectedSignerSha256, + [string]$TermsUrl = 'https://peilian.njzhmj.top/legal/terms.html', + [string]$PrivacyUrl = 'https://peilian.njzhmj.top/legal/privacy.html', + [switch]$SkipBuild, + [switch]$AllowDirtyRehearsal +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $projectRoot + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$Arguments = @() + ) + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($Arguments -join ' ')" + } +} + +function Read-Git { + param([string[]]$Arguments) + $output = & git @Arguments + if ($LASTEXITCODE -ne 0) { + throw "git command failed: git $($Arguments -join ' ')" + } + return ($output | Out-String).Trim() +} + +$worktreeStatus = Read-Git @('status', '--porcelain') +$isDirty = -not [string]::IsNullOrWhiteSpace($worktreeStatus) +if ($isDirty -and -not $AllowDirtyRehearsal) { + throw 'RC preparation requires a clean worktree. Commit the reviewed release batch first.' +} +if ($BuildKind -eq 'release' -and [string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) { + throw 'Enterprise release verification requires -ExpectedSignerSha256.' +} + +$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path +$manifestPath = Join-Path $projectRoot 'mobile-uni\src\manifest.json' +$packagePath = Join-Path $projectRoot 'mobile-uni\package.json' +$manifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $manifestPath | ConvertFrom-Json +$package = Get-Content -Raw -Encoding UTF8 -LiteralPath $packagePath | ConvertFrom-Json +if ([string]$manifest.versionName -ne [string]$package.version) { + throw "mobile-uni package version $($package.version) does not match manifest version $($manifest.versionName)." +} + +$commit = Read-Git @('rev-parse', 'HEAD') +$shortCommit = Read-Git @('rev-parse', '--short=8', 'HEAD') +$upstreamDelta = Read-Git @('rev-list', '--left-right', '--count', 'HEAD...origin/main') +Invoke-Checked -FilePath 'git' -Arguments @('diff', '--check') + +$env:VITE_APP_TERMS_URL = $TermsUrl +$env:VITE_APP_PRIVACY_URL = $PrivacyUrl +Remove-Item Env:VITE_DEV_SMS_HINT_ENABLED -ErrorAction SilentlyContinue +Remove-Item Env:VITE_DEV_SMS_HINT_VALUE -ErrorAction SilentlyContinue + +if (-not $SkipBuild) { + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'test:unit') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'typecheck') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'configure:app-privacy') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'build:h5') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'build:app') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'verify:app-release') + Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'frontend', 'run', 'build:prod') + + Push-Location (Join-Path $projectRoot 'backend') + try { + Invoke-Checked -FilePath 'mvn' -Arguments @( + '-pl', 'ruoyi-modules/ruoyi-aihr', + '-am', + '-DskipTests=false', + 'test' + ) + Invoke-Checked -FilePath 'mvn' -Arguments @( + '-pl', 'ruoyi-admin', + '-am', + '-DskipTests', + 'package' + ) + } + finally { + Pop-Location + } +} + +$postBuildWorktreeStatus = Read-Git @('status', '--porcelain') +$postBuildDirty = -not [string]::IsNullOrWhiteSpace($postBuildWorktreeStatus) +if (-not $isDirty -and $postBuildDirty) { + throw 'RC build changed the previously clean worktree. Review and commit or revert generated changes before release.' +} + +$apkVerifier = Join-Path $projectRoot 'mobile-uni\scripts\verify-android-apk.ps1' +$verifyArguments = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $apkVerifier, + '-ApkPath', $resolvedApkPath, + '-BuildKind', $BuildKind, + '-ExpectedTermsUrl', $TermsUrl, + '-ExpectedPrivacyUrl', $PrivacyUrl +) +if (-not [string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) { + $verifyArguments += @('-ExpectedSignerSha256', $ExpectedSignerSha256) +} +$apkVerificationOutput = & powershell @verifyArguments 2>&1 +$apkVerificationOutput | ForEach-Object { Write-Output $_ } +if ($LASTEXITCODE -ne 0) { + throw 'Android APK verification failed.' +} +$apkVerificationText = ($apkVerificationOutput | Out-String) +$signerMatch = [regex]::Match($apkVerificationText, 'signer SHA-256:\s*([0-9A-Fa-f]+)') +$cleartextMatch = [regex]::Match($apkVerificationText, 'usesCleartextTraffic:\s*(True|False)') +if (-not $signerMatch.Success -or -not $cleartextMatch.Success) { + throw 'APK verifier output is missing signer or cleartext metadata.' +} + +$versionName = [string]$manifest.versionName +$versionCode = [string]$manifest.versionCode +$releaseEligible = -not $isDirty -and -not $postBuildDirty -and -not $SkipBuild +$modeSuffix = if ($releaseEligible) { 'frozen' } else { 'rehearsal' } +$evidenceDirectory = Join-Path $projectRoot "output\aug1-rc\$versionName-$versionCode-$shortCommit-$modeSuffix" +New-Item -ItemType Directory -Path $evidenceDirectory -Force | Out-Null +$artifactName = "bangdao-$versionName-$versionCode-$shortCommit-$BuildKind.apk" +$artifactPath = Join-Path $evidenceDirectory $artifactName +Copy-Item -LiteralPath $resolvedApkPath -Destination $artifactPath -Force + +$frontendIndex = Join-Path $projectRoot 'frontend\dist\index.html' +$mobileIndex = Join-Path $projectRoot 'mobile-uni\dist\build\h5\index.html' +$backendJar = Join-Path $projectRoot 'backend\ruoyi-admin\target\ruoyi-admin.jar' +foreach ($requiredArtifact in @($frontendIndex, $mobileIndex, $backendJar)) { + if (-not (Test-Path -LiteralPath $requiredArtifact)) { + throw "Required release artifact is missing: $requiredArtifact" + } +} + +$rehearsalReasons = @() +if ($isDirty) { + $rehearsalReasons += 'worktree contains uncommitted changes' +} +if ($SkipBuild) { + $rehearsalReasons += 'full build and tests were skipped' +} +$evidence = [ordered]@{ + generatedAt = (Get-Date).ToUniversalTime().ToString('o') + releaseTarget = '2026-08-01 Android controlled internal test' + releaseEligible = $releaseEligible + rehearsalReasons = $rehearsalReasons + git = [ordered]@{ + commit = $commit + upstreamDelta = $upstreamDelta + clean = -not $postBuildDirty + } + app = [ordered]@{ + packageName = [string]$manifest.'app-plus'.distribute.android.packagename + versionName = $versionName + versionCode = $versionCode + targetSdkVersion = [int]$manifest.'app-plus'.distribute.android.targetSdkVersion + buildKind = $BuildKind + signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant() + usesCleartextTraffic = [bool]::Parse($cleartextMatch.Groups[1].Value) + legalUrls = @($TermsUrl, $PrivacyUrl) + } + artifacts = [ordered]@{ + apk = [ordered]@{ + path = $artifactPath + bytes = (Get-Item -LiteralPath $artifactPath).Length + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPath).Hash + } + frontendIndexSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $frontendIndex).Hash + mobileH5IndexSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $mobileIndex).Hash + backendJarSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $backendJar).Hash + } + automatedGates = if ($SkipBuild) { + @('artifact-only rehearsal; review prior build evidence separately') + } + else { + @( + 'mobile unit tests', + 'mobile typecheck', + 'mobile H5 build', + 'mobile App build', + 'App release asset and sensitive-value scan', + 'frontend production build', + 'ruoyi-aihr full test suite', + 'backend package', + 'APK metadata, legal links, signature, content match and sensitive-value scan' + ) + } + manualGatesRemaining = @( + 'privacy reject and consent choices', + 'login error and re-login', + 'microphone allow and deny', + 'media cancellation', + 'foreground/background and network recovery', + 'authenticated search, practice, ask and five direct-feedback channels', + 'business content and authorization sign-off', + 'release-unit and rollback sign-off' + ) +} + +$jsonPath = Join-Path $evidenceDirectory 'release-evidence.json' +$markdownPath = Join-Path $evidenceDirectory 'release-evidence.md' +$evidence | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 +@( + '# Bangdao August 1 Android RC evidence', + '', + "- Generated at: $($evidence['generatedAt'])", + "- Release eligible: $releaseEligible", + "- Git commit: $commit", + "- Clean worktree: $(-not $postBuildDirty)", + "- App: $($evidence['app']['packageName']) $versionName ($versionCode), API $($evidence['app']['targetSdkVersion'])", + "- APK: $artifactName", + "- APK SHA-256: $($evidence['artifacts']['apk']['sha256'])", + "- Signer SHA-256: $($evidence['app']['signerSha256'])", + "- Cleartext traffic allowed: $($evidence['app']['usesCleartextTraffic'])", + '', + '## Manual gates remaining', + '', + ($evidence.manualGatesRemaining | ForEach-Object { "- $_" }) +) | Set-Content -LiteralPath $markdownPath -Encoding UTF8 + +Write-Output "RC evidence written to $evidenceDirectory" +if (-not $releaseEligible) { + Write-Warning 'This is a rehearsal and cannot be distributed as the August 1 RC.' +}