[{"product":"星线 App","title":"星线 App 技术文档","url":"/app/","text":"# 星线 App 技术文档\n\n星线 App 的技术文档涵盖 iOS、Android、账号授权、聊天服务、本地数据、媒体与通话、安全、开发、测试和部署。资料日期为 2026-10-23；接口与操作说明依据可检查的源码和已有记录整理，实际可用范围以所安装版本和对应验收结果为准。\n\n## 从这里开始\n\n首次了解产品，先读快速开始与功能及平台范围。开发者可沿系统架构、登录与会话、API 参考进入协议；维护者可从开发配置、测试验收、运维和故障排查进入实际流程。\n\n| 主题 | 阅读入口 |\n| --- | --- |\n| 原生客户端 | iOS、Android、权限与无障碍 |\n| 消息与在线能力 | 消息及媒体、实时同步与通话、城市贴纸 |\n| 本机个人资料 | 本地记忆、医疗健康记录 |\n| 服务与数据 | 数据模型、安全和隐私、配置与环境 |\n| 交付与公开访问 | 版本发布、文档访问与引用 |\n\n## 按具体工作查阅\n\n| 需要完成的工作 | 建议顺序 | 应得到的结论 |\n| --- | --- | --- |\n| 接入一次完整登录 | 登录与会话 → 协议示例 → 客户端生命周期 | 两层 proof、回调校验、pending 恢复及会话失效如何处理 |\n| 接入消息和附件 | 好友与群 → 消息与媒体 → API | 关系权限、上传引用、重复发送、分页及本人删除的含义 |\n| 开发新事与定位 | 新事 → 位置共享 → 权限 | 可见范围、媒体处理、开始/更新/停止及拒绝权限后的状态 |\n| 调整本机记忆或工作台 | 工作台 → 本机格式 → 记忆转出 | 哪些数据按设备保存、如何兼容旧字段、失败时如何保留原库 |\n| 分析加密能力 | 安全与隐私 → MLS协议 | 当前普通消息链路、独立加密协议与本机加密的准确范围 |\n| 准备联调环境 | 模块地图 → 开发 → 配置参考 | 当前权威源码、依赖、配置校验及隔离测试的限制 |\n| 执行回归或发行 | 测试矩阵 → 发行检查 → 运维 | 每个场景的前置条件、预期结果、证据与回滚对象 |\n| 排查已有故障 | 故障排查 → 处置步骤 → 限制与错误 | 失败发生在哪一层、能否重试、怎样核对恢复结果 |\n\n阅读接口时先确认对象所属产品、当前账号和会话范围。阅读本机格式时先确认平台与安装环境。名词、ID、时间单位和删除状态可随时对照术语参考，避免把同名字段跨服务或跨资源使用。\n\n## 产品和系统入口\n\n星线 App 将联系、分享与本机日常记录放在一起。iOS 使用 SwiftUI，Android 使用 Jetpack Compose；聊天服务负责在线会话、消息、媒体、新事与相关权限，星线 Account 提供身份认证与授权。本机记忆、个人任务和收藏有独立的设备端存储边界。\n\n| 公开入口 | 用途 |\n| --- | --- |\n| App 官网 | 产品介绍与真实下载状态 |\n| 星线 Account | 账户登录、授权与账号管理 |\n| https://chat.thestarline.cn | 客户端聊天 API 基地址，受保护接口需要聊天会话 |\n| App 隐私说明 | 对用户生效的 App 隐私说明 |\n| 支持 | 产品支持入口 |\n\n本技术文档解释实现，不替代法律文本、正式服务承诺或对某个账号的访问授权。公开聊天 API 地址不是匿名读取他人数据的入口。\n\n## 怎样理解实现状态\n\n文档区分源码中可确认的实现、历史保存的测试记录，以及仍需要当前环境验证的事项。代码中有路由说明服务端存在该契约；客户端有调用说明该构建包含相应路径；真实设备、跨网络、推送与渠道状态需要各自证据。文档整理不会替代这些业务验收。\n\n当前下载站仍将 iOS 与 Android 标为准备中。没有可据此宣称已公开上架的 App Store 链接或正式稳定 APK。Android 存在历史真实授权和冷启动恢复记录；iOS 真实账号完整链路、iPad 可用性、跨网络通话与后台来电等仍有独立验收要求。watchOS 独立应用尚未交付，星线智联小程序发布流程已暂停。详情见发布状态。\n\n新增专题写到具体约束与差异：例如临时好友码的消费顺序、群主退出后的转移、客户端视频缓存方式、位置停止失败、本地字段兼容和记忆转出依赖。源码中发现的差异会标明核对位置与待验场景，不把“建议实现的行为”写成“当前必然如此”。\n\n## 数据与加密的重要边界\n\n本机记忆主体和附件使用设备端加密保存；它们目前没有跨设备云同步。退出账号、卸载、删除记忆和转出文件是不同操作，不能互相推断。\n\n聊天服务存在 MLS 加密核心和独立加密事件协议，但当前默认客户端聊天发送仍使用普通消息接口，附件也没有完整的客户端密文封装链路。因此不承诺所有当前聊天和附件已经完成端到端加密。阅读安全和隐私了解 TLS、本机加密、服务端字段加密与 MLS 的不同范围。\n\n## 文档中的示例与配置\n\nJSON 中的 UUID、消息编号、时间和文字是合成示例；配置只给变量名、用途、默认与启用条件，不给运行凭据。命令默认用于已隔离的开发或测试环境，涉及写入的步骤需使用授权测试账号。完整返回字段、分页边界、权限和错误状态应结合相邻表格阅读，不从单个成功响应推导全链路可用。\n\nAccount 与 App 使用独立维护来源。App 工作树附带的历史 Account 文件可能落后于当前身份服务，配置和迁移以当前 Account 权威源码为依据；模块归属和核对方法见源码模块地图。\n\n## 文档格式与引用\n\n每篇文章提供 HTML 页面与 Markdown 原文，正文不依赖 JavaScript。可通过文档清单查看目录，或下载完整文本。引用时请注明文章标题、原始地址和资料日期，具体规则见文档访问与引用。"},{"product":"星线 App","title":"快速开始","url":"/app/getting-started/","text":"# 快速开始\n\n本页帮助用户、开发者和测试人员找到正确入口。星线 App 当前处于开发与发布准备阶段；先核对你正在使用的版本和环境，再进行登录、存储或协议测试。\n\n## 查看可用版本\n\n到 App 官网查看 iOS 和 Android 的发布状态。没有真实公开下载地址时，页面显示准备中。开发预览截图、源码中的版本号、签名成功或商店草稿都不能证明可公开下载。\n\n取得开发预览包时，应确认提供方、平台、构建版本、签名与服务器环境。不要把未经核对的第三方 APK 当作正式发行版，也不要为了复现问题卸载保存个人记忆的现有 App。正式发布条件见版本发布。\n\n## 一次正常的使用路径\n\n1. 打开 App，查看聊天、工作台和我的等主要入口；具体布局以平台和构建为准。\n2. 使用聊天功能时进入星线账户登录；系统浏览器承载 Account 身份认证与授权，App 不应自己模拟 Account 登录页。\n3. 确认授权应用名称为“星线 App”，授权说明为：正在为“星线 App”授权您的账号信息数据。\n4. 完成授权后返回 App，客户端换取聊天会话并恢复用户资料。\n5. 在已有权限的会话发送消息，或在本机建立记忆、个人任务与收藏。\n\n账号授权成功、返回 App 成功和聊天会话建立成功是三个需要分别观察的步骤。授权码和一次性 ticket 不能重复使用，回调失败时应重新发起登录，不复制带参数的完整回跳链接给他人。协议说明见登录与会话。\n\n## 登录停在某一步时\n\n| 看到的状态 | 先检查什么 | 后续动作 |\n| --- | --- | --- |\n| App 尚未打开 Account | 当前构建的聊天地址、网络和授权开始响应 | 先定位请求是否成功，不反复改账号密码 |\n| 浏览器仍停在验证或授权页面 | 用户是否完成当前步骤，页面是否报错或要求补充验证 | 按页面完成必要验证；取消后从 App 重新发起 |\n| 浏览器结束但 App 未回到前台 | 回调 scheme、系统是否允许返回、对应构建是否仍安装 | 记录平台与停留页面；不要将回跳 URL 公开发送 |\n| App 收到回调但显示失败 | state、到期、pending、ticket 和换票请求 | 同一次流程必须匹配；重新开始，而不是混用旧参数 |\n| 已登录后重开变为未登录 | 聊天会话到期、安全存储与恢复读取结果 | 区分会话失效和读取失败，不清空记忆作为修复 |\n\nAndroid 当前信任正式 Account HTTPS 授权来源；将聊天服务切到本地不等于已经完成本地原生授权配置。开发人员应先阅读开发配置，不要按端口连通推断整个原生流程可用。\n\n## 先区分在线与本机功能\n\n| 使用内容 | 依赖 | 需要注意 |\n| --- | --- | --- |\n| 聊天、新事、媒体和通话 | 聊天会话、网络与对应服务 | 对方关系、群成员或媒体权限决定可见范围 |\n| 记忆主体和记忆附件 | 当前设备的本地存储与密钥 | 当前没有云同步，另一设备不会自动获得内容 |\n| 个人任务、收藏 | 客户端本地数据 | 不等同于独立星企产品的企业待办 |\n| 城市定位选择 | 操作触发的位置权限或允许的城市来源 | 拒绝权限不应阻止选择其他适用城市 |\n\n重要记忆在卸载、换机或清理应用数据前需要单独处理。转出 JSON 不等于已有完整导入恢复；本机加密也不能保护已经转出的明文文件。详见本地记忆。医疗健康模板是个人记录工具，不提供诊断、健康平台自动同步或已交付的用药提醒，详见医疗记录。\n\n## 第一次发送与记录\n\n聊天应先确认好友或群成员关系，再发送一条没有敏感内容的测试文字。附件分成上传和发送引用两个阶段，上传成功但第二步失败时，不能按对方已收到处理。重复点击或网络重试应对应同一个发送意图，客户端通过 clientId 与服务端消息 ID 合并，见消息与媒体。\n\n建立记忆时可先用合成标题、正文和一个小附件检查保存、列表、搜索、编辑与重开。确认原文件和预览都能读取后，再使用个人资料。选择了主题但所有内容均为空时不会保存一条空记录。附件名参与搜索，附件正文当前不等于已执行 OCR 或全文索引。\n\n工作台的个人任务和收藏也保存在设备上。两端的筛选与时间展示有差异，例如 iOS 有任务标题/备注搜索和独立逾期筛选，Android 当前不能按同一界面能力假定完全对等。完整字段、操作及排序说明见个人工作台。\n\n## 权限在对应操作中处理\n\n选择城市、扫码、视频和实时位置分别需要不同系统能力。拒绝相机后可用的图片/粘贴路径、拒绝定位后的城市选择、仅音频入会，以及系统设置重新授权，都应按实际入口判断。不要将一种权限拒绝解释为账号失效。\n\n实时位置开始后要分别确认本机持续采集状态和服务端共享状态。停止失败时仍需读取和确认远端是否已移除；简单离开页面不等于共享已终止。详细步骤见位置共享和权限说明。\n\n## 更新、退出与换机前\n\n更新前核对构建来源与签名兼容，优先保留既有应用数据。记录保存在当前设备不等于按聊天账号分别隔离；修改 Account 密码、退出或切换账号不会自动删除、重加密本机记忆。退出界面完成也不一定证明远端 logout 请求已经成功。\n\n换机或清理前先查记忆转出和本机格式。转出还依赖额外许可流程，当前跨服务闭环仍需核验；不能在未经验证的前提下把它作为唯一备份，更不能因已有 JSON 就认定当前版本一定支持导入恢复。读取损坏或密钥不可用时保留原安装和原文件，避免覆盖式修复。\n\n## 开发者的最短阅读路线\n\n先读系统架构了解职责，再按目标平台阅读 iOS或 Android。准备构建和隔离联调时使用开发配置，核对环境及配置。接口实现以API 参考为入口，尤其注意聊天 token 与 Account token 的区别。\n\n公开文档不提供生产客户端密钥、受保护数据库或实际用户测试账号。API 表格是协议说明，不代表第三方 App 自动获得注册、访问或生产接入许可。测试应使用明确隔离的合成数据，验收清单见测试。\n\n接口调用可按协议示例先核对 /me、分页、幂等发送、媒体引用和事件游标，再处理附加能力。开发测试与真实分发的检查项不同；需要准备发行包时，使用发行检查清单，逐项记录签名、版本、安装、授权、数据兼容及渠道状态。\n\n## 遇到问题时记录什么\n\n保留平台、系统版本、App 构建版本、发生时间、网络类型、操作步骤和已脱敏的错误提示。涉及回调时只描述停留在哪一步；涉及记忆时先区分保存失败、列表未刷新、文件权限或密钥不可用。不要把验证码、token、病历正文、聊天内容或含敏感参数的 URL 写入公开问题。\n\n前端可打开、API 健康、短信/邮件到达和双端消息可用分别验证。故障排查提供定位顺序，支持站提供后续联系入口。"},{"product":"星线 App","title":"功能及平台范围","url":"/app/features/","text":"# 功能及平台范围\n\n本页把星线 App 的主要能力与交付边界放在一起比较。表格表示截至 2026-10-23 可从当前源码确认的入口或模块，不表示每个版本、设备和生产账号都已完成端到端验收。实际发布条件见版本发布。\n\n## 功能矩阵\n\n| 能力 | iOS | Android | 边界与后续核验 |\n| --- | --- | --- | --- |\n| 星线账户登录 | 原生发起、系统授权浏览器、回跳换票 | 原生发起、系统授权浏览器、回跳换票 | 两端有 PKCE 及会话恢复；iOS 真实账号完整链路仍需独立验收 |\n| 单聊与群聊 | 会话、消息和状态界面 | 会话、消息和状态界面 | 网络、成员权限和服务状态决定可用性 |\n| 消息媒体 | 图片与普通文件上传引用 | 图片与普通文件上传引用 | 专用视频选择与播放属于新事；附件 E2EE 尚未闭环 |\n| 新事 | 发布、阅读及互动入口 | 发布、阅读及互动入口 | 与聊天媒体有独立的数据及权限边界 |\n| 城市贴纸 | 素材、选择和位置适配 | 素材、选择和位置适配 | 44 城素材三端一致；真实定位与网络场景另验 |\n| 本地记忆 | 本机加密主体与附件、主题模板 | 本机加密主体与附件、主题模板 | 没有跨设备云同步；转出与恢复能力分开核验 |\n| 医疗健康记录 | 结构化模板和关联记录 | 结构化模板和关联记录 | 个人归档，不是诊断、健康平台接入或提醒服务 |\n| 本地任务与收藏 | 个人日常入口 | 个人日常入口 | 本机功能，不是星企企业组织/频道功能 |\n| 语音与视频通话 | LiveKit、系统通话相关接入 | LiveKit、通话界面相关接入 | 两台真机跨网络、后台与锁屏来电尚需验收 |\n| MLS 加密协议 | 有独立传输模块及设备密钥准备 | 未发现原生 OpenMLS 主消息接入 | 默认聊天路径不能标为全量已验收 E2EE |\n\n双端共享产品方向，但界面组织、系统权限、后台执行和安全存储分别使用平台机制。不能用 Android 一次历史成功替代 iOS 验收，也不能用模拟器截图代替实体设备权限与推送结果。\n\n## 双端差异应怎样核对\n\n| 场景 | 当前需要区分的实现 | 详细说明 |\n| --- | --- | --- |\n| 原生登录中断 | iOS 保存 API 基地址与待换票 ticket；Android pending 字段及清理时机不同 | 登录、生命周期 |\n| 个人任务筛选 | iOS 有今天/逾期/完成及搜索；Android 当前待办/已完成/全部，首页今日统计另有截止条件 | 工作台 |\n| 记忆序列化 | iOS 与 Android 的记录字段、日期表示和加密文件封装不同 | 本机格式 |\n| 新事视频 | 服务端支持单个Range，但当前客户端先完整下载后播放 | 新事 |\n| 实时位置停止 | 页面操作与持续通知停止分支的本机/远端顺序不同 | 位置共享 |\n| MLS阻塞恢复 | 服务端有重建与确认契约，不等于客户端会自动完成所有重键 | MLS协议 |\n\n这些差异影响测试步骤与失败恢复。改进任一端时应先明确目标行为，再核对相同字段在另一端如何保存、搜索、展示和转出，不能仅以同一功能名称认定数据格式互换。\n\n## 哪些功能需要账号\n\n在线聊天服务以聊天 Bearer token 识别用户，再按会话成员、好友关系、群成员、媒体所属和通话参与者检查权限。Account 负责身份认证，聊天服务负责在线业务授权；知道公开 API 地址或对象 ID 不赋予访问权。\n\n好友申请、临时码、删除、屏蔽和群成员变化有各自的消费与清理顺序。新事的 public 指已登录 API 用户可见；媒体还要通过帖子或消息可见性检查。个人群的群主退出、本人隐藏聊天、退群和完整解散不属于同一种操作，见好友与群与新事。\n\n记忆主体存于设备，个人任务和收藏也有本地数据边界。登录、退出、清理本机和服务端账号处理需要分别理解，详见数据模型与本地记忆。\n\n## 网络与离线\n\n在线消息和新事需要后端。事件流帮助客户端重新获取状态，但它不是聊天正文的永久备份。发送队列、重试与去重用于恢复操作，不等于所有离线操作都会自动成功。\n\n本机记忆可以在设备上保存与读取；涉及服务器识别、转出授权或其他网络功能时仍需要相应服务。服务器识别与本地规则提取是不同路径，提交到模型的字段与隐私边界需按本地记忆和安全和隐私核对。\n\n## 按数据类型比较上限\n\n聊天文件、聊天贴纸、新事图片/视频与本机记忆附件由不同入口控制。普通聊天上传文件为10MiB级限制，本机单个记忆附件为20MiB级限制，新事视频另有30MiB级限制；具体字节值、数量、账号总量和宽高要求见限制与错误。在界面显示“附件过大”时，需要指出当前是哪一种附件，而不是用一个通用大小覆盖所有功能。\n\n同样，消息正文、医疗长字段、标题和 JSON 请求整体都有各自限制。原生字符串长度与服务端码点计数也可能不同，前端通过不代表服务端一定通过。字段长度、类型与迁移细则见本机格式和医疗健康记录。\n\n## 检查一个功能是否完整可用\n\n先检查代码入口和运行依赖，再验证正常路径、权限拒绝、重复操作、断网、中断恢复和升级后的数据兼容。网络功能还需核对服务端实际状态，本机功能还需核对重新打开与密钥可用性。音视频与推送必须补充两台真实设备及跨网络场景。\n\n每个结论应包含平台、构建、系统版本、环境、操作步骤和证据。详细矩阵见测试场景，出现异常后按故障处置分阶段处理。仅有源码或历史记录的能力继续标明范围，不因本页文字增加而自动改变交付状态。\n\n## 单独列出的未交付事项\n\n- watchOS 独立联网应用尚未交付，当前 iOS 工程不能作为 watchOS 成品说明。\n- iPad 可用性与 App Store 审核需单独处理；已进入审核流程不等于已公开上架。\n- 当前记忆没有设备间云同步，没有可据转出 JSON 推导的完整导入恢复。\n- 没有已交付的系统健康平台自动写入、医疗诊断或用药推送提醒。\n- 全量聊天与附件 E2EE、跨网络音视频和后台来电不能仅凭模块存在认定完成。\n\n## 与其他星线产品的关系\n\n星线 Account 是共享身份系统。星企是独立企业产品；同一聊天服务存在的 /enterprise/* 接口不表示星线 App 前台已经提供企业组织、企业频道、审批或企业待办。星线智联小程序有独立上传与发布状态，当前流程已暂停。各产品共享组件时，仍按各自的客户端、权限和发布记录判断可用性。"},{"product":"星线 App","title":"系统架构","url":"/app/architecture/","text":"# 系统架构\n\n星线 App 由 iOS、Android 原生客户端、聊天服务、星线 Account 身份服务及通话媒体服务组成。本页依据当前源码说明组件责任、请求路径与实现边界；源码中存在的模块不自动代表客户端已经接入，也不代表生产环境已经完成验收。\n\n## 组件与责任\n\n| 组件 | 责任 | 主要技术 |\n| --- | --- | --- |\n| iOS 客户端 | 原生登录、聊天、新事、记忆、位置与通话界面；会话和本地加密状态管理 | Swift、SwiftUI、Keychain、CryptoKit、LiveKit |\n| Android 客户端 | 原生登录、聊天、新事、记忆、位置与通话界面；本地安全存储 | Kotlin、Jetpack Compose、Android Keystore、LiveKit |\n| 聊天服务 | 将 Account 身份映射为聊天用户；会话、好友、群、消息、媒体、新事、事件与通话状态 | Node.js 24 及以上、ES modules、HTTP、内置 SQLite |\n| Account | 登录、注册、身份验证、授权页面、OIDC 授权码与用户信息 | 独立身份系统，OIDC issuer 为 https://account.thestarline.cn/oidc |\n| 加密核心 | MLS 设备、密钥包、群状态、消息验证与加密状态持久化 | Rust、OpenMLS，C ABI 与 WebAssembly 适配 |\n| 媒体与推送 | 音视频房间、短期加入凭证、iOS VoIP 来电唤醒 | LiveKit、APNs VoIP |\n\n公开聊天 API 基地址是 https://chat.thestarline.cn。下载站及本技术文档位于 https://app.thestarline.cn，它们与鉴权 API 分属不同入口。客户端不持有 Account 的机密客户端凭据、LiveKit 服务端密钥或 APNs 签名私钥。\n\n!系统架构：iOS 与 Android 经 HTTPS 访问聊天服务，使用系统浏览器授权；聊天服务连接 Account、SQLite、LiveKit 与 APNs；MLS 核心目前仅有 iOS 独立适配及服务端 WASM 校验器。\n\n两端通过系统浏览器进入 Account，经聊天服务的授权码回调完成换票；聊天 API 使用 HTTPS JSON、二进制及事件长轮询。通话媒体由客户端直接连接 LiveKit，聊天服务只负责状态、房间控制和短期凭证。图中 MLS 是独立模块能力，不代表默认消息路径已启用。\n\n## 一次登录怎样变成聊天会话\n\n原生客户端生成本机 codeVerifier，向 /auth/native/start 发送 S256 codeChallenge。聊天服务保存挑战并另行生成 Account 授权所用的 verifier、state；浏览器完成 Account 授权后回到 https://chat.thestarline.cn/auth/callback。服务端使用机密客户端认证及自己的 verifier 换取 Account 访问令牌，再读取用户信息，将 sub 映射到本地聊天用户。\n\n最终返回 App 的是一次性聊天 ticket。App 必须向 /auth/exchange 同时提交 ticket、原 state 和本机 verifier，才能获得聊天 Bearer token。Account 访问令牌不会作为聊天 token 下发。详见登录与会话。\n\n## 数据与同步路径\n\n聊天服务使用 node:sqlite 的同步连接，启用外键、WAL 和 5 秒 busy timeout。消息写入、去重和事件生成通常在 BEGIN IMMEDIATE 事务中完成，因此客户端可以用同一个 clientId 安全重试同一条发送。这个实现模型适合单个服务进程与其数据库协作；当前进程内的长轮询等待器不会在多个实例间自动广播，扩展部署需要另行设计通知和数据一致性方案。\n\n/events 是按用户过滤的增量变更通知，携带事件 ID、会话或群 ID 和可选消息 ID，不携带完整聊天正文。客户端收到事件后重新获取列表、消息或通话状态。消息 ID、普通事件 ID、MLS sequence 是不同序列，不能混用。\n\n媒体以 BLOB 保存，客户端先上传取得 media.id，随后发送引用该 ID 的消息。下载会重新检查当前用户对媒体的可见性。新事媒体使用单独的数据表、配额和权限规则。记忆主体主要由原生客户端管理，聊天服务提供识别和转出许可桥接，并不存在一组可据此推导的“云端记忆 CRUD”接口。详见数据模型与消息和媒体。\n\n## 加密模块与当前主链路\n\nRust 核心支持 MLS 1.0，固定使用 MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 套件。聊天服务提供设备密钥包、会话准备、Welcome、Commit、应用密文事件和确认接口。iOS 有独立 ChatEncryptedTransport 处理文字密文同步、设备签名密钥固定、离线队列及确认恢复。\n\n当前 iOS ChatStore 主聊天发送与 Android 发送仍走普通 /messages 路径；iOS 会准备设备密钥包，但不能仅凭此认定某个聊天已启用 MLS。普通媒体上传没有独立附件密钥、客户端封装密文或附件解密路径。文档因此将“加密模块能力”“实际主聊天路径”“生产验收结果”分别说明。已准备的 MLS 会话会拒绝继续通过普通接口发送明文，不能通过回退接口绕过模式约束。\n\n## 产品边界与验收\n\n共享后台还包含 /enterprise/* 企业组织、成员、企业频道、待办、公告和服务接口。这些服务于独立的星企产品，使用企业成员与角色校验；不能把它们列为星线 App 的前台工作台功能，也不能把个人聊天群等同于企业频道。\n\n通话 HTTP 状态机、LiveKit SDK 接入和 APNs 发布器均可从源码确认。真实双设备跨网络音视频、后台来电、锁屏接听和生产 APNs 到达率需要单独验收。服务健康返回 ok 只说明 HTTP 服务可响应，不证明 Account、模型、MLS 校验器、媒体或推送全部可用。继续阅读实时同步与通话、测试、运维与安全和隐私。\n\n深入流程见联系人与个人群、新事、位置共享和MLS协议；数值与失败处理集中在限制与错误。"},{"product":"星线 App","title":"登录、授权与会话","url":"/app/authentication/","text":"# 登录、授权与会话\n\n星线 App 使用星线 Account 完成身份验证，再由聊天服务签发独立的聊天会话。原生客户端通过系统浏览器参与授权，不把 Account 密码交给聊天服务；当前原生协议同时保护 Account 授权码交换和 App 聊天票据交换。\n\n## 地址与身份边界\n\n| 项目 | 地址或规则 |\n| --- | --- |\n| 聊天 API | https://chat.thestarline.cn |\n| Account issuer | https://account.thestarline.cn/oidc |\n| 正式 HTTPS 授权回调 | https://chat.thestarline.cn/auth/callback |\n| 星线 App 返回入口 | starlineworkbench://chat-auth |\n| 星企返回入口 | xingqiworkbench://auth，仅用于独立星企产品 |\n| 授权范围 | openid email profile phone |\n\n聊天服务通过 issuer 的 /.well-known/openid-configuration 获取授权、令牌和用户信息端点，检查返回 issuer 完全一致，且这三个端点位于配置 issuer 的同源范围。生产启动还要求正式 Account issuer、HTTPS 公网 URL 和服务端机密 OIDC 客户端配置。具体客户端 ID、密钥和注册记录不属于公开示例。\n\n## 原生登录的两层证明\n\n1. App 使用系统安全随机源生成 32 字节随机值，编码为不带填充的 base64url verifier，并计算 BASE64URL(SHA256(verifier))。\n2. App 调用 POST /auth/native/start，发送 {app:\"starline\",codeChallenge,screen:\"login\"}。注册入口可把 screen 设为 register。\n3. 聊天服务验证挑战是规范的 43 字符 base64url，创建有效期 10 分钟的登录 flow。响应包含 authorizationURL、state、expiresAt。客户端须保存本次 verifier、state 和到期时间，再打开返回 URL；iOS 还保存 API 基地址。\n4. 聊天服务另行生成自己的 verifier，与 S256 挑战发送给 Account。原生入口设置 prompt=login；注册入口额外设置 screen_hint=signup。这层 verifier 留在聊天服务端。\n5. Account 将授权码和 state 回调到正式 HTTPS 地址。聊天服务一次性消费 flow，使用 client_secret_basic、授权码和服务端 verifier 请求令牌，随后请求用户信息。\n6. 聊天服务要求 sub 非空，并要求邮箱已验证或手机号已验证。已验证手机号会规范化；显示名按 name、邮箱前缀和默认名称取值，最多保存 80 个 UTF-16 代码单元。\n7. 服务端创建有效期 60 秒的一次性 ticket，将 ticket 与原 state 放入 App 返回 URL。App 对返回入口和参数做校验后，调用 POST /auth/exchange，提交 {ticket,state,codeVerifier}。\n8. 服务端核对 state 哈希与 App verifier 的 S256 值，消费 ticket，签发聊天会话；响应为 {accessToken,expiresAt,user,eventCursor}。\n\n两层 verifier 是不同随机值：服务端的一层保护 Account 授权码，App 的一层让仅截获自定义 scheme ticket 的程序无法单独换取聊天会话。客户端不能直接拿 Account access token 调用聊天业务接口。\n\n## 到期、消费与失败语义\n\n| 对象 | 默认有效期 | 重放处理 |\n| --- | --- | --- |\n| 原生登录 flow | 10 分钟 | HTTPS 回调读取时删除；过期或错误回调返回 login_failed |\n| 兼容 /auth/start flow | 40 分钟 | 仅为兼容浏览器入口，不能替代原生证明流程 |\n| 聊天 ticket | 60 秒 | 成功换取后删除；错误原生 proof 不签发会话 |\n| 聊天 device session | 24 小时 | 每次 Bearer 请求检查哈希与到期时间 |\n\nexpiresAt 是 Unix 毫秒时间戳。不要把 Account OIDC token 的期限当成聊天会话期限；当前 Account 源码的访问令牌和 ID token TTL 为 900 秒，授权码为 60 秒。它们用于身份协议，不是 App 业务会话的刷新机制。聊天服务当前没有公开 refresh-token 路由。\n\n原生开始接口可能返回 INVALID_APP、INVALID_CHALLENGE、INVALID_SCREEN 或 LOGIN_UNAVAILABLE。票据缺失、过期、已消费、state 不匹配或 verifier 不匹配会导致 INVALID_TICKET。客户端应重新开始授权；不能删除 proof 参数去尝试兼容 ticket 模式。错误响应形状见API 参考。\n\n## 客户端回调和持久化\n\niOS 只接受预期 scheme 与 host，拒绝用户名、密码、端口、fragment、异常 path、重复 state、重复 ticket/error，以及 ticket 与 error 同时出现。回调 state 必须等于当前未到期 flow，且必须属于同一 API 基地址。iOS 在交换前保存 ticket，支持进程中断后恢复；成功后先持久化 session，再清理 pending 状态，避免清理失败把已成功登录改成失败。\n\nAndroid 的 pending 仅保存 verifier、state、expiresAt，没有 iOS 的 API 基地址和 ticket 字段；回调通过校验后会先清除 pending，再发起换票。它不能据此获得与 iOS 相同的“换票中进程中断后持久恢复”能力，失败时需重新开始授权。两端的实现与调试方法分别见iOS和Android。登录 token 不应写入普通 URL、截图、日志、分析事件或静态网站。iOS 使用 Keychain，Android 使用 Keystore 支持的本地加密存储。\n\n## 使用与退出\n\n除健康检查和授权入口外，聊天 API 使用 Authorization: Bearer <chat_access_token>。服务端接受规定格式的 Bearer token，并将其 SHA-256 摘要与未过期 session 记录匹配。GET /me 返回当前聊天用户与该用户最新 eventCursor，可用于恢复登录后的状态同步。\n\nPOST /auth/logout 成功时删除当前聊天会话；它不等于 Account 全站退出或撤销其他设备。两端都会清除本机状态，并忽略远端退出请求失败；Android 先清本机再尝试服务端退出，iOS 尝试释放通话、注销推送和服务端退出后清本机。所以“已退出界面”不能证明服务端撤销成功：网络失败时，旧 session 可能持续到 24 小时到期。Account 修改密码或撤销 Account 会话也没有自动联动撤销聊天 session 的实现。\n\nVoIP 设备与具体 session 摘要关联，服务端注销或到期后不会继续被查询为该会话的有效推送目标。本机退出还会停止轮询、释放媒体连接并清理当前账号界面状态；这些本机动作与远端 token 撤销应分别验证。\n\n## 记忆转出许可的独立边界\n\n聊天服务还有 /memory-export/start 与 /memory-export/exchange，用于当前登录用户的额外转出验证，不能用于普通登录。开始流程有效期 5 分钟；服务端向 Account 的 redeem 接口验证 ticket、state、verifier，并检查返回用户与当前 Account subject 一致。\n\n当前作为权威依据的 Account 主源码未包含对应 redeem 路由；App 工作树随附的历史 Account 副本虽然有实现，也不能替代当前主源码或证明其已部署。因此只能确认聊天侧桥接契约，不能认定该跨服务流程当前可用。客户端本地导出和 Account 验证是否完整闭环，应按照测试取得实际结果后再更新状态。"},{"product":"星线 App","title":"聊天服务 API 参考","url":"/app/api-reference/","text":"# 聊天服务 API 参考\n\n本页列出当前聊天服务源码的 HTTP 契约，包括星线 App 使用的个人业务接口、独立 MLS 接口及共享后台中属于星企的企业接口。基地址为 https://chat.thestarline.cn。路由存在仅代表代码实现，不能证明生产依赖已经配置、客户端已经接入或真实设备路径已经验收。\n\n## 通用约定\n\n除 GET /health、GET /auth/start、POST /auth/native/start、GET /auth/callback 和 POST /auth/exchange 外，以下接口均要求 Authorization: Bearer <chat_access_token>。这是聊天服务签发的 token，不能用 Account access token、OIDC ID token 或 LiveKit 媒体 token 替代。星企接口还要求相应企业成员和角色权限。\n\nJSON 请求使用 Content-Type: application/json，根必须为对象，默认请求体上限为 16,384 字节；本文标注的接口有单独上限。二进制上传直接发送文件字节，不是 multipart。JSON 响应为 UTF-8，使用 Cache-Control: no-store 与 X-Content-Type-Options: nosniff。媒体下载使用单独响应头。\n\n时间值是 Unix 毫秒时间戳。UUID 字段用字符串；普通消息、事件、新事、评论等 ID 为安全整数；企业消息 ID 对外为字符串。分页游标均作用于指定资源，不能互换。下表中的 {id} 表示路径参数，不含字面花括号。\n\n错误响应统一为：\n\n``json\n{\"error\":{\"code\":\"INVALID_CURSOR\",\"message\":\"游标无效\"}}\n`\n\n| HTTP 状态 | 常见代码与含义 |\n| --- | --- |\n| 400 | INVALID_JSON、INVALID_CURSOR、INVALID_MESSAGE 等；请求字段或格式无效 |\n| 401 | UNAUTHORIZED、INVALID_TICKET；登录失效或票据证明无效 |\n| 403 | FORBIDDEN、CONTACT_BLOCKED、NOT_FRIENDS、UNKNOWN_DEVICE；无权执行 |\n| 404 | NOT_FOUND、PROFILE_NOT_FOUND、NOT_MEMBER；资源不存在或当前不可访问 |\n| 409 | CLIENT_ID_REUSED、E2EE_REQUIRED、CALL_BUSY、CALL_STATE 及 MLS 状态冲突 |\n| 413 | TOO_LARGE、STORAGE_LIMIT；单次请求或账号配额超限 |\n| 415 | CONTENT_TYPE；JSON 接口未发送 JSON 内容类型 |\n| 416 | INVALID_RANGE；新事媒体 Range 无效 |\n| 429 | TOO_MANY_LOOKUPS、TOO_MANY_CODES、TOO_MANY_RECOGNITIONS |\n| 503 | LOGIN_UNAVAILABLE、E2EE_UNAVAILABLE、CALL_MEDIA_UNAVAILABLE、MEMORY_MODEL_UNAVAILABLE 等依赖不可用 |\n| 500 | INTERNAL；未映射的服务异常 |\n\n错误码必须与 HTTP 状态和具体路由一起判断。例如 NOT_FRIENDS 在联系动作中可为403，而建群/邀请成员返回400，邀请还可能是总人数超过50人；不能只按代码名推断唯一原因。详见限制与错误。\n\n未支持的路由通常为 404，不保证返回 405。/__preview/enterprise/session 被明确禁用，始终返回 404，不是登录或测试会话入口。\n\n## 健康与授权\n\n这些接口无需聊天 Bearer token；授权完成后才获得业务会话。完整信任与到期语义见登录与会话。\n\n| Method 与路径 | 参数 | 成功响应与限制 |\n| --- | --- | --- |\n| GET /health | 无 | 200 {status:\"ok\"}；仅 HTTP 存活性，不检查全部下游 |\n| GET /auth/start | query：app 可选 starline/xingqi；screen=register 可进入注册 | 302 Account 授权地址；兼容流程约 40 分钟，原生客户端应使用下一接口 |\n| POST /auth/native/start | JSON：app 默认 starline；codeChallenge 必填，规范 43 字符 base64url；screen 可选 login/register | 200 {authorizationURL,state,expiresAt}；flow 10 分钟，Account PKCE 另由服务端生成 |\n| GET /auth/callback | Account 回传 query：state、code；授权失败可含 error | 302 App 返回 URL，携带 ticket 与原生 state，或 error=login_failed；正式地址 https://chat.thestarline.cn/auth/callback |\n| POST /auth/exchange | JSON：ticket 字符串最多 128；原生必须另有 state、codeVerifier，verifier 为 43–128 字符 base64url | 200 {accessToken,expiresAt,user,eventCursor}；ticket 60 秒且一次性；session 默认 24 小时 |\n| POST /auth/logout | Bearer；无需业务字段 | 200 {status:\"signed_out\"}；撤销当前聊天 session |\n\nnative/start 的 app 不合法返回 INVALID_APP，挑战错误返回 INVALID_CHALLENGE，screen 错误返回 INVALID_SCREEN。服务端 Account token 交换使用机密客户端认证与服务端 PKCE verifier，App 不发送客户端 secret。原生 proof 失败不会签发会话；兼容票据不能携带原生 proof 伪装成新流程。两端本机退出都可能忽略远端logout失败，本机已清除不证明服务端撤销；Account密码变化也不自动撤销独立聊天session。\n\n以下仅示意请求形状。占位挑战、ticket、state 和 verifier 必须由同一次测试授权流程产生，不能照抄为生产凭据：\n\n`sh\ncurl -X POST 'https://chat.thestarline.cn/auth/native/start' \\\n  -H 'Content-Type: application/json' \\\n  --data '{\"app\":\"starline\",\"screen\":\"login\",\"codeChallenge\":\"<S256_BASE64URL_43>\"}'\n\ncurl -X POST 'https://chat.thestarline.cn/auth/exchange' \\\n  -H 'Content-Type: application/json' \\\n  --data '{\"ticket\":\"<ONE_USE_TICKET>\",\"state\":\"<SAME_STATE>\",\"codeVerifier\":\"<LOCAL_VERIFIER>\"}'\n`\n\n## 当前用户与资料\n\n所有接口要求 Bearer。User 形状为 {id,displayName,friendId}。Profile 形状为 {user,bio,contactPhone}，本人读取时另含 phoneVisibility、visibleToUserIds；不可见手机号为 null。\n\n| Method 与路径 | 参数与权限 | 成功响应 |\n| --- | --- | --- |\n| GET /me | 当前 session | 200 {user,eventCursor} |\n| GET /profiles/{userId} | 目标用户 UUID；按当前用户可见性过滤联系方式 | 200 {profile}；用户不存在为 PROFILE_NOT_FOUND |\n| PUT /me/profile | bio 必填字符串，trim 后 ≤160 字；contactPhone 必填字符串，空字符串清除；phoneVisibility 必填 private/selected/friends/everyone；visibleToUserIds 默认 [] | 200 {profile}；指定可见用户最多 100 位、不得重复，必须当前好友，非 selected 不得传非空列表 |\n| GET /blocks | 当前用户 | 200 {users:[User...]} |\n| PUT /blocks/{userId} | 不能是自己；目标需存在 | 200 {status:\"blocked\",conversationId}；有进行中的单聊通话时先处理媒体与通话状态 |\n| DELETE /blocks/{userId} | 目标需存在，不能是自己 | 200 {status:\"unblocked\",conversationId} |\n\n手机号删除空格与短横线，11 位中国大陆手机号补 +86，其他形式需符合 + 开头的 8–15 位国际号码。资料手机号可见性不等于 Account 安全手机号设置。错误包括 INVALID_BIO、INVALID_PHONE、INVALID_VISIBILITY、INVALID_VIEWERS、SELF_BLOCK；屏蔽中媒体服务失败可返回 CALL_MEDIA_UNAVAILABLE。\n\n## 好友与单聊\n\n### 好友建立\n\n| Method 与路径 | 请求或查询 | 成功响应与限制 |\n| --- | --- | --- |\n| POST /friend-codes | JSON {} | 201 {code,expiresAt}；12 位十六进制临时码，10 分钟；生成新码替换旧码；每用户每 10 分钟最多 10 次生成 |\n| GET /friend-requests | 无 | 200 {incoming:[{id,peer,createdAt}],outgoing:[...]} |\n| POST /friend-requests | JSON 中 friendId、phone、temporaryCode 三种字符串恰选一种 | 新申请 201、已有同向申请 200；{status,peer,requestId} |\n| POST /friend-requests/{requestId}/accept | 当前用户必须为接收者 | 200 {status:\"accepted\",conversationId} |\n| DELETE /friend-requests/{requestId} | 当前用户必须为申请双方之一 | 200 {status:\"deleted\"} |\n| DELETE /friends/{conversationId} | 路径是会话 UUID，当前仍是好友；不是用户 UUID | 200 {status:\"deleted\"}；处理进行中的通话、好友关系与加密阻塞 |\n\nfriendId 去空格/短横线并转大写后须 11 位字母数字；temporaryCode 同样规范化后须 12 位 A–F/0–9。手机号和临时码查找分别按用户、方法每 10 分钟最多 20 次。错误包括 INVALID_INPUT、INVALID_FRIEND_ID、INVALID_FRIEND_CODE、SELF_FRIEND、ALREADY_FRIENDS、INCOMING_REQUEST、CONTACT_BLOCKED 和 TOO_MANY_LOOKUPS。未知或过期临时码返回 NOT_FOUND；收到对方申请时应处理 incoming，而不是重复反向申请。\n\n### 单聊列表与消息\n\n| Method 与路径 | 参数与行为 | 成功响应 |\n| --- | --- | --- |\n| GET /conversations | query includeHidden=1 可包含本人隐藏聊天 | 200 {conversations:[...]} |\n| DELETE /conversations/{conversationId} | 会话参与者；推进本人历史边界并隐藏 | 200 {status:\"deleted\"} |\n| POST /conversations/{conversationId}/restore | 会话参与者；只取消隐藏 | 200 {status:\"restored\"} |\n| GET /conversations/{conversationId}/messages | query after 默认0；before 可选；limit 默认50、1–100；非零 after 与 before 不可同时使用 | 200 {messages:[...]}，按消息 ID 升序 |\n| POST /conversations/{conversationId}/messages | JSON clientId UUID；text 与 mediaId 恰选一个；当前好友、未屏蔽且模式为 legacy | 首次 201，重复 200，{message} |\n| DELETE /conversations/{conversationId}/messages/{messageId} | 会话参与者；只对本人隐藏指定消息 | 200 {status:\"deleted\"} |\n| POST /conversations/{conversationId}/read | JSON {lastMessageId}；非负安全整数且必须是本会话消息 | 200 {status:\"read\"}，已读位置只前进 |\n\n会话对象含 id、peer、lastMessage、unreadCount、peerReadMessageId、createdAt、isFriend、isBlocked、isHidden。普通消息含 id、senderId、clientId、body、media、createdAt；发送回执另含 conversationId、duplicate。media 不存在时为 null。文字 trim 后须非空且 ≤2000 Unicode 码点；媒体必须属于当前发送者。\n\n相同发送者的 clientId 在普通单聊消息表中唯一；不同会话、正文或媒体复用同一 ID 返回 CLIENT_ID_REUSED。preparing/active/blocked 的 MLS 会话拒绝普通明文发送并返回 E2EE_REQUIRED。读取仍受个人历史边界和隐藏消息过滤。恢复聊天不恢复被历史边界排除的内容。\n\n`sh\nCHAT_TOKEN='<AUTHORIZED_TEST_SESSION>'\nCONVERSATION_ID='11111111-1111-4111-8111-111111111111'\ncurl \"https://chat.thestarline.cn/conversations/${CONVERSATION_ID}/messages?limit=50\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\"\ncurl -X POST \"https://chat.thestarline.cn/conversations/${CONVERSATION_ID}/messages\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\" -H 'Content-Type: application/json' \\\n  --data '{\"clientId\":\"22222222-2222-4222-8222-222222222222\",\"text\":\"这是合成测试消息\"}'\n`\n\n示例 UUID 是合成值，必须换成测试账号实际有权访问的范围；发送属于写操作，请仅在授权测试会话中使用。\n\n## 个人群聊\n\n企业频道使用另一组 enterprise 路由，不能与以下个人群互换。\n\n| Method 与路径 | 请求或查询、权限 | 成功响应 |\n| --- | --- | --- |\n| POST /groups | JSON name trim 后1–40字；memberIds 为1–49个不同好友 UUID，不含自己 | 201 {group}；加发起者后最多50人 |\n| GET /groups | query includeHidden=1 | 200 {groups:[...]} |\n| GET /groups/{groupId} | 当前群成员 | 200 {group,members:[User...]} |\n| DELETE /groups/{groupId} | 当前群成员，删除本人视图并推进历史边界 | 200 {status:\"deleted\"} |\n| POST /groups/{groupId}/restore | 当前群成员 | 200 {status:\"restored\"} |\n| POST /groups/{groupId}/mute | 当前群成员；JSON {muted:boolean} | 200 {group} |\n| POST /groups/{groupId}/name | 群主；JSON name为1–40字 | 200 {group} |\n| POST /groups/{groupId}/members | 群主；JSON memberIds为1–49个不同好友UUID、不含自己；总人数≤50 | 200 {group,members}；已有加密范围进入blocked |\n| DELETE /groups/{groupId}/members/{userId} | 群主；不能移除自己；目标必须为成员 | 200 {group,members}；有通话先清理媒体与状态 |\n| POST /groups/{groupId}/leave | 当前成员 | 200 {status:\"left\"}；有通话处理成员变化；加密范围blocked |\n| POST /groups/{groupId}/disband | 群主 | 200 {status:\"disbanded\"}；解散群与相关数据 |\n| GET /groups/{groupId}/messages | 当前成员；query before可选，limit默认50、1–100 | 200 {messages:[...]}，升序；不支持单聊的after增量契约 |\n| POST /groups/{groupId}/messages | 当前成员且legacy；JSON clientId、text/mediaId与单聊相同 | 201/200 {message}，返回groupId及duplicate |\n| DELETE /groups/{groupId}/messages/{messageId} | 当前成员；对本人隐藏 | 200 {status:\"deleted\"} |\n| POST /groups/{groupId}/read | JSON lastMessageId必须属于本群 | 200 {status:\"read\"} |\n\ngroup 对象含 id、name、ownerId、createdAt、memberCount、lastMessage、unreadCount、allReadMessageId、isMuted、isHidden。群消息额外含 senderDisplayName。常见错误是 INVALID_GROUP_NAME、INVALID_GROUP_MEMBERS、NOT_FRIENDS、FORBIDDEN、SELF_REMOVE、INVALID_MESSAGE、CLIENT_ID_REUSED、E2EE_REQUIRED；媒体处理失败可使成员操作返回503。\n\n## 聊天文件、贴纸与城市\n\n| Method 与路径 | 请求、权限和限制 | 成功响应 |\n| --- | --- | --- |\n| POST /media | query kind=file/sticker、name；Content-Type为实际MIME；body原始字节 | 201 {media} |\n| GET /media/{mediaId} | 所有者或当前可见消息的接收用户 | 200 原始字节；attachment下载；private缓存3600秒；无Range契约 |\n| GET /stickers | 当前用户 | 200 {stickers:[Media...]}；仅自定义贴纸 |\n| POST /stickers/city | JSON恰有一个字段cityId，须来自随客户端分发的城市目录 | 200 {media}；按当前账号与城市素材去重 |\n| GET /location/city | 无业务参数；按可信请求来源粗略定位 | 200 {city:string或null,countryCode:string或null,source:\"ip\"} |\n\nMedia={id,kind,name,mimeType,size,createdAt}。file最大10 MiB，sticker最大512 KiB；账号聊天媒体总量100 MiB，自定义贴纸最多100个。文件名trim后1–120码点，禁止控制字符与路径分隔符。MIME需符合类型格式；贴纸必须有效PNG或JPEG，PNG宽高均≤2048。城市ID格式为小写字母开头、随后小写字母/数字/短横线、总长≤64，还必须存在于实际目录。\n\n错误包括 INVALID_KIND、INVALID_NAME、INVALID_TYPE、EMPTY_FILE、INVALID_STICKER、TOO_LARGE、STORAGE_LIMIT、INVALID_CITY、CITY_STICKER_UNAVAILABLE、NOT_FOUND。普通上传保存原始字节，没有自动附件加密；上传后仍需发送引用mediaId的消息。下载的owner分支独立，非所有者的单聊可见消息分支不重查is_friend/block；群消息分支要求当前成员。因此删好友或屏蔽不保证撤销原先仍可见普通媒体。依据：chat-service/src/database.mjs:239-254。文件头校验不等于病毒扫描。\n\n`sh\ncurl -X POST 'https://chat.thestarline.cn/media?kind=file&name=example.txt' \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\" \\\n  -H 'Content-Type: text/plain' --data-binary '合成附件内容'\n`\n\n## 新事与互动\n\n所有新事接口均需Bearer。visibility可为friends、selected、public、private，默认friends；public指已登录API用户可见，不是匿名网页。selected必须指定当前好友，屏蔽和个人隐藏会进一步过滤。\n\n| Method 与路径 | 请求或查询 | 成功响应与限制 |\n| --- | --- | --- |\n| POST /moments/media | query name；原始字节；MIME仅image/jpeg、image/png、video/mp4、video/quicktime | 201 {media}；图片≤10MiB，视频≤30MiB；每账号500MiB |\n| GET /moments/media/{mediaId} | 按帖子可见性；可选单个Range：bytes=start-end，end可空 | 200/206二进制；支持Accept-Ranges；private缓存300秒 |\n| POST /moments | JSON text可选且≤2000字；visibility；viewerIds默认[]；mediaIds默认[] | 201 {post}；JSON≤20000字节；文字或媒体至少一项 |\n| GET /moments/feed | query before默认最大安全整数；limit默认20、1–50 | 200 {posts,nextBefore} |\n| GET /moments/users/{userId} | 同上，且目标用户存在 | 200 {posts,nextBefore}，按作者及可见性过滤 |\n| GET /moments/{postId} | 当前用户可见 | 200 {post} |\n| DELETE /moments/{postId} | 仅帖子作者 | 200 {status:\"deleted\"} |\n| PUT /moments/{postId}/like | 当前用户可见 | 200 {post}；重复点赞幂等 |\n| DELETE /moments/{postId}/like | 当前用户可见 | 200 {post} |\n| GET /moments/{postId}/comments | before默认0；后续用nextBefore | 200 {comments,nextBefore}；每页30条，按ID倒序 |\n| POST /moments/{postId}/comments | JSON text为1–500字；replyToId可选，须本帖评论正整数ID | 201 {comment} |\n| DELETE /moments/{postId}/comments/{commentId} | 评论作者或帖子作者 | 200 {status:\"deleted\"} |\n| POST /moments/{postId}/hide | 对本人隐藏 | 200 {status:\"hidden\"} |\n| POST /moments/{postId}/report | JSON reason为1–200字 | 200 {status:\"reported\"} |\n| GET /moments/notifications | before默认最大安全整数 | 200 {notifications,nextBefore,unreadCount}；每页30条可见通知 |\n| POST /moments/notifications/read | 标记当前用户全部未读通知 | 200 {status:\"read\"} |\n\n帖子输入viewerIds最多100、不重复、UUID，非selected时必须为空；selected至少一位有效未屏蔽好友。mediaIds最多9、不重复、UUID；必须为自己尚未绑定其他帖子的媒体。图片最多9张，或仅1段视频，不得混发。未绑定媒体超过24小时清理。PNG仍执行宽高≤2048校验，视频检查ftyp标识。\n\npost含id、authorId、text、visibility、createdAt、author、media、likeCount、commentCount、liked、recentComments；作者读取selected帖子可得到viewerIds。comment含id、postId、authorId、replyToId、text、createdAt、authorName。通知含actorId、actorName、postId、commentId、kind、createdAt、readAt，kind为like/comment/reply。\n\n错误包括INVALID_POST、INVALID_COMMENT、INVALID_REPORT、INVALID_MEDIA、INVALID_RANGE、INVALID_LIMIT、NOT_FOUND、FORBIDDEN。某些输入被权限过滤后得到404；不能仅因知道帖子或媒体ID就访问内容。\n\n## 实时位置\n\n以下路径模板中的 {resource} 必须替换为 conversations 或 groups，两类范围各实现相同四个Method。\n\n| Method 与路径 | 请求与权限 | 成功响应 |\n| --- | --- | --- |\n| GET /live-locations/mine | 当前用户 | 200 {sessions:[{scopeKind,scopeId}]} |\n| GET /{resource}/{scopeId}/live-locations | 当前范围成员 | 200 {locations:[...]} |\n| POST /{resource}/{scopeId}/live-locations | JSON latitude、longitude、accuracy；单聊需好友且未屏蔽 | 201 {location}，显式开始或更新已有共享 |\n| PUT /{resource}/{scopeId}/live-locations | 同上，且已存在自己的共享 | 200 {status:\"updated\"}；未开始为409 NOT_SHARING |\n| DELETE /{resource}/{scopeId}/live-locations | 当前范围成员，停止本人 | 200 {status:\"stopped\"} |\n\n经纬度与精度需有限数字，latitude为-90–90，longitude为-180–180，accuracy为0–100000。位置对象含userId、displayName、latitude、longitude、accuracy、startedAt、updatedAt。当前没有服务端自动到期字段，也没有位置写入普通events的契约。此数据不是MLS密文。\n\n## 普通变更长轮询\n\n| Method 与路径 | 查询参数 | 成功响应 |\n| --- | --- | --- |\n| GET /events | after默认0；wait默认20、0–25秒；Bearer | 200 {events,cursor}，每批最多100条 |\n\nGET /events 的query为after（默认0，非负安全整数）、wait（默认20，0–25秒）。响应200 {events,cursor}，每批最多100条、按ID升序；空批次保持原cursor。event为{id,conversationId,groupId,kind,messageId,createdAt}，可选引用可能null。客户端收到事件后重新获取对应业务资源，不应把它当完整消息内容。\n\n`sh\ncurl 'https://chat.thestarline.cn/events?after=0&wait=20' \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\"\n`\n\n客户端应连续维护轮询和断线重连。更多游标和客户端恢复行为见实时同步与通话。\n\n## MLS 设备与加密事件\n\n这些接口要求Bearer且服务器已加载可用MLS校验器，否则503 E2EE_UNAVAILABLE。deviceId格式为[A-Za-z0-9_-]{1,80}；keyPackage与payload用规范标准base64，不是base64url。服务端验证MLS封装，终端负责完整消息认证与解密。\n\n| Method 与路径 | 参数与限制 | 成功响应 |\n| --- | --- | --- |\n| GET /e2ee/devices/{deviceId}/key-packages | 当前用户的设备 | 200 {registered,available} |\n| POST /e2ee/devices/{deviceId}/key-packages | JSON keyPackage；解码1–65536字节，JSON≤100000字节 | 201或已领取重复包200，{keyPackageId}；每设备最多25个未用包 |\n\n下表 {resource} 为conversations或groups，每种各有8条Method+path。范围必须属于当前用户；除state只查范围外，其余需相应已注册设备。单聊prepare、events POST、activate还需当前好友且未屏蔽。\n\n| Method 与路径 | JSON或query | 成功响应与状态限制 |\n| --- | --- | --- |\n| GET /e2ee/{resource}/{scopeId}/state | 无额外字段 | 200 {mode,creatorUserId,creatorDeviceId}；无范围记录时mode=legacy |\n| GET /e2ee/{resource}/{scopeId}/devices | query deviceId必填 | 200 {devices:[{userId,deviceId,signatureKey,keyPackageId,keyPackage,invited,joined}]}；公钥与包base64，包可null |\n| POST /e2ee/{resource}/{scopeId}/prepare | JSON deviceId | 201 {mode:\"preparing\"}；仅legacy，每参与者至少一个注册设备 |\n| GET /e2ee/{resource}/{scopeId}/events | query deviceId、after默认0 | 200 {events,cursor}；立即查询，最多100条按sequence升序，仅当前设备收件 |\n| POST /e2ee/{resource}/{scopeId}/events | JSON deviceId、clientId UUID、kind、payload；Welcome另需targetUserId、targetDeviceId、keyPackageId | 首次201、相同重试200，{sequence}；kind为welcome/commit/application；payload≤1048576字节，JSON≤1500000字节 |\n| POST /e2ee/{resource}/{scopeId}/welcome-acks | JSON deviceId、sequence安全整数 | 200 {status:\"acknowledged\"}；必须是发给本设备的Welcome |\n| POST /e2ee/{resource}/{scopeId}/commit-acks | JSON deviceId、sequence安全整数 | 200 {status:\"acknowledged\"}；必须是本设备收到的Commit |\n| POST /e2ee/{resource}/{scopeId}/activate | JSON deviceId | 200 {mode:\"active\"}；仅创建者、所有当前设备完成必要Welcome/Commit确认 |\n\n加密event含sequence、kind、clientId、senderUserId、senderDeviceId、payload、createdAt、targetUserId、targetDeviceId。服务端按发送者、设备、clientId去重，检查范围、类型、payload摘要、收件人和包ID完全相同。Welcome定向单设备并消费相应密钥包；application仅active可发；preparing阶段仅创建者建立群；后续Commit协调及activate受创建者限制。\n\n关键错误包括INVALID_KEY_PACKAGE、DEVICE_KEY_CHANGED、TOO_MANY_PACKAGES、NOT_MEMBER、UNKNOWN_DEVICE、MISSING_DEVICE、ALREADY_PREPARED、INVALID_EVENT、NOT_PREPARED、CLIENT_ID_REUSED、NOT_ACTIVE、PREPARING、NOT_CREATOR、INVALID_RECIPIENT、PACKAGE_USED、INVALID_WELCOME、INVALID_COMMIT、AWAITING_WELCOME、AWAITING_COMMIT、AWAITING_REKEY、INVALID_SCOPE。HTTP映射为NOT_MEMBER→404，UNKNOWN_DEVICE→403，INVALID_KEY_PACKAGE/INVALID_EVENT/INVALID_RECIPIENT/INVALID_WELCOME→400，其余具名MLS错误通常409。\n\n当前iOS独立MLS传输器实现文字payload，Android与主聊天尚未完成同等接入；本节不提供虚构密文可直接发送的curl。联调需调用真实加密核心生成有效TLS字节，随意base64字符串会被拒绝。完整顺序见消息与媒体。\n\n## 通话与 VoIP 设备\n\n| Method 与路径 | 参数与权限 | 成功响应 |\n| --- | --- | --- |\n| POST /calls | JSON scopeKind=conversation/group、scopeId UUID、mode=audio/video、clientId UUID；范围成员，单聊另需好友且未屏蔽 | 201 {call}；媒体未配置503、冲突409 CALL_BUSY |\n| GET /calls/active | 当前用户 | 200 {call}，无则null |\n| GET /calls/current | query scopeKind、scopeId；当前范围成员 | 200 {call}，无则null |\n| GET /calls/history | 同上 | 200 {calls}，最近100条终态记录 |\n| POST /calls/{callId}/accept | JSON {}；当前受邀者，单聊需仍可联系 | 200 {call}；状态冲突409 CALL_STATE |\n| POST /calls/{callId}/decline | JSON {}；当前仍在响铃的受邀者 | 200 {call} |\n| POST /calls/{callId}/end | JSON {}；响铃时仅发起者取消；active需实际参与者 | 200 {call}；群离开不必结束整场 |\n| POST /calls/{callId}/join | JSON {}；仅active群通话的当前群成员 | 200 {call} |\n| POST /calls/{callId}/media-token | JSON {}；active且尚未离开的参与者 | 200 {url,token}；TTL30秒，当前房间发布订阅，禁止data发布 |\n| POST /voip/devices | JSON deviceId为UUID，pushToken为64–512个十六进制字符；绑定当前session | 200 {status:\"registered\"} |\n| DELETE /voip/devices | JSON deviceId为UUID | 200 {status:\"unregistered\"} |\n\nCall对象含id、scopeKind、scopeId、callerId、mode、state、createdAt、startedAt、endedAt、endedReason，不含内部roomName及请求clientId。state为ringing/active/ended/missed/cancelled；startedAt和endedAt可null。所有其他成员忙时，POST也可返回201且state=ended、endedReason=busy；必须检查对象状态。\n\n媒体token不是聊天会话，不能公开缓存、写入静态站或长期复用。错误包括INVALID_CALL、INVALID_CALL_SCOPE、INVALID_VOIP_DEVICE、CALL_BUSY、CALL_STATE、CALL_MEDIA_UNAVAILABLE。状态机、空房回收与真实跨网络验收边界见实时同步与通话。\n\n## 记忆识别与转出桥接\n\n| Method 与路径 | 参数与限制 | 成功响应 |\n| --- | --- | --- |\n| POST /memory/recognize | JSON text，trim后2–2000字；每用户每10分钟最多10次；需模型配置 | 200 {schema}；失败503 MEMORY_MODEL_UNAVAILABLE，限流429 TOO_MANY_RECOGNITIONS |\n| GET /memory-export/start | 当前登录用户；需Account配置 | 200 {url,state}；5分钟转出flow，打开Account额外验证 |\n| POST /memory-export/exchange | JSON state、ticket，均32–128个字母/数字/下划线/短横线 | 200 {authorized:true}；一次性flow，校验当前Account subject一致 |\n\nschema含category、icon、fields；字段含id、title、type、options，type为服务端归一化的表单字段类型，签证和宠物疫苗主题还会补充专用字段。请求的text会发送至聊天服务及配置模型，用于生成结构；这不形成持久记忆库或云同步，不能把“未持久保存”理解为“没有上传”。当前权威Account主源码没有/api/memory-export/redeem路由；App工作树的历史Account副本虽有实现，也不能证明当前服务支持。因此聊天侧转出桥接不能当成已验收能力。错误包括EXPORT_UNAVAILABLE、INVALID_EXPORT_TICKET、ACCOUNT_MISMATCH、INVALID_MEMORY_TEXT。\n\n## 共享后台：独立星企企业接口\n\n本节用于解释共享服务的完整路由范围。这些是星企接口，不是星线 App 个人前台功能。全部要求聊天Bearer；进入指定企业路径先验证企业成员，随后按操作检查manager（admin/owner）或owner。企业ID与个人群ID不能互换。\n\n令以下表格的{org}表示/enterprise/organizations/{organizationId}，仅为排版缩写，实际请求必须展开完整路径。\n\n| Method 与完整路径或展开规则 | 参数与权限 | 成功响应 |\n| --- | --- | --- |\n| GET /enterprise/organizations | 当前用户 | 200 {organizations} |\n| POST /enterprise/organizations | JSON name为1–80字 | 201 {organization}，创建者为owner并建立全员群 |\n| POST /enterprise/invitations/preview | JSON code为43字符base64url，有效未撤销邀请码 | 200 {invitation}，含organizationId、organizationName、expiresAt、joinMode、membership |\n| GET /enterprise/join-requests | 当前用户 | 200 {requests}，最多100条本人申请 |\n| POST /enterprise/join-requests | JSON code，有效邀请 | 200 {status,organizationId,organizationName}，status为pending或joined |\n| GET {org} | 当前企业成员 | 200企业快照：organization、members、departments、channels、tasks、announcements、services、joinRequests、invitations；最后两项对普通member为空 |\n| PATCH {org}/members/me/card | JSON恰含name，单行1–80字或null；本人 | 200 {member} |\n| POST {org}/invitations | JSON {}；manager | 201 {invitation:{id,code,expiresAt}}，3日有效；原始code仅创建响应给出 |\n| DELETE {org}/invitations/{invitationId} | manager | 200 {ok:true} |\n| POST {org}/join-requests/{requestId} | JSON approve为boolean；manager且不能审批自己 | 200 {ok:true}；重复相同决定幂等，冲突409 DECISION_CONFLICT |\n| POST {org}/departments | JSON name为1–80字；manager | 201 {department} |\n| PATCH {org}/departments/{departmentId} | JSON name；manager | 200 {department}；同企业重名409 DUPLICATE_DEPARTMENT |\n| DELETE {org}/departments/{departmentId} | manager | 200 {ok:true}，关联成员部门置null |\n| PATCH {org}/members/{userId} | 可传role=admin/member、departmentId、jobTitle≤80字、cardName单行1–80字或null | 200 {ok:true}；manager，角色调整仅owner且不能修改owner角色 |\n| DELETE {org}/members/{userId} | manager；不能移除owner，admin不能移除admin | 200 {ok:true} |\n| POST {org}/channels | JSON kind=direct/group、memberIds非空≤200个不重复同企业ID；group需name≤80字 | 201 {channel}；direct最终双方必须恰为2人，重复同事单聊复用频道 |\n| GET {org}/channels/{channelId}/messages | query before可选正安全整数；企业和频道成员 | 200 {messages}，最多100条，返回升序；受入群历史边界限制 |\n| POST {org}/channels/{channelId}/messages | JSON body为1–2000字、clientId为1–128字母/数字/下划线/短横线；JSON≤32768字节 | 201/200 {message}；同频道同发送者重试去重 |\n| POST {org}/channels/{channelId}/read | JSON {}；企业和频道成员 | 200 {ok:true}，读到当前最新消息 |\n| POST {org}/tasks | JSON title≤120、notes≤4000（可空）、dueAt为null或非负安全整数、assigneeId为当前同企业成员；JSON≤65536字节 | 201 {task}，初始未完成 |\n| PATCH {org}/tasks/{taskId} | 更新上述字段及isCompleted；JSON≤65536字节 | 200 {task}；manager或创建者可管理，其他受指派者只能改isCompleted |\n| DELETE {org}/tasks/{taskId} | manager或创建者 | 200 {ok:true} |\n| POST {org}/announcements | JSON title≤120字、body≤10000字；JSON≤131072字节；manager | 201 {announcement} |\n| DELETE {org}/announcements/{announcementId} | manager | 200 {ok:true} |\n| PUT {org}/announcements/{announcementId}/read | JSON {}；当前企业成员 | 200 {ok:true} |\n| POST {org}/services | JSON name≤80字、url≤2000；manager；只允许无用户名密码的HTTPS URL | 201 {service:{id,name,url}} |\n| DELETE {org}/services/{serviceId} | manager | 200 {ok:true}` |\n\n企业常见错误为FORBIDDEN、INVALID_INPUT、NOT_FOUND、INVALID_INVITATION、INVITATION_ALREADY_USED、DECISION_CONFLICT、INVALID_DEPARTMENT、INVALID_MEMBER、INVALID_ASSIGNEE、INVALID_URL与CLIENT_ID_REUSED。邀请被撤销或过期不可批准加入；已被决定的同一邀请不能复活旧申请。企业频道保存服务端可读文字，不属于个人聊天MLS范围，也没有据此推导的企业附件路由。\n\n## 调用与联调顺序\n\n建议先在受控测试账号完成native/start→Account→callback→exchange，再调用/me确认身份与事件游标；之后读取列表，使用合成文字及小文件验证发送、重试、下载与已读。MLS联调必须使用真实生成的测试密钥包和密文；通话联调要在API状态之外验证双设备媒体与后台来电。\n\n本页所有示例为合成或占位内容，没有生产凭据。接口细节与文档不一致时，以当前源码和可复核测试结果更新契约；不要通过去掉proof、改用旧接口或绕过成员检查“修复”失败。继续阅读数据模型、安全和隐私、测试及运维。\n\n按业务流程联调时可结合联系人与个人群、新事、位置共享、MLS协议和限制与错误读取状态与失败分支。"},{"product":"星线 App","title":"协议示例与集成步骤","url":"/app/protocol-recipes/","text":"# 协议示例与集成步骤\n\n本页把分散的接口串成可检查的流程，说明调用顺序、需要保留的状态、重复请求及失败处理。请求契约来自聊天服务当前源码；这些示例不代表所有客户端已经采用相同实现。路由逐项定义见 API 参考，状态和限制见限制与错误。\n\n## 示例环境与约定\n\n命令使用隔离开发服务，测试账号、会话、消息和附件均应为合成数据。CHAT_TOKEN 必须由该环境内已经完成的测试登录获得。生产地址与本地地址的票据不能互换；原生授权还受系统回调与平台信任规则约束，尤其 Android 当前只允许正式 Account HTTPS 来源。\n\n``sh\nCHAT_BASE_URL='http://127.0.0.1:4210'\n: \"${CHAT_TOKEN:?请先在同一测试环境完成授权并设置聊天会话}\"\nCONVERSATION_ID='11111111-1111-4111-8111-111111111111'\nCLIENT_MESSAGE_ID='22222222-2222-4222-8222-222222222222'\n`\n\n上面的两个 UUID 只表示字段格式。会话必须换成当前测试账号有权访问的真实测试会话；每条新消息需要新的 clientId。同一条消息的网络重试则继续使用原 clientId。不要把 Account access token、ID token、LiveKit token 或文档访问地址当作 CHAT_TOKEN。\n\ncurl --silent --show-error 保留失败信息且不输出传输进度。不要为排错启用会回显 Authorization 的详细网络日志；只需保存 HTTP 状态、错误码和请求阶段。示例 JSON 中的用户、编号和时间都是合成值。\n\n## 一、恢复会话并取得同步起点\n\n`sh\ncurl --silent --show-error --fail-with-body \\\n  \"${CHAT_BASE_URL}/me\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\"\n`\n\n正常响应形状如下：\n\n`json\n{\n  \"user\": {\n    \"id\": \"33333333-3333-4333-8333-333333333333\",\n    \"displayName\": \"合成测试用户\",\n    \"friendId\": \"A1B2C3D4E5F\"\n  },\n  \"eventCursor\": 42\n}\n`\n\n| 结果 | 集成端动作 |\n| --- | --- |\n| 200 | 记录当前用户及 eventCursor，继续加载会话、群和其他有权资源 |\n| 401 UNAUTHORIZED | 本聊天会话不可用，进入重新授权；没有公开 refresh-token 接口可供直接续期 |\n| 网络超时 | 区分网络状态与登录失效；未收到 HTTP 响应不能直接解释为密码错误 |\n| 500 或 503 | 保留错误码并检查相关服务，不循环创建授权 flow 代替定位依赖故障 |\n\n一种可采用的集成顺序是先取得 eventCursor，再读取业务列表，随后从该 cursor 拉事件。列表加载期间产生的变化可能再次触发读取；通过对象 ID 合并即可避免重复。该顺序是集成建议，并非两端全部状态管理的逐行描述，实际实现见客户端生命周期。\n\n## 二、构造原生授权证明\n\nApp→聊天服务的 verifier 由 32 字节安全随机值编码为无填充 base64url，challenge 为 verifier 的 SHA-256 摘要再做同样编码。下面只在本机生成挑战，不执行授权：\n\n`sh\nCODE_VERIFIER=\"$(python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip(\"=\"))')\"\nCODE_CHALLENGE=\"$(printf '%s' \"$CODE_VERIFIER\" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')\"\n`\n\n这两个值在该生成方式下都是 43 字符。只把 challenge 放进开始请求，verifier 保存在当前设备的本次授权状态中。开始请求为：\n\n`sh\ncurl --silent --show-error --fail-with-body \\\n  -X POST \"${CHAT_BASE_URL}/auth/native/start\" \\\n  -H 'Content-Type: application/json' \\\n  --data \"{\\\"app\\\":\\\"starline\\\",\\\"screen\\\":\\\"login\\\",\\\"codeChallenge\\\":\\\"${CODE_CHALLENGE}\\\"}\"\n`\n\n服务端返回 authorizationURL、state、expiresAt。App 先保存 pending，再交系统浏览器打开授权地址；用户完成 Account 流程后，聊天服务通过 HTTPS 回调换码，再把一次性 ticket 和原 state 交回 App。最终换票 JSON 为：\n\n`json\n{\n  \"ticket\": \"ONE_USE_TICKET_FROM_CALLBACK\",\n  \"state\": \"EXACT_PENDING_STATE\",\n  \"codeVerifier\": \"VERIFIER_FROM_THE_SAME_FLOW\"\n}\n`\n\n该对象发送到 POST /auth/exchange。占位文本不能直接用于测试；ticket、state、verifier 必须来自同一未到期流程。回调解析失败、state 不同、重复参数或 ticket 过期时重新开始授权，不删除 proof 字段尝试兼容模式。iOS 与 Android 的 pending 持久化和换票中断恢复并不相同，见登录与会话。\n\n## 三、发送文字并安全重试\n\n`sh\ncurl --silent --show-error --fail-with-body \\\n  -X POST \"${CHAT_BASE_URL}/conversations/${CONVERSATION_ID}/messages\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\" \\\n  -H 'Content-Type: application/json' \\\n  --data \"{\\\"clientId\\\":\\\"${CLIENT_MESSAGE_ID}\\\",\\\"text\\\":\\\"合成测试消息\\\"}\"\n`\n\n发送前需确认当前用户仍是好友、双方未屏蔽，且该会话的加密模式为 legacy。text 与 mediaId 恰选一种，文字 trim 后非空，最多 2,000 Unicode 码点。普通接口不能把文字与附件合并成一次发送。\n\n| 请求情况 | 预期结果 | 需要保留的状态 |\n| --- | --- | --- |\n| 首次有效发送 | 201，message.duplicate 为 false | 服务端 message.id 与本地 clientId 对应关系 |\n| 同发送者、同会话、同 clientId、同正文与媒体重复发送 | 200，duplicate 为 true | 合并已有消息，不再新增一条气泡 |\n| 已使用的 clientId 换正文、附件或会话 | 409 CLIENT_ID_REUSED | 不自动换 ID 继续盲发，先核对原发送意图 |\n| 服务端已准备/启用/阻塞 MLS | 409 E2EE_REQUIRED | 不能降级为另一普通接口绕过模式 |\n| 服务端可能已经写入，但客户端没收到响应 | 网络失败，结果未知 | 保留原 clientId 与相同内容，允许后续重试核对 |\n\n唯一性在普通单聊消息表中按 senderId、clientId 检查。群消息使用独立消息表，集成端仍应为每个新发送意图生成独立 ID，不依赖不同表碰巧可以重复使用。权限变化后重试可能得到 403/404，并不意味着原消息从未写入。\n\n## 四、上传附件再发送引用\n\n先准备一份没有个人信息的 15 字节测试文件：\n\n`sh\nprintf 'synthetic-data\\n' > synthetic-note.txt\ncurl --silent --show-error --fail-with-body \\\n  -X POST \"${CHAT_BASE_URL}/media?kind=file&name=synthetic-note.txt\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\" \\\n  -H 'Content-Type: text/plain' \\\n  --data-binary '@synthetic-note.txt'\n`\n\n上传使用原始字节，不能用 multipart 表单替代。成功 201 的 media 包括 id、kind、name、mimeType、size、createdAt；将返回 ID 放入下一条消息：\n\n`json\n{\n  \"clientId\": \"44444444-4444-4444-8444-444444444444\",\n  \"mediaId\": \"55555555-5555-4555-8555-555555555555\"\n}\n`\n\n此时不带 text。mediaId 必须属于发送者；上传成功并不代表已经向接收者发出消息。第二步失败时应明确呈现“文件已上传、消息未确认”的阶段，不能显示为双方都已收到。\n\n文件下载路径是 GET /media/{id}。上传者可以读取自己的媒体；非所有者需要符合个人历史边界且未被本人隐藏的消息引用，群消息还要求当前成员资格。普通单聊媒体的读取分支不重新检查好友或屏蔽，因此删好友、屏蔽不等于过去仍可见的媒体立即不可读。本人隐藏、历史边界或退群可能改变引用授权；存在其他可见引用时也不能笼统认定该文件一定不可读。普通聊天媒体没有 Range 契约，不要把新事视频接口的分段能力套用到这里。\n\n上传原始数据不等于客户端附件加密。服务器保存的字节与上传字节一致，附件 E2EE 边界见消息与媒体。聊天文件、贴纸、新事媒体与本机记忆附件的上限分别不同，见限制与错误。\n\n## 五、翻页、增量与消息合并\n\n`sh\ncurl --silent --show-error --fail-with-body \\\n  \"${CHAT_BASE_URL}/conversations/${CONVERSATION_ID}/messages?limit=50\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\"\n`\n\n没有 after/before 时，服务端取最新一页，再以 ID 升序返回。例如可见消息为 11、18、25；显示顺序就是 11→18→25，ID 不要求连续。\n\n| 需求 | 下一请求 | 合并规则 |\n| --- | --- | --- |\n| 继续向前加载历史 | before=11 | 只取 ID 小于 11 的消息，按 ID 合并到前方 |\n| 拉取 25 之后的新单聊消息 | after=25 | 只取 ID 大于 25 的消息，按 ID 去重追加 |\n| 初始加载最新页 | 不带 after/before，或 after=0 | 不是从最早历史开始遍历 |\n| 群历史翻页 | /groups/{id}/messages?before=... | 使用群 before 规则，不假定支持单聊 after 增量契约 |\n\n非零 after 与 before 不可同时传。before 是严格排除边界，传 0 不表示“从最新开始”，通常得到空结果。空页只能说明当前权限和边界下没有该方向结果，不证明数据库从未有过消息。个人隐藏和历史清理会过滤记录。\n\n服务端分页 ID、客户端 clientId 和普通事件 ID 用途不同。不要以时间戳作为这些游标，也不要把旧页里最后一条消息 ID 当作加载更早历史的 before。\n\n## 六、长轮询与业务刷新\n\n`sh\nEVENT_CURSOR=42\ncurl --silent --show-error --fail-with-body \\\n  \"${CHAT_BASE_URL}/events?after=${EVENT_CURSOR}&wait=20\" \\\n  -H \"Authorization: Bearer ${CHAT_TOKEN}\"\n`\n\n服务端每批最多返回 100 个当前用户可见事件，按 ID 升序，并返回 cursor。空批次保持原 cursor。事件携带 kind、conversationId/groupId、可选 messageId 和 createdAt，不是消息正文的完整替代物。\n\n集成端依次处理该批事件、刷新对应资源并保存 cursor；消费失败时不要只推进游标而丢弃需要恢复的业务状态。一次返回满批可继续用 wait=0 消费积压，再恢复正常等待。重连时复用已确认游标，允许对象 ID 去重，不把网络重连等同于清空历史。\n\n普通事件游标与 MLS sequence 属于不同协议。实时位置也没有自动写入普通 events 的契约；不能仅通过等待普通消息事件完成位置更新。消息轮询、MLS 同步、位置读取和通话房间连接应分别处理，见实时同步与位置共享。\n\n## 七、已读与删除的语义\n\nPOST /conversations/{id}/read 使用 {lastMessageId:25}。消息必须属于该会话；已读位置只前进，不因客户端提交更小 ID 回退。UI 应在真实展示/阅读策略满足后更新，不在仅收到通知时直接把对方消息全部标为已读。\n\nDELETE /conversations/{id}/messages/{messageId} 是本人隐藏，不能当双方撤回。DELETE /conversations/{id} 会隐藏本人会话并推进历史边界，restore 只取消隐藏，不恢复该边界之前的内容。删除好友、屏蔽、退出群和解散群又是其他操作。有关成员转移和数据清理顺序，见好友与群。\n\n## 八、检查失败而不改变业务数据\n\n可在隔离环境检验以下只读或不成立请求，不需要创建真实内容：\n\n| 请求 | 应观察的结果 |\n| --- | --- |\n| 无 Bearer 请求 /me | 401，而不是匿名返回用户 |\n| GET /events?after=-1&wait=0 | 400 INVALID_CURSOR |\n| 单聊 ?after=25&before=11 | 400 INVALID_CURSOR |\n| 单聊 ?limit=0 | 400 INVALID_LIMIT |\n| 对未授权媒体 ID 发 GET | 404，不返回媒体正文 |\n\n这些是待执行的契约检查清单，不是本文已经向真实服务发出请求的验收回执。多账号权限、并发发送、掉线恢复与资源回收需结合测试矩阵检查；发生实际故障时按故障处置先定位阶段，再处理状态。\n\n## 源码入口\n\n请求解析、状态码和权限入口位于 chat-service/src/server.mjs；普通消息事务、去重、可见历史与事件位于 chat-service/src/database.mjs。原生封装分别在 ios-app/Sources/Features/Chat/ChatAPI.swift 与 android-app/app/src/main/java/cn/thestarline/workbench/AppData.kt`。修改流程时同时检查请求体、事务语义、客户端重试和API 参考，不能只修改示例。"},{"product":"星线 App","title":"数据模型与一致性","url":"/app/data-model/","text":"# 数据模型与一致性\n\n聊天服务以 SQLite 保存身份映射、联系人关系、聊天内容、媒体、新事、事件和通话状态。原生记忆数据另有客户端本地模型。本页描述源码中的逻辑实体与约束，帮助调用方理解 ID、可见性、重试和删除语义；不提供任何用户数据库或生产记录。\n\n## 标识符、时间和对外对象\n\n聊天用户、会话、群、媒体、好友申请和通话主要使用 UUID 字符串。普通消息、新事、评论、通知和事件使用各自的自增整数；企业消息的对外 id 被转换成字符串。MLS 使用自增 sequence，设备 ID 允许 1 至 80 个字母、数字、下划线或短横线。多数接口路径的 UUID 正则只校验字符集合与长度，客户端仍应产生合法 UUID。\n\n时间字段如 createdAt、updatedAt、expiresAt 是 Unix 毫秒值。API 通常使用 camelCase，数据库使用 snake_case。null 表示暂无值，例如尚未接通的 startedAt、没有最新消息的 lastMessage、无权查看的手机号。调用方不得把 null 当空字符串，也不能跨不同实体比较 ID 大小判断全局时间顺序。\n\n## 身份与资料\n\n| 实体 | 核心关系与约束 |\n| --- | --- |\n| users | 本地 id；唯一 Account issuer_sub；显示名；唯一好友 ID chat_code；手机号查找摘要及有效期 |\n| user_profiles | 每用户一条；简介、加密联系方式、手机号可见范围、更新时间 |\n| user_profile_phone_viewers | 指定可见者的 owner/viewer 复合键；不能指定自己 |\n| oauth_flows | state 摘要、服务端 verifier、到期时间、产品标识、原生挑战 |\n| login_tickets | ticket 摘要、用户、到期时间、原生挑战及 state 摘要 |\n| device_sessions | token 摘要、用户、到期时间 |\n\n公开 User 只含 {id,displayName,friendId}，不会返回 Account subject、token 摘要或查找摘要。手机号查找使用带服务端密钥的 HMAC-SHA256，并在登录时刷新为 24 小时有效；个人资料中的可展示联系电话是另一字段，使用 AES-256-GCM 保存，由资料可见性控制。两者用途和存储形式不同。\n\n## 好友、单聊与个人视图\n\nfriend_requests 保存申请双方及时间，禁止自己申请自己，同方向双方组合唯一。friend_codes 每用户只保留一个当前临时码摘要；friend_lookup_attempts 按用户与查找方法记录 10 分钟窗口内次数。\n\nconversations 保存排序后的 user_a 与 user_b，要求前者小于后者并且双方组合唯一；is_friend 表示好友关系是否仍有效。删除好友不会仅靠删一条消息实现。user_blocks 是有方向的屏蔽关系，但发送、通话和部分资料检查使用任一方向屏蔽的联合条件。\n\nconversation_preferences 为每个用户保存 is_hidden 和 visible_after_message_id。删除聊天会把个人可见边界推进到当时最后一条消息并隐藏列表项；恢复只取消隐藏，不回退边界。hidden_messages 为用户、范围与消息建立单条隐藏记录。因此“删除消息”是当前用户视图删除，不能解释为对方撤回、数据库物理擦除或备份销毁。\n\n## 消息、群与已读\n\nmessages 与 group_messages 分别保存所属范围、发送者、client_id、正文、可选媒体引用和时间。二者各自要求 (sender_id,client_id) 唯一；同一发送者不能在该表内把同一 clientId 改用于其他范围或其他内容。服务端发现完全相同重试返回既有消息，内容变化返回 CLIENT_ID_REUSED。\n\nread_positions 保存单聊用户最后已读消息，群的已读位置位于 group_members。更新只前进，且目标消息必须属于当前范围。未读数还受个人可见边界、单条隐藏和发送者过滤影响，不能只用最新 ID 减已读 ID 计算。\n\ngroups 保存名称、群主与创建时间；group_members 复合键保存加入时间、最后已读、历史可见边界、隐藏和静音设置。个人群最多 50 人。新成员的历史边界、退出或移除成员后的范围权限，均由服务端当前成员记录决定。群主可邀请、改名、移除成员或解散；群主退出时的归属处理由服务端事务完成，不能由客户端只删本地列表代替。\n\n## 媒体、新事与位置\n\n聊天 media 保存所有者、sticker/file、文件名、MIME、字节数、BLOB 和创建时间；城市内置贴纸还有用于按账号去重的 builtin key。消息通过 media_id 引用。下载权限来自所有者或当前用户可见消息，不能只凭 UUID 下载。\n\n新事数据单独保存于 moments_posts、moments_audience、moments_media、moments_likes、moments_comments、moments_notifications、moments_hidden 和 moments_reports。可见性为 friends、selected、public、private；selected 另存指定好友列表，public 仍需登录访问 API。点赞是用户与帖子的唯一组合；评论可引用同帖评论。未绑定帖子的媒体超过 24 小时会被清理。新事不是 MLS 加密聊天数据。\n\nlive_locations 以范围类型、范围 ID、用户为复合键，保存经纬度、精度、开始和更新时间。POST 显式开启共享，PUT 只更新既有共享，DELETE 停止。服务端没有在本表中设置自动过期字段，调用方应展示更新时间并明确停止共享。\n\n## MLS、通话与变更通知\n\nMLS 表包括设备签名公钥、一次性密钥包、范围模式、加密事件和按设备收件确认。e2ee_scopes 的 preparing/active/blocked 与没有记录时的 legacy 共同构成状态机。密钥包以内容 SHA-256 作为 ID；事件唯一键为发送用户、设备和 clientId，payload 摘要用于检查重试一致性。\n\ncalls 保存通话范围、发起者、audio/video、状态、时间和结束原因；内部房间名不会出现在普通公开 call 对象中。call_invites 描述每位邀请者的响应，call_participants 描述实际加入和离开。voip_devices 将设备、推送 token 与登录 session 摘要关联。\n\nevents 是用户收件范围内的变更索引，含可空的 conversationId/groupId/messageId。MLS sequence 可作为 e2ee 通知的 messageId，但不因此变成普通消息 ID。长轮询默认每批最多 100 条。详见实时同步。\n\n## 存储与产品边界\n\n数据库启用外键及 WAL，写操作通过事务维护去重、消息和事件一致性。备份与恢复必须同时考虑数据库、媒体 BLOB、加密配置、MLS 客户端状态和 Account 身份映射，不能只复制某一个消息表。\n\nenterprise_* 表属于共享后台的独立星企领域，组织、部门、成员角色、企业频道、企业待办、公告与服务均以企业 ID 隔离，不属于星线 App 个人聊天数据模型的前台功能承诺。更多接口字段见API 参考，数据保护边界见安全和隐私。\n\n状态操作的具体影响见联系人与个人群、新事及位置共享，尤其注意个人视图删除、关系移除和完整解散使用不同清理分支。"},{"product":"星线 App","title":"MLS 设备与加密事件协议","url":"/app/mls-protocol/","text":"# MLS 设备与加密事件协议\n\n仓库已实现Rust MLS核心、服务端交付接口和iOS独立传输器。当前iOS主ChatStore与Android主聊天仍发送普通text/media，附件也未接入客户端密文封装。本页解释协议状态与恢复要求，不把模块存在等同于全量端到端加密或生产安全审计通过。\n\n## 身份、编码与职责\n\nRust固定使用MLS 1.0与MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519。设备credential串联聊天用户ID与设备ID，签名公钥32字节；会话scopeId的UTF-8字节用作MLS GroupId。\n\n| 对象 | 关键字段与约束 |\n| --- | --- |\n| Device | userId、deviceId、signatureKey；deviceId为1–80位字母数字/下划线/短横线 |\n| KeyPackage | TLS编码MLS字节；1–65536字节；标准base64放入JSON；id为包内容SHA-256 |\n| Scope | scopeKind、scopeId、mode、creatorUserId、creatorDeviceId；blocked内部另记blocked_after_sequence |\n| Event | sequence、clientId、kind、senderUserId、senderDeviceId、payload、createdAt、可选targetUserId/targetDeviceId |\n| Recipient | sequence、userId、deviceId、acknowledgedAt；服务器按设备决定收件与确认 |\n\n服务端验证密钥包的协议签名、套件和账号/设备匹配，以及非Welcome封装的消息类型和群ID；终端负责真正的MLS认证与解密。Welcome封装服务器检查类型、声明收件设备与包可用性；加入终端还必须验证群ID、邀请者credential和签名公钥，不能仅信任HTTP元数据。依据：chat-crypto/src/lib.rs:15-16,48-98,270-309。\n\nMLS内容密文并不隐藏全部交付元数据。服务端仍保存范围ID、发送账号和设备、收件设备、事件种类、密文字节数、时间与确认状态，并利用当前成员目录决定谁能领取事件。协议中的加密内容保护不能推导出匿名通信、隐藏社交关系或服务器完全不知道消息活动；公开文档也不应将这些可见元数据称为已加密正文的一部分。依据：chat-service/src/e2ee-delivery.mjs:36-52,235-255。\n\n## 注册、目录与claim\n\n设备POST /e2ee/devices/{deviceId}/key-packages注册自己的包。相同用户与deviceId已存在时，签名公钥必须相同，否则DEVICE_KEY_CHANGED；每设备最多25个未用包。GET本设备key-packages返回registered、available。iOS登录后通常补到3个可用包，但注册本身不建立加密聊天，也不允许仅据此显示锁标记。\n\n目录GET /e2ee/{conversations|groups}/{id}/devices?deviceId=...给出当前参与者的全部已注册设备，并为每设备挑最早可用包。目录读取不预留、不claim包；发起者必须准备应对另一个请求先使用同一包。只有成功写入定向Welcome的事务，才把该包claimed_sequence设为对应事件序号。\n\n同一Welcome的完全相同重试先命中事件去重，仍返回原sequence，不会再次消费包；改新clientId却引用已claim的包得到PACKAGE_USED。不能把公开目录返回的keyPackageId当永久可复用邀请凭证。新设备首次注册会把该用户已有active范围改blocked，记录当时边界；此操作没有另写普通事件提醒，客户端需要重查模式。依据：chat-service/src/e2ee-delivery.mjs:76-145,187-255。\n\n## 初次建立的次序\n\n1. 每位当前参与者至少注册一个设备，发起设备调用prepare。仅legacy允许，成功后进入preparing并固定协议创建者账号和设备。\n2. 创建者在本机建立MLS群；对每个尚未joined且未invited的目标读取包，核对或固定已知签名公钥。\n3. Rust add_member生成Commit与Welcome；先以独立clientId提交Commit并确认本机pending commit，再提交定向Welcome及包ID。\n4. 目标设备按sequence读取自己的Welcome，验证邀请者和群ID，持久化本地状态，然后发welcome-acks。\n5. 已加入设备处理其收到的后续Commit并持久化，发送commit-acks；创建者发送的Commit由本机完成后推进自己的sequence。\n6. 创建者调用activate。服务器确认所有当前注册设备均已加入并处理必要Commit，返回mode=active；此前不能发送application。\n\npreparing只能由创建者建立群。目录中的joined不等于界面在线：它是创建者身份或Welcome已有确认的结果。一个用户有多台已注册设备时，不是只等每用户任意一台确认；离线旧设备也可能阻止激活。依据：ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:76-150、chat-service/src/e2ee-delivery.mjs:159-172,301-354。\n\n## 状态迁移与成员变化\n\n| 当前状态 | 允许推进 | 被禁止或要求等待的动作 |\n| --- | --- | --- |\n| legacy | prepare→preparing | prepare前application为NOT_PREPARED |\n| preparing | 创建者Commit/Welcome；收件设备ack；创建者activate→active | application为NOT_ACTIVE，其他设备不得建立群 |\n| active | application；创建者新Commit可使其blocked | 直接新Welcome会AWAITING_COMMIT |\n| blocked | 创建者新Commit、必要Welcome和ack；完成后activate→active | application首先得到NOT_ACTIVE；不能回退普通消息发送 |\n\n群成员增加、移除或退出、删除好友、屏蔽以及新设备注册会阻塞已有范围。blocked恢复需要边界之后的新Commit；每个剩余设备必须处理该Commit，或通过边界之后的新Welcome加入。activate还检查每设备后续未确认Commit。AWAITING_REKEY、AWAITING_WELCOME和AWAITING_COMMIT是不同等待条件，不该通过循环activate掩盖缺失步骤。\n\n协议创建者设备与个人群owner不同，群主转移不会自动转移创建者权限；当前没有公开创建者迁移或设备注销路由。iOS独立prepare遇blocked直接返回模式，未自动实施完整重键协调，不能把服务端恢复契约描述成已交付的客户端自动恢复。依据：chat-service/src/e2ee-delivery.mjs:201-211,301-363、ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:78-94。\n\n## 应用消息、游标与去重\n\nPOST events的payload最多1048576字节，JSON最多1500000字节，kind只允许welcome/commit/application。事件幂等键为发送用户+设备+clientId，同一次重试还要保持范围、kind、payload摘要、目标与包ID不变，否则CLIENT_ID_REUSED。重新加密同一业务文字会产生不同密文，不能再用原clientId提交。\n\nGET events按当前设备收件、sequence升序、每页最多100。sequence是全表自增值，并非每会话连续无缺口；其他范围和不发给本设备的事件会造成合法跳号。普通/events的id是另一游标，e2ee通知中的messageId可能引用MLS sequence，但仍不能拿它替换普通事件id。\n\niOS独立文字payload含version=1、kind=text、clientID、text，最多65536个UTF-8字节。设备先把待发送原密文保存，再HTTP提交；响应成功后转为本地已发送消息。接收先验证MLS发送者，再与服务端senderUserId/senderDeviceId比对，解析payload并保存消息与sequence。处理或保存失败不得跳过并推进游标。\n\nWelcome/Commit ack先保存在本地，HTTP确认成功才移出pending；确认丢失可重复提交。自身application回显不会再用接收棘轮解密，而是确认排队消息并推进序号；若既无排队项也无已确认本地消息，按状态异常处理。依据：ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:153-263、ios-app/Sources/Features/Chat/ChatCryptoDevice.swift:442-510。\n\n## 信任固定与本地保存失败\n\niOS把远端账号、设备和签名公钥的对应关系固定在本机Keychain；已知设备的公钥变化时拒绝继续，首次见到设备则返回firstSeen标识。仓库有根据双方身份和公钥生成安全码的函数，但不能把这个辅助函数等同于主聊天已提供完整的用户核对流程，更不能等同于服务端目录透明性或独立身份审计。依据：ios-app/Sources/Features/Chat/ChatCryptoTrustStore.swift:8-81。\n\n加密设备操作通过persisted包装：执行后保存checkpoint，若处理或落盘失败则重新读取上次磁盘状态；连恢复也失败，就把设备标为unusable并拒绝继续使用，避免在内存状态与已保存游标不一致时继续发送。checkpoint同时包含原生MLS状态、消息、游标、待发密文、待确认记录和未完成邀请。不能只备份一个sequence数字再丢掉加密群状态；重新生成设备也不能解开旧状态。依据：ios-app/Sources/Features/Chat/ChatCryptoDevice.swift:553-605。\n\n## 正常与失败例\n\n正常：甲设备创建preparing，提交C1与发给乙的W1；乙验证W1、保存并ack；甲activate成功后提交A1，响应丢失则沿用同一clientId与同一密文重试，服务端返回原sequence。序号可能为101、104、109，不要求连续。\n\n失败：乙尚未ack Welcome就activate，得到409 AWAITING_WELCOME；换一个已经注册的deviceId却使用不同签名密钥，得到409 DEVICE_KEY_CHANGED；成员变化后直接继续application，得到409 NOT_ACTIVE；把base64编码的普通JSON冒充MLS payload会INVALID_EVENT。错误并不授权回退text/media明文路径。\n\n本页仅说明源码协议。iOS主ChatStore目前只准备包、实际send仍普通messages；Android未接入同等MLS传输；普通文件、新事、位置和音视频均不能据此声明已使用MLS。详见消息与媒体、本地数据格式、安全和隐私与验收矩阵。\n\n源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。"},{"product":"星线 App","title":"限制、错误码与重试规则","url":"/app/limits-and-errors/","text":"# 限制、错误码与重试规则\n\n本页按当前聊天服务与客户端源码列出限制。数值是具体字段或路由的约束，不代表全局容量承诺；反向代理、操作系统和外部供应商还可能有独立限制，但本页不臆测未实现的策略。错误判断应结合HTTP状态、error.code和当前业务阶段。\n\n## 单位与计数\n\nMiB=1048576字节，KiB=1024字节。界面显示“10 MB”时，当前服务实际检查10×1024×1024字节。JSON上限计算传输字节，包含字段名、引号、转义与base64膨胀；不能仅计算正文字符。base64约为原字节数的4/3，必须规范标准编码，base64url不适用MLS payload。\n\n多数服务端文字使用[...text].length计Unicode码点；iOS的String.count计字素簇，Android的length/take计UTF-16代码单元。组合重音和多码点表情等会产生不同计数。客户端允许输入不代表服务端一定接受，应保留服务端错误说明。依据：chat-service/src/server.mjs:50-78,135-142、ios-app/Sources/Features/Moments/MomentComposerView.swift:97-100、android-app/app/src/main/java/cn/thestarline/workbench/MomentsScreens.kt:513-514。\n\n## 服务端大小与数量\n\n| 对象 | 实际限制 | 超限表现 |\n| --- | --- | --- |\n| 默认JSON | 16384字节，根必须对象 | 413 TOO_LARGE；非JSON为415 CONTENT_TYPE |\n| 普通消息 | trim后文字≤2000码点；text与一个mediaId二选一 | 400 INVALID_MESSAGE |\n| 文件/聊天照片 | 单项10 MiB，非空 | 413 TOO_LARGE；空为400 EMPTY_FILE |\n| 自定义贴纸 | 512 KiB；PNG/JPEG；PNG宽高1–2048 | INVALID_STICKER或TOO_LARGE |\n| 聊天媒体账号配额 | 合计100 MiB；自定义贴纸100个 | 413 STORAGE_LIMIT；没有公开清理媒体配额的DELETE接口 |\n| 文件名 | trim后1–120码点；无控制字符或路径分隔符 | 400 INVALID_NAME |\n| 新事 | text≤2000码点；发布JSON≤20000字节；9图或单视频 | 400 INVALID_POST或413 TOO_LARGE |\n| 新事媒体 | 图片10 MiB、视频30 MiB；每账号500 MiB | TOO_LARGE或STORAGE_LIMIT |\n| 简介/资料受众 | bio≤160码点；selected最多100个不同当前好友 | INVALID_BIO或INVALID_VIEWERS |\n| 个人群 | name为1–40码点；创建/邀请1–49个不同好友；全群≤50人 | INVALID_GROUP_NAME、INVALID_GROUP_MEMBERS或NOT_FRIENDS |\n| 新事评论/举报 | 评论1–500码点，举报1–200码点 | INVALID_COMMENT或INVALID_REPORT |\n| 记忆识别 | text为2–2000码点 | INVALID_MEMORY_TEXT |\n| MLS密钥包 | TLS字节1–65536；JSON≤100000字节；每设备未用包≤25 | INVALID_KEY_PACKAGE、TOO_MANY_PACKAGES或TOO_LARGE |\n| MLS事件 | payload 1–1048576字节；JSON≤1500000字节 | INVALID_EVENT、INVALID_CIPHERTEXT或TOO_LARGE |\n| iOS独立MLS文字 | 非空、≤65536 UTF-8字节 | 本地invalidState；不适用于普通消息接口 |\n\n依据：chat-service/src/server.mjs:350-365,416-474,498-558,662-680,775-817,916-925、chat-service/src/database.mjs:219-226、chat-service/src/moments-repository.mjs:102-137、ios-app/Sources/Features/Chat/ChatCryptoDevice.swift:442-457。\n\n## 分页、有效期与已有频率计数\n\n| 类型 | 值与边界 |\n| --- | --- |\n| 单聊/群消息 | 默认50、最多100；limit最少1；before与非零after不能混用，群不支持单聊after契约 |\n| 普通events | 每批最多100，wait默认20秒、0–25秒；游标非负安全整数 |\n| MLS events | 每批100、立即查询；sequence可合法跳号 |\n| 新事feed | 默认20、最多50；comments和notifications每页30 |\n| 通话历史 | 最近100条终态；无公开该路由分页游标 |\n| 原生登录/聊天ticket/session | 10分钟/60秒/24小时；Account OIDC token期限另算 |\n| 临时好友码/转出flow/媒体token | 10分钟/5分钟/30秒 |\n| 呼叫与空房 | 约60秒未接超时；默认每10秒检查媒体空房，连续空约90秒结束 |\n| 未绑定新事媒体 | 超过24小时可清理，不是所有已发布媒体24小时到期 |\n\n当前allowFriendLookup按用户+method维护10分钟固定窗口：phone与temporary-code各最多20次，generate-code最多10次，memory-recognition最多10次。窗口从该方法当前计数周期第一次请求起计算；达到上限后不会因持续失败自动延长窗口。格式验证失败一般在计数前拒绝；格式正确但查不到目标也会计数，模型调用开始后失败可能已经计数。\n\n未见对普通messages发送、全部GET、群操作或新事发布统一应用此计数器，也没有该代码自动返回Retry-After或剩余额度头的契约。不能把10分钟20次写成全站限流。依据：chat-service/src/database.mjs:386-400、chat-service/src/server.mjs:775-790,832-898,1186-1190。\n\n## HTTP错误的触发条件\n\n| HTTP / error.code | 典型触发条件 | 下一步 |\n| --- | --- | --- |\n| 400 INVALID_JSON / INVALID_INPUT | JSON无效、数组根、联系人添加方式不唯一、字段类型错误 | 修正请求结构，不能原样重试 |\n| 400 INVALID_CURSOR / INVALID_LIMIT | 非安全整数、负游标、数量为0、混合前后游标 | 使用正确资源游标；最少1条 |\n| 400 INVALID_PHONE / INVALID_FRIEND_ID / INVALID_FRIEND_CODE | 手机号、好友ID或临时码格式错误 | 本机规范化后再提交 |\n| 400 INVALID_MESSAGE / INVALID_CLIENT_ID | text和media互斥失败、文字过长、UUID不合规、read目标不属于聊天 | 修正字段，不生成空消息 |\n| 400 INVALID_POST / INVALID_COMMENT | 受众不符、媒体被绑定、图视频混传、回复不属于帖子 | 重读关系/资源；不要盲重发 |\n| 400 NOT_FRIENDS | 建群成员未满足好友条件；邀请成员也可因加入后超过50人失败 | 分别检查目标用户UUID、当前好友关系与群容量，不能仅按“已断交”解释 |\n| 400 INVALID_LOCATION / 409 NOT_SHARING | 坐标无效，或PUT前未开启共享 | 修正坐标；共享必须由用户显式开始 |\n| 401 UNAUTHORIZED / INVALID_TICKET | 聊天session失效，或ticket过期、重放、proof错误 | 重新登录；不能删除proof降级 |\n| 403 CONTACT_BLOCKED / NOT_FRIENDS | 联系动作中任一方屏蔽或当前不再是好友；建群/邀请的同码400例外见上行 | 刷新关系，停止发送/位置/通话动作 |\n| 403 FORBIDDEN | 非群主或非企业授权角色等 | 使用真实有权身份，不绕过UI校验 |\n| 404 NOT_FOUND / PROFILE_NOT_FOUND / NOT_MEMBER | 无记录、资源不可见、已退出范围等 | 刷新列表；不要枚举ID探测 |\n| 409 CLIENT_ID_REUSED | 同幂等ID改了范围、正文、媒体或MLS密文 | 恢复原请求；新用户动作才用新ID |\n| 409 ALREADY_FRIENDS / INCOMING_REQUEST | 已有好友关系或反向申请 | 进入既有会话或接受incoming |\n| 409 E2EE_REQUIRED / NOT_ACTIVE | 非legacy尝试普通发送，或MLS尚未激活 | 走正确协议并补必要确认，不退回明文 |\n| 409 CALL_BUSY / CALL_STATE | 另有通话或邀请、参与状态已变化 | 重查active/current，再决定动作 |\n| 413 TOO_LARGE / STORAGE_LIMIT | 单次请求过大或账号媒体配额满 | 减小输入；重试不增加可用配额 |\n| 415 CONTENT_TYPE / 416 INVALID_RANGE | JSON类型错误，或新事媒体Range语法/起止无效 | 改Content-Type或合法单段范围 |\n| 429 TOO_MANY_LOOKUPS / TOO_MANY_CODES / TOO_MANY_RECOGNITIONS | 对应用户method计数达到上限 | 停止密集重试，等该窗口结束 |\n| 503 LOGIN_UNAVAILABLE / E2EE_UNAVAILABLE / CALL_MEDIA_UNAVAILABLE / MEMORY_MODEL_UNAVAILABLE / CITY_STICKER_UNAVAILABLE | 缺配置、校验器/媒体/模型/素材不可用等 | 保留本机状态，检查具体依赖，适度重试 |\n| 500 INTERNAL | 未被路由映射的异常 | 保留脱敏上下文，先确认是否实际写入 |\n\nMLS错误映射存在细节：NOT_MEMBER→404、UNKNOWN_DEVICE→403，INVALID_KEY_PACKAGE/INVALID_EVENT/INVALID_RECIPIENT/INVALID_WELCOME→400，其余具名错误通常409。因此INVALID_COMMIT当前是409，不能只按“INVALID前缀都400”分类；缺devices查询deviceId的INVALID_DEVICE为400，而包可用性内部的同名错误走409映射。依据：chat-service/src/server.mjs:1193-1203。\n\n发送权限与历史读取权限并不对称。CONTACT_BLOCKED/NOT_FRIENDS约束相关发送、位置或通话动作，不能推导普通旧附件必然404；普通单聊媒体凭仍可见消息读取时没有重新查好友/屏蔽，所有者还可独立下载。新事媒体则重新按帖子可见性过滤。依据：chat-service/src/database.mjs:239-254、chat-service/src/moments-repository.mjs:117-123。\n\n## 重试必须区分是否有幂等键\n\n正常幂等例：普通消息POST使用相同clientId和相同内容，第一次响应丢失后重试返回200及原message；MLS沿用同clientId、同密文和同目标重试返回原sequence。不能重新加密后沿用旧ID。GET可恢复查询，已读只前进，PUT like设置同一状态可重复。\n\n失败风险例：新事POST、评论POST与媒体上传没有同等clientId契约，响应丢失后直接重发可能重复内容或额外占用配额；先查询最新记录确认。生成好友码重试会替换旧码；申请接受重试可能因原申请已删除而404，此时应查会话而非认为好友建立必失败。换票响应丢失后ticket可能已消费，重试401需重新发起授权，不能恢复出未收到的token。\n\n不要将401自动重试成无限登录循环，也不要把所有503视为无副作用：关系操作在媒体调用阶段失败与消息提交后刷新失败有不同边界。本机退出成功也不能证明远端logout成功；Account改密不自动撤销聊天session。依据：chat-service/src/database.mjs:524-551,635-652与登录与会话。\n\n## 当前客户端差异\n\nAndroid新事先选selected好友再切到其他visibility时，仍可能携带非空viewerIds，服务端返回400 INVALID_POST；iOS发布前在非selected时发送空列表。iOS独立MLS prepare遇blocked只返回模式，不自动完成重键，继续应用发送会因未active而拒绝。这些是本轮源码核出的失败路径，未运行或修复原生代码，不能标成验收通过。对应依据见新事和MLS协议。\n\n## 客户端时限与验收\n\n通用iOS JSON请求通常15秒、普通/events为32秒；聊天媒体60秒，新事媒体90秒。Android连接15秒，普通JSON读20秒、events读35秒、上传下载读90秒。它们是网络调用时限，不是文档或媒体预览的自动关闭时间，也不代表服务器一定在时限内取消已提交操作。依据：ios-app/Sources/Features/Chat/ChatAPI.swift:45-96、ios-app/Sources/Features/Moments/MomentsStore.swift:186-227、android-app/app/src/main/java/cn/thestarline/workbench/AppData.kt:26-94。\n\n当前两端错误包装主要保留HTTP状态与message，并非都向业务层保留error.code；排错应记录已脱敏的状态、阶段与资源类型，不能记录Bearer、ticket、verifier或用户正文。字段全表见API参考，恢复步骤见故障处理，场景验证见验收矩阵。本轮为源码与文档核对，未执行生产业务或原生验收。\n\n源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。"},{"product":"星线 App","title":"联系人、好友申请与个人群","url":"/app/contacts-and-groups/","text":"# 联系人、好友申请与个人群\n\n好友关系、屏蔽关系、聊天可见性和群成员是四类独立状态。删除聊天不等于删除好友，群静音不等于屏蔽群成员，个人删除消息也不是全员撤回。本页给出这些状态的操作顺序、权限与恢复边界；全部业务请求使用聊天 Bearer 会话。\n\n## 三种添加方式\n\nPOST /friend-requests 的 JSON 在下列三个字符串字段中恰选一个。不要同时发送空字符串的备用字段，因为服务端按字段是否为字符串计数。\n\n| 字段 | 规范化与验证 | 额外条件 |\n| --- | --- | --- |\n| friendId | 删除空格、短横线并大写；11位字母数字 | 查找唯一聊天好友ID；不是Account用户ID |\n| phone | 删除空格、短横线；大陆11位号码补+86；国际号码带+ | 只查登录时取得、尚未过期的已验证手机号摘要 |\n| temporaryCode | 删除空格、短横线并大写；12位十六进制 | 对方用POST /friend-codes生成，10分钟有效 |\n\n临时码按账号保存HMAC摘要，生成新码替换旧码；生成每用户每10分钟最多10次。手机号与临时码查找各自每10分钟最多20次，好友ID路径没有在该层调用同一计数器。不能由此推导全部联系人请求都有统一限流。依据：chat-service/src/server.mjs:832-898、chat-service/src/database.mjs:371-400。\n\n临时码不是“成功成为好友才消费”：服务端找到有效码，排除添加自己和双方屏蔽后就删除该码，然后执行申请状态判断。因此同向pending、已是好友、存在反向申请，也可能已经消费临时码。重试前先刷新申请和会话列表，不应反复提交同一码。依据：chat-service/src/database.mjs:500-509。\n\n## 申请与接受的完整顺序\n\n1. 发送申请前展示目标添加方式，提交单一字段。\n2. 201 {status:\"requested\",peer,requestId}表示新建申请；当前还不是好友。接收者收到friend_request事件，刷新GET /friend-requests。\n3. 同向申请已存在返回200 {status:\"pending\",...}，requestId可能为null，应从outgoing列表取得现有记录。\n4. 对方已有反向申请返回409 INCOMING_REQUEST，转到incoming列表接受；已是好友为409 ALREADY_FRIENDS，任一方屏蔽为403 CONTACT_BLOCKED。\n5. 接收者调用POST /friend-requests/{requestId}/accept。服务端在事务中验证接收者、删除申请、建立或恢复双方唯一会话，并返回conversationId。\n6. 双方按friend_accepted事件重读会话。取消或拒绝统一用DELETE申请，双方才有权限，响应status=deleted。\n\n重新成为好友会复用原有双方会话ID，不重新建立另一份单聊；原来的个人历史可见边界也不会因此自动重置。依据：chat-service/src/database.mjs:476-564。\n\n合成成功例：{\"temporaryCode\":\"A1B2C3D4E5F6\"}对应一次申请，随后由对方按返回requestId接受。合成失败例：同时提交{\"friendId\":\"ABC123DEF45\",\"phone\":\"\"}会得到400 INVALID_INPUT，不能把空phone当作未传。票据、好友码和用户ID示例仅用于解释数据形状，不应拿真实用户资料排错。\n\n## 手机号、资料与竞争更新\n\n手机号添加使用Account最近登录返回的已验证号码摘要，当前聊天服务将可查找期限设为24小时；它与个人资料中自行填写、按private/selected/friends/everyone显示的contactPhone是不同字段。隐藏资料联系电话并不等同于关闭上述已验证号码查找路径，修改资料电话也不更新Account身份号码。界面应按实际字段解释行为，不能从一个开关推断另一个功能已经关闭。依据：chat-service/src/database.mjs:279-287,324-375。\n\n申请列表只是快照：在用户点击接受前，对方可能取消申请，或任一方完成屏蔽。服务端事务重新校验并决定结果，客户端不能仅凭卡片曾显示就假定成功。接受请求若响应丢失，重试可能404，因为原申请已经删除；此时重读conversations确认双方关系。正常情况下，同一对用户始终复用唯一会话，客户端应按conversationId合并，而不是给每次接受创建重复条目。\n\n## 屏蔽与删除好友\n\nPUT /blocks/{userId}是有方向的本人屏蔽；业务检查会合并任一方向屏蔽。成功后清掉双方未完成申请及对应单聊的全部实时位置记录，写入block_changed事件，但不将is_friend改为false，也不删除已有聊天正文。DELETE /blocks/{userId}只解除本人的屏蔽，不恢复已删除的申请或位置共享，也不能解除对方对自己的屏蔽。\n\n若存在该单聊通话，屏蔽路由先删除媒体房间并结束通话；媒体处理失败返回503，不能把界面按钮点击当作已完成屏蔽。已有MLS范围会被阻塞。依据：chat-service/src/server.mjs:845-864、chat-service/src/database.mjs:672-725。\n\nDELETE /friends/{conversationId}中的路径参数是会话ID。它将is_friend改false、清理双方申请与单聊实时位置，并通知双方；历史消息与会话记录继续存在。恢复联系需重新申请并接受。非好友不能继续普通发送、开始或接听单聊通话、开始或更新单聊实时位置。\n\n历史读取需要单独判断：普通聊天媒体的所有者可直接下载；非所有者凭仍可见的消息授权，受个人历史边界和单条隐藏过滤。单聊媒体查询不重新检查is_friend或block，因此屏蔽、删好友不保证旧的可见媒体立即失效。群消息授权分支要求当前群成员，但媒体所有者分支仍独立存在。删除本人消息也不会物理删除自己上传的文件。依据：chat-service/src/database.mjs:239-254。\n\n## 个人群权限与成员历史\n\n| 动作 | 请求字段 | 权限与结果 |\n| --- | --- | --- |\n| 创建群 | name为1–40字；memberIds为1–49个不同好友用户id（UUID），不含自己；不是11位friendId | 发起者成为群主；包含自己后最多50人 |\n| 邀请成员 | memberIds为1–49个不同好友用户id（UUID），不是11位friendId | 仅群主；只把不在群中的对象当新增，总数最多50 |\n| 改名 | name为1–40字 | 仅群主；产生group_renamed事件 |\n| 移除成员 | 路径目标用户ID | 仅群主；不能移除自己，应走leave |\n| 退出群 | 无业务字段 | 当前成员；群主退出会自动转移归属 |\n| 解散群 | 无业务字段 | 仅群主；先处理进行中的通话，再清理群相关数据 |\n\n创建时所有成员从新群开始；后续加入者的visible_after_message_id及last_read_message_id都设为加入当时最新群消息ID，所以不能阅读入群前历史。群主退出时，服务端按joined_at、user_id排序选最早剩余成员为新群主；最后成员退出删除群核心记录。leave与disband是不同清理分支，不能将最后成员退出描述成执行了完整解散清理。依据：chat-service/src/database.mjs:764-777,950-1033。\n\n移除成员、退出与解散会处理相应通话及位置共享；移除或退出使当前MLS范围blocked。群主的个人身份与MLS创建者设备是不同角色，群主转移并不自动转移MLS创建者权限。企业组织和企业频道另见工作台，不是此处的个人群功能。\n\n## 隐藏、删除与恢复\n\n| 操作 | 服务端变化 | 能否恢复旧历史 |\n| --- | --- | --- |\n| DELETE某条messages/{messageId} | 给当前用户插入hidden_messages | 没有公开单条取消隐藏接口；其他人仍可见 |\n| DELETE单聊 | 本人is_hidden=true，历史边界推进到当时最后消息 | restore只取消隐藏，不倒退边界 |\n| DELETE个人群视图 | 本人隐藏、历史边界与已读位置都推进 | restore不恢复边界前消息，也不等于重新入群 |\n| POST群mute | 只修改本人is_muted | 可再次设false；不改变成员关系、正文或已读 |\n\nincludeHidden=1用于获取自己仍有资格访问的隐藏会话或群。普通新消息会重新显示隐藏聊天，但旧历史边界仍有效。群静音更新没有在该方法中写入普通events，不能把它承诺为通过事件即时同步的跨设备设置。依据：chat-service/src/database.mjs:635-652,730-760,855-880,904-925。\n\n## 已读与异常处理\n\n单聊/read和群/read发送{lastMessageId}，位置只前进；目标消息必须属于当前聊天，群还要求消息晚于该成员历史边界。0虽然通过整数格式检查，但并不对应实际消息，仍会INVALID_MESSAGE。单聊用peerReadMessageId表示对方已读，群用其他成员已读位置最小值allReadMessageId；不能通过未读数算出逐人阅读时间。\n\n正常例：连续提交同一有效lastMessageId均可返回status=read，第二次不重复推进；失败例：把单聊消息ID提交给群read得到400 INVALID_MESSAGE。收到404时先刷新当前成员和申请状态，403先处理权限与屏蔽，409 CLIENT_ID_REUSED不得更改内容后继续沿用原发送ID。完整参数见API参考，错误与重试见限制与错误。本页依据源码阅读，未构成真实双端验收。\n\n源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。"},{"product":"星线 App","title":"消息、媒体与 MLS 数据流","url":"/app/messaging-and-media/","text":"# 消息、媒体与 MLS 数据流\n\n星线 App 当前主聊天界面通过普通消息 API 发送文字或媒体引用。仓库另外实现了 Rust MLS 核心、服务端加密事件流和 iOS 独立加密传输器。本页分别说明实际消息路径与 MLS 协议，尤其明确附件是否经过客户端加密，避免把已有加密模块扩大描述为所有聊天数据已经端到端加密。\n\n## 当前普通消息路径\n\n客户端在单聊调用 /conversations/{id}/messages，在群聊调用 /groups/{id}/messages。请求必须包含合法 UUID clientId，并在非空 text 和一个 mediaId 中二选一。文字会 trim，最多 2000 个 Unicode 码点。消息写入后返回 {message}；首次写入为 201，完全相同的重试为 200。\n\n客户端应为一次用户发送动作生成一次 clientId，失败重试沿用该 ID。服务端不仅检查 ID，还检查范围、正文和媒体是否一致；复用 ID 发送不同内容得到 409 CLIENT_ID_REUSED。客户端可先展示 sending，再按回执或同步结果合并，网络失败展示 failed 并提供重试。HTTP 成功代表服务端已接受，不能单凭它显示“对方已读”。\n\n单聊发送要求当前仍是好友且双方均未屏蔽；群发送要求当前是成员。普通消息表保存服务端可读正文和媒体引用。当前 iOS 主 ChatStore 与 Android 发送路径均使用这些接口；注册 MLS 密钥包不会自动改变这条链路。\n\n## 分页、已读与删除\n\n单聊列表支持 after 增量或 before 旧消息分页，非零 after 不能与 before 同时使用；群消息只支持 before。默认 50 条、最多 100 条，返回顺序为时间线升序。最新页和 before 页内部从数据库倒序取数后再反转，因此界面不需要额外反转。第一页不要传 before=0 代替“没有游标”。\n\n已读接口发送 {lastMessageId}，必须指向当前范围的实际消息，已读位置只前进。单聊回执使用对方 peerReadMessageId，群的已读信息按群模型处理。删除消息只对当前用户隐藏；删除聊天还会推进个人历史可见边界。恢复聊天不会恢复已越过的旧历史，也不会向其他参与者发起撤回。\n\n## 附件实际上传与访问\n\n先用原始二进制请求体调用 POST /media?kind=file&name=...，成功返回 {media:{id,kind,name,mimeType,size,createdAt}}，再把 mediaId 放入消息发送请求。请求体不是 multipart，也不是 base64 JSON。当前实现直接保存上传字节；客户端未生成独立附件密钥，未先把照片或文件封装为密文，也没有下载后的附件解密步骤。因此不能将这些附件称为 MLS 加密附件。\n\n| 类型 | 单项限制 | 其他限制 |\n| --- | --- | --- |\n| 普通文件、聊天照片 | 10 MiB，即 10*1024*1024 字节 | 非空；MIME 需符合类型格式 |\n| 自定义贴纸 | 512 KiB | 有效 PNG/JPEG；PNG 宽高均为 1–2048；每账号最多 100 个自定义贴纸 |\n| 聊天媒体总量 | 每账号 100 MiB | 普通文件与贴纸共同计入字节配额 |\n| 文件名 | 最多 120 个 Unicode 码点 | 禁止控制字符、斜杠与反斜杠 |\n\nGET /media/{id} 要求 Bearer token。所有者可下载，其他用户必须能看到引用该媒体的消息，且不能越过个人历史边界或单条隐藏记录。返回 Content-Disposition: attachment、nosniff 和私有缓存头。当前普通附件下载不实现 Range 分片。UUID 不是访问许可。普通单聊媒体的可见消息授权分支不重新检查好友或屏蔽，因此删好友、屏蔽不等于撤销全部历史媒体访问；群消息授权分支要求当前成员，媒体所有者分支则独立存在。依据：chat-service/src/database.mjs:239-254。\n\niOS 上传照片会先缩放到最长边不超过 2048 像素并转 JPEG，普通文件检查是否为非空规则文件及大小；这属于客户端预处理，不改变服务端可读性。城市贴纸通过 /stickers/city 选择服务器内置素材，按账号与素材键去重；GET /stickers 列出自定义贴纸，不包含所有内置城市贴纸。\n\n## 新事媒体使用另一套契约\n\n新事图片允许 JPEG/PNG，最大 10 MiB；视频允许 MP4/MOV，最大 30 MiB，检查 ftyp 文件标识。每账号新事媒体配额为 500 MiB。一个帖子最多 9 张图片，或仅 1 个视频，不能混发图片和视频。媒体必须属于当前用户、尚未绑定其他帖子。\n\n新事媒体读取依据帖子可见性，支持单个 bytes=start-end Range，返回 206 或 416；未绑定帖子的媒体仅所有者可读，超过 24 小时会清理。新事图文与评论保存在服务端，不属于 MLS 聊天事件流。\n\n## MLS 设备与会话建立\n\n加密核心使用 MLS 1.0 和固定套件 MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519。设备身份包含聊天用户 ID、设备 ID 和签名公钥，密钥包必须通过 OpenMLS 签名、有效性及套件检查，并与已认证用户和路径设备匹配。相同设备不能静默替换签名公钥，每设备最多保存 25 个未使用密钥包。\n\n1. 设备注册密钥包，服务端保存可验证的公钥材料；私钥留在设备。\n2. 范围当前是 legacy 时，创建者调用 prepare。每位当前参与者至少要有一个已注册设备，范围变为 preparing。\n3. 创建者在本机创建 MLS 群，按设备获取可用密钥包，生成 Commit 与定向 Welcome，使用不同 clientId 依次提交。\n4. 目标设备处理 Welcome、核对邀请者身份并固定签名公钥，完成本地持久化后提交 welcome-ack；已有设备处理后续 Commit 并提交 commit-ack。\n5. 创建者 activate。服务端确认当前所有设备已加入并处理所需 Commit，范围才变为 active。\n6. active 范围接受 application 密文事件。客户端处理 MLS 消息，核对加密层认证的发送用户、设备与服务端元数据一致，然后解析应用载荷。\n\n密钥包 TLS 字节最大 65,536 字节，事件 MLS payload 最大 1,048,576 字节，使用规范标准 base64 置于 JSON。事件 JSON 请求体上限为 1,500,000 字节。iOS 独立文字加密载荷为 version=1、kind=text、clientID、text，文字上限另为 65,536 UTF-8 字节；这不等于普通文字接口的 2000 字限制，也不构成大附件传输方案。\n\n## 模式、重试与恢复\n\n一旦存在 preparing、active 或 blocked 范围，旧 POST messages 接口就返回 E2EE_REQUIRED，防止改走明文。注册新设备、群成员变化、删除好友或屏蔽可使已存在加密范围 blocked；完成新的成员 Commit、Welcome 和必要确认后，创建者才可再次 activate。不要把等待设备上线解释成消息已经交付。\n\n服务端对每个发送用户、设备、clientId 去重，并比较密文摘要、类型和目标。iOS 把待发送密文、pending invite、ack 和 sequence 连同 MLS 状态保存，重试时使用原密文，不重新加密同一排队消息。独立传输器存在不代表主聊天界面已完成接入；Android 当前未见相应 MLS 发送链路。\n\n当前不能宣称所有文字、附件、新事、实时位置或音视频均已端到端加密，也不能宣称已完成生产安全审计。接口细节见API 参考，密钥与威胁边界见安全和隐私。\n\n密钥包claim、设备确认、非连续sequence与blocked恢复的详细次序见MLS协议；重试边界见限制与错误。"},{"product":"星线 App","title":"新事的发布、可见性与媒体","url":"/app/moments/","text":"# 新事的发布、可见性与媒体\n\n新事是服务端保存的图文与短视频内容，使用独立于聊天消息的表、媒体配额和通知机制。它没有接入MLS；可见范围由每次读取时的当前关系决定。服务端支持媒体Range，但当前iOS与Android播放器路径都先完整下载后播放。\n\n## 模型与响应字段\n\n| 对象 | 字段与含义 |\n| --- | --- |\n| Post | id、authorId、text、visibility、createdAt、author、media、likeCount、commentCount、liked、recentComments；作者读selected帖子另有viewerIds |\n| Media | id、name、mimeType、kind=image/video、size、createdAt；原始字节另存BLOB |\n| Comment | id、postId、authorId、replyToId、text、createdAt、authorName |\n| Notification | id、actorId、actorName、postId、commentId、kind、createdAt、readAt；kind为like/comment/reply |\n\n帖子、评论、通知ID为各自的整数序列，媒体和用户为UUID；createdAt/readAt是Unix毫秒，readAt可null。完整表定义及模型组装见chat-service/src/moments-repository.mjs:8-71,149-164,204-231,277-301。\n\n## 可见范围不是发布时冻结的好友快照\n\n| visibility | 作者之外的读取规则 |\n| --- | --- |\n| friends | 当前仍是好友、没有任一方向屏蔽 |\n| selected | 上述好友条件成立，且用户在该帖moments_audience中 |\n| public | 已登录聊天API用户，双方未屏蔽；不是匿名公开网页 |\n| private | 只有作者 |\n\n个人hide优先过滤，包括作者本人隐藏自己的帖子后也不会通过普通post读取。作者通常可读自己的帖子，其他用户还会结合屏蔽与当前好友关系。selected发布须有至少一位当前未屏蔽好友，最多100、不重复，不含自己；其他范围viewerIds必须为空。删除好友后selected对象即使仍在受众表，也不再满足当前好友条件。依据：chat-service/src/moments-repository.mjs:80-89,126-145。\n\n没有公开PATCH帖子或调整既有visibility的路由。若页面因失去可见资格而返回404，应按不可再读取处理，不应在客户端继续展示缓存为“公开可见”。\n\n## 两阶段上传与发布\n\n1. 客户端先准备媒体，按每个文件调用POST /moments/media?name=...，Content-Type为实际MIME，body为原始字节。\n2. 上传成功得到media.id，暂时尚未绑定帖子，仅所有者可读取。\n3. 客户端收齐mediaIds后调用POST /moments：text、visibility、viewerIds、mediaIds。\n4. 服务端在事务中确认媒体为当前用户拥有、尚未绑定其他帖子，插入帖子与受众，并绑定全部媒体。\n5. 返回201 {post}后更新列表；上传成功但未发帖不等于发布成功。\n\n| 项目 | 服务端限制 |\n| --- | --- |\n| text | trim后最多2000个Unicode码点；文字或媒体至少一项 |\n| 图片 | JPEG或PNG，单项10 MiB；PNG宽高1–2048 |\n| 视频 | MP4或MOV，单项30 MiB；检查ftyp标识 |\n| 单帖媒体 | 最多9图，或仅1段视频；不能图视频混合 |\n| 账号媒体 | 新事独立500 MiB配额；未绑定媒体超过24小时清理 |\n| JSON与文件名 | 发布JSON最多20000字节；name为1–120码点，无控制字符或路径分隔符 |\n\n这些值见chat-service/src/server.mjs:350-365,416-434、chat-service/src/moments-repository.mjs:97-145。媒体原始字节和帖子内容都能被服务端读取；文件头检查不等于完整转码、恶意文件扫描或编解码器兼容性验收。\n\n发布和上传没有clientId去重契约。若响应丢失，先查询自己的最新新事确认结果；重新上传可能额外占用空间，重复发帖也可能产生第二条。若上次实际发布成功，相同mediaIds再次发布会因已绑定返回INVALID_POST，而不是返回上次帖子。不能套用聊天消息的“相同clientId安全重试”。\n\n## Range读取与客户端实际行为\n\nGET /moments/media/{mediaId}先按当前帖子权限检查，随后处理一个可选Range。支持bytes=0-1023或bytes=1024-，不支持bytes=-1024、多段或其他语法。合法范围返回206、Content-Range和Content-Length；不带Range返回200。结束位置超出文件末尾会截到末尾，起点超出大小、起点大于终点或非安全整数返回416 INVALID_RANGE。缓存为private、max-age=300，不是公开CDN许可。\n\n合成例：对大小2048字节的有权访问媒体，请求Range: bytes=0-1023返回前1024字节；bytes=2048-返回416。首次未绑定媒体仅本人能下载，即使另一个用户知道媒体UUID也返回404。依据：chat-service/src/server.mjs:367-392、chat-service/src/moments-repository.mjs:117-123。\n\niOS MomentsStore.mediaData只接受200，把完整Data缓存，视频写临时mp4/mov再交AVPlayer；Android下载也只接受200，按图片10 MiB、视频30 MiB有界读取，点击视频后才下载并写缓存文件交VideoView。因此“后端支持Range”不代表当前两端已实现边下载边播放。依据：ios-app/Sources/Features/Moments/MomentsStore.swift:186-206、android-app/app/src/main/java/cn/thestarline/workbench/MomentsScreens.kt:380-438。\n\n## 点赞、评论、删除和通知\n\nPUT like为幂等点赞，DELETE like为取消；只有首次新增点赞产生通知，给自己的内容点赞不通知自己。评论1–500字，可带同帖replyToId，成功后产生comment或reply通知；回复对象缺失为INVALID_COMMENT。作者可删除自己评论，帖子作者可删除其帖下任意评论。删除被回复评论会把后续评论的replyToId置null，不会一并删掉所有回复。\n\n只有帖子作者可DELETE帖子，数据库外键级联删除其媒体、评论、点赞与相关通知。hide只对本人隐藏，report以帖子+举报者去重并更新原因，不能举报自己的帖子。report成功只证明记录写入，不证明已有审核处理工作流。依据：chat-service/src/moments-repository.mjs:188-267。\n\nFeed默认20、最多50条；before指向更旧帖子，返回nextBefore，不能把缺省替换成0。评论与通知固定每页30，通常倒序，nextBefore=null表示该次查询没有下一页。通知列表动态过滤屏蔽和不可见帖子，unreadCount按当前可见通知计算；mark read会将当前账号全部未读通知标记已读。新事没有在这些方法中写普通/events，客户端靠进入页面或显式刷新同步。\n\n## 分页合并、互动历史与账号切换\n\nfeed在内部每次扫描最多100条候选，再逐条检查当前可见性，累积到请求数量加一才决定下一页。因此返回ID不连续、帖子数量少于候选数量都是正常情况；分页应使用nextBefore，不应自行减ID或认为被过滤内容不存在。客户端追加下一页还按帖子ID去重，避免刷新与分页重叠产生重复卡片。依据：chat-service/src/moments-repository.mjs:166-185、ios-app/Sources/Features/Moments/MomentsStore.swift:67-77。\n\n通知记录的是过去发生的互动。取消赞只删除like关系，没有同步删除原来的点赞通知；取消后再点赞可能产生新的互动通知。当前liked状态应以帖子响应为准，不能由“收到过点赞通知”推断。评论者、帖子作者和被回复者相同时，会避免给同一人重复生成自己参与的通知；通知可见性仍在读取时重新过滤。\n\niOS在refresh检测到账号变化或退出时清空帖子、通知和内存媒体缓存，并删除其登记的视频临时文件；这不等于整个操作系统缓存已擦除。下载完成与刷新之间若账号发生变化，还需通过客户端生命周期测试验证旧响应不会影响新账号。本轮只记录源码机制，没有执行切换账号或缓存清除验收。依据：ios-app/Sources/Features/Moments/MomentsStore.swift:31-63。\n\n## 双端差异与失败样例\n\n| 场景 | iOS当前实现 | Android当前实现 |\n| --- | --- | --- |\n| 图片准备 | 最长边缩到2048，以0.82质量重编码JPEG | 读取选择器原始JPEG/PNG字节，最多读上限+1；不做同等重编码 |\n| 文字计数 | Swift String.count，按字素簇提示和禁用发布 | Kotlin take(2000)/length，按UTF-16代码单元截取 |\n| 指定受众 | 候选过滤isFriend与非isBlocked；非selected强制发送[] | 候选从conversations取peer；发布固定带selected列表 |\n| 发布后刷新 | 将返回post插入当前列表 | 确认提交后切回新事，再刷新列表 |\n\n服务端计数是Unicode码点，包含组合字符的输入可能与两端计数不同，以服务端判定为准。Android还有源码可推导的失败场景：先选指定好友，再切到friends/public/private，selected列表未清空仍随请求发出，服务端因非selected带viewerIds拒绝400 INVALID_POST；iOS发送前清空该字段。该场景本轮未运行原生验收，不能写成已修复。依据：ios-app/Sources/Features/Moments/MomentComposerView.swift:36-62,194-236、ios-app/Sources/Features/Moments/MomentsStore.swift:91-108、android-app/app/src/main/java/cn/thestarline/workbench/MomentsScreens.kt:458-500,513-554。\n\n正常例：friends+viewerIds=[]+一张刚上传的本人图片返回post。失败例：混传一图一视频、引用别人媒体、复用已绑定媒体或selected列表含已删除好友，均不会完成新帖子写入。详见API参考、限制与错误及验收矩阵。\n\n源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。"},{"product":"星线 App","title":"城市提示与实时位置共享","url":"/app/location-sharing/","text":"# 城市提示与实时位置共享\n\n城市贴纸推荐、一次性地点消息和实时位置共享是不同功能。城市识别只为推荐素材；实时共享会把精确坐标和时间持续写入聊天服务，必须由用户显式开始，并在停止、权限变化、断网和退出账号时分别处理本机采集与远端记录。\n\n## 三条位置路径\n\n| 路径 | 数据与用途 | 是否自动开始实时共享 |\n| --- | --- | --- |\n| 城市提示GET /location/city | 请求来源公网地址映射到city、countryCode、source=ip；可返回null | 否 |\n| 城市贴纸设备定位 | 前台取一次定位并经系统地理编码得到城市，再匹配本地目录 | 否 |\n| /conversations/{id}/live-locations或/groups/{id}/live-locations | 当前范围用户的经纬度、精度、开始和更新时间 | 只有POST显式开始 |\n\n城市接口不返回街道或坐标。服务端仅在连接对端属于可信代理allowlist时接受代理覆盖的单个X-Real-IP，不读取X-Forwarded-For；非公网来源、目录缺失、校验失败或查不到城市时返回null而非伪造结果。依据：chat-service/src/city-location.mjs:99-124。\n\niOS城市定位期望约三公里精度、10秒取值超时，不启用后台更新；iOS选择器并行尝试设备与IP，设备结果优先，10分钟内可复用结果，手动选择会使旧自动任务失效。Android城市定位只做一次前台fix，地理编码前把经纬度量化到0.02度网格。系统地理编码仍有其系统服务的数据边界，不应把“坐标不传聊天城市接口”扩大成“整个定位过程不接触外部服务”。依据：ios-app/Sources/Features/Chat/CityStickerDeviceLocator.swift:18-108、ios-app/Sources/Features/Chat/CityStickerSelection.swift:36-113、android-app/app/src/main/java/cn/thestarline/workbench/CityStickerLocation.kt:44-90。\n\n## 实时共享的数据与权限\n\n| 字段 | 类型与约束 |\n| --- | --- |\n| scopeKind、scopeId | conversation/group与对应聊天ID；用不同路径区分 |\n| userId、displayName | 由已登录会话决定；请求不能替别人上传 |\n| latitude、longitude | 有限数字；纬度-90至90，经度-180至180 |\n| accuracy | 有限数字，0–100000，单位米；不是“误差越大越可信” |\n| startedAt、updatedAt | 服务端Unix毫秒；前者首次开始时间，后者最近成功写入时间 |\n\n所有操作都要求Bearer与当前范围成员资格。单聊POST/PUT还要求仍是好友且任一方均未屏蔽；DELETE只停止自己的共享。群GET/POST/PUT/DELETE要求当前成员，不要求每两位群成员互为好友。服务端存储可读坐标，不是MLS密文；同范围有权成员能读取全部当前共享者。\n\n依据：chat-service/src/server.mjs:101-109,582-610、chat-service/src/database.mjs:1037-1085。\n\n## 从开始到恢复的操作次序\n\n1. 用户明确选择共享并确认，客户端请求系统位置权限；Android当前界面还要求通知权限，用于持续共享通知。\n2. 获得有效坐标后POST范围路径，请求只包含latitude、longitude、accuracy。成功201 {location}后才登记本机共享范围并启动持续定位。\n3. 后续位置更新使用PUT，同一用户、范围只更新一条记录；首次startedAt不重置，updatedAt更新为本次服务端时间。\n4. 页面单独GET范围位置，当前两端聊天面板约每4秒刷新；位置写入没有普通/events通知，不应等待message事件才刷新地图。\n5. 登录或恢复时GET /live-locations/mine取得本人的共享范围，然后与权限和本机定位状态协调。\n6. 用户结束该范围时DELETE，成功200 {status:\"stopped\"}后从本机集合和界面移除；没有其他共享范围才停止持续采集。\n\nPOST会upsert既有自己的记录，PUT不会创建新共享：未POST就PUT返回409 NOT_SHARING。客户端不能在PUT失败时自动POST恢复，因为这可能在用户已结束共享后重新开启。依据：chat-service/src/database.mjs:1063-1085、ios-app/Sources/Features/Chat/ChatStore.swift:577-645。\n\n合成正常请求：{\"latitude\":0,\"longitude\":0,\"accuracy\":25}是合法测试坐标值，不代表用户真实位置。POST后GET应出现当前用户并有两个时间字段；DELETE后GET不再出现。合成失败请求：{\"latitude\":91,\"longitude\":0,\"accuracy\":25}返回400 INVALID_LOCATION；不应因缺少定位结果而把用户默认为零坐标发送。\n\n同一个账号可同时向多个聊天范围共享，同一条本机定位会依次PUT到ownLiveScopes中的各范围。停止一个聊天不会停止其他范围；只有本机剩余范围为空才释放持续监听。确认“全部结束”时应重新读取mine，而不是只看当前聊天面板为空。范围内没有自己的记录，也不表示同账号在另一个群没有共享。依据：ios-app/Sources/Features/Chat/ChatStore.swift:607-645、android-app/app/src/main/java/cn/thestarline/workbench/AppState.kt:387-449。\n\n## 持续定位的双端实现\n\n| 场景 | iOS | Android |\n| --- | --- | --- |\n| 采集设置 | best accuracy、distanceFilter=10米，禁止系统自动暂停，允许后台更新 | GPS/NETWORK provider更新，最小间隔5秒、距离10米，前台定位服务 |\n| 上传节流 | 至少间隔5秒，同一时刻仅一轮上传 | 单调时钟间隔至少5秒、AtomicBoolean避免并行上传 |\n| 过旧fix | 接收精度非负且时间不旧于约60秒的定位 | 精度0–100000、时间不旧于60秒 |\n| 范围恢复 | 按mine重建ownLiveScopes | 服务约30秒同步范围，持续通知显示状态 |\n| 普通页面停止 | 先等DELETE成功，再改本机范围集合 | 同样先等DELETE成功，再停止相应本机状态 |\n\n这些是程序配置与调度目标，不保证操作系统每5秒交付一次定位，也不代表锁屏、后台权限和省电模式已完成设备验收。iOS仅在至少一个共享范围存在时持续更新；Android服务返回START_NOT_STICKY，不保证被系统终止后自动复活。依据：ios-app/Sources/Features/Chat/ChatLiveLocation.swift:41-107、android-app/app/src/main/java/cn/thestarline/workbench/LiveLocationService.kt:54-171。\n\n## 时间、可信程度与页面一致性\n\nupdatedAt由服务器在成功接收POST/PUT时生成，协议没有单独的定位fix时间字段。因此“刚刚更新”仅表示刚收到坐标，不能单独证明坐标刚由卫星测得、用户仍在原地或后台采集持续正常。当前官方客户端会过滤部分过旧定位，但服务端并不验证设备传感器来源。展示accuracy和最后更新时间比只显示一个地图标记更有依据。\n\nAndroid刷新当前范围时会保存请求前的scope与session，响应返回后再次核对，避免原聊天的结果覆盖用户新打开的聊天。iOS当前refreshLiveLocations方法没有同等响应后scope/token核对，需要把换账号、切换聊天时旧请求晚到加入验收，不能直接承诺两端异步隔离行为完全一致。依据：android-app/app/src/main/java/cn/thestarline/workbench/AppState.kt:374-384、ios-app/Sources/Features/Chat/ChatStore.swift:577-586。\n\n## 停止失败、权限关闭与数据保留\n\n停止本机传感器不会自动删除服务器最后坐标，服务器删除成功也不等于系统定位监听必定已释放。两端普通页面的结束动作若DELETE失败，会保留共享状态并展示或传播错误，不应显示“已经结束”。Android持续通知的“结束全部”是另一个分支：先停止本机采集，随后DELETE所有范围，网络失败约每10秒重试，通知显示正在重试。\n\n系统权限关闭后，本机停止或无法继续采集，但远端记录不会因此自动消失；Android明确提示打开App结束原有共享。网络暂时不可用时保留最后成功位置和updatedAt，地图应标明最后更新时间，而不是宣称仍在实时移动。依据：android-app/app/src/main/java/cn/thestarline/workbench/LiveLocationService.kt:54-65,91-130、ios-app/Sources/Features/Chat/ChatStore.swift:607-645。\n\n当前live_locations没有expiresAt或自动过期清理字段，也不保存一张连续轨迹历史表：同一范围用户更新覆盖该行的当前坐标。DELETE停止会删除该行；屏蔽或删除好友清理对应单聊双方的位置，退出/移除群成员清理该成员的位置，解散群清理该范围全部位置。备份、日志和系统缓存的生命周期不等同于该表行删除，不能据此承诺所有副本立即销毁。依据：chat-service/src/database.mjs:693-725,928-934,990-1033,1063-1085。\n\n退出账号也需区分：Android先停止本机，再尽力清理服务器共享；iOS本机退出先停止manager，但其signOut路径没有同等逐范围DELETE共享流程。聊天session到期不会自动清掉live_locations，仍有权的其他成员可能读到旧位置，直至显式停止或关系清理。此为源码边界，未据此执行真实定位请求。详见客户端生命周期、限制与错误与安全和隐私。\n\n源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。"},{"product":"星线 App","title":"实时同步、位置与音视频通话","url":"/app/realtime-and-calls/","text":"# 实时同步、位置与音视频通话\n\n聊天变更通过 HTTP 长轮询通知，音视频使用独立的 LiveKit 媒体连接，iOS 来电可通过 APNs VoIP 唤醒。三者承担不同责任：事件告诉客户端刷新什么，通话 API 维护参与状态，媒体服务承载实际音视频。任何单一环节成功都不能代表整个通话已经可用。\n\n## 普通事件游标\n\nGET /events?after=0&wait=20 要求聊天 Bearer token。after 是当前用户已经处理到的普通事件 ID，必须为非负安全整数；wait 为 0–25 秒，默认 20。服务端先查已存在事件；有结果立即返回，没有结果才在进程内登记等待者。登记后再次查询以补上查询与注册之间的竞争窗口。\n\n响应为 {events:[...],cursor}。每批最多 100 条、按 ID 升序；空批次的 cursor 保持请求 after。事件包含 id,conversationId,groupId,kind,messageId,createdAt，具体引用可能为 null。常见 kind 包括 message、message_read、group_message、group_message_read、friend_request、friend_accepted、friend_removed、block_changed、group_members_changed、call_changed 和 e2ee。\n\n登录交换及 /me 会返回用户最新 eventCursor，供初始化增量监听。客户端先获取当前列表快照，再连续消费事件；断线后沿用已处理游标重连，并按事件刷新对应资源。不要把消息 ID 当事件游标，也不要直接用 e2ee sequence 调用普通 /events。\n\niOS 当前循环使用 wait=20，收到事件后刷新会话、群、好友申请及相关消息；call_changed 触发通话重查。401 清理该会话，其他错误约等待 3 秒再重试，且核对异步响应仍属于原 token。事件是刷新信号，消息正文仍从资源接口读取。等待器保存在单进程 Map，当前没有跨实例唤醒或 WebSocket 订阅路由。\n\n## MLS 的另一种游标\n\nGET /e2ee/{conversations|groups}/{id}/events?deviceId=...&after=... 返回按设备收件的加密事件，每批最多 100 条，用 sequence 推进 cursor。它是立即查询接口，不使用普通事件的 wait 参数。普通 events 中的 e2ee 通知可以触发该查询，但两种游标必须分别保存。\n\n客户端应在 MLS 验证及本地状态持久化成功后推进 sequence，Welcome 与 Commit 需要各自确认。iOS 独立传输器限制单次同步最多处理 100 批，超过后提示继续同步。若解密、发送者校验或状态保存失败，不应跳过密文并永久推进游标。详见消息与媒体。\n\n## 实时位置是显式会话\n\n单聊和群聊分别使用 /conversations/{id}/live-locations、/groups/{id}/live-locations。POST 显式开始，PUT 更新已经开启的共享，GET 查询当前参与者位置，DELETE 停止当前用户共享。GET /live-locations/mine 用于恢复自己的共享范围。\n\n位置数据为 latitude、longitude、accuracy，必须是有限数字；纬度 -90–90、经度 -180–180、精度 0–100000 米。未先开始就 PUT 返回 409 NOT_SHARING。单聊开始和更新还要求仍是好友且未屏蔽，群操作要求当前成员。返回 startedAt、updatedAt，界面应展示更新时间，不能把静态最后坐标当实时移动。\n\n当前服务端位置表没有自动到期字段，位置变更也未写入普通 events；客户端需单独刷新并在退出、停止共享和权限变化时处理生命周期。坐标是服务端可读数据。GET /location/city 仅提供基于请求公网地址的粗略城市与国家代码，不等于 GPS 定位，无法判断时返回 null。\n\n## 通话创建与状态机\n\n创建调用 POST /calls，发送 scopeKind（conversation/group）、scopeId、mode（audio/video）、UUID clientId。服务端要求当前成员；单聊额外要求好友及未屏蔽。同一个范围最多一场 ringing/active 通话，发起者不能同时占用另一场。相同发起者与 clientId 的相同请求返回同一通话；不同内容或冲突可能得到 CALL_BUSY。\n\n| 状态 | 含义与转移 |\n| --- | --- |\n| ringing | 等待邀请者响应；发起者可 end 取消；接听后 active |\n| active | 至少已完成接听状态；媒体仍需单独连接 |\n| cancelled | 发起者在响铃阶段取消，原因 caller_cancelled |\n| missed | 响铃达到约 60 秒无人接听，原因 unanswered |\n| ended | 挂断、全员忙、拒接、成员变化或空媒体房间等终态 |\n\n邀请另有 ringing/accepted/declined/busy/missed/cancelled 状态。若其余所有成员都忙，创建请求可以返回 201 但 call 已是 ended、原因 busy，因此客户端必须读取响应 state。首位受邀者 accept 使通话 active，并登记发起者与接听者为参与者；后续群成员可 accept 或在 active 时 join。join 仅支持群通话。\n\nGET /calls/active 查询当前用户正在响铃或实际参与的通话；/calls/current 按聊天范围查询；/calls/history 返回该范围最近 100 条终态记录。普通 call 响应不含内部房间名及请求 clientId。旧事件只能触发重查，不应把旧 state 覆盖新的终态。\n\n## 媒体加入与退出\n\n接听或加入成功后，参与者调用 /calls/{id}/media-token 获取 {url,token}。服务端只为 active 且尚未离开的当前参与者签发凭证，TTL 为 30 秒，权限限定当前房间、发布音视频与订阅，不允许发布 data。客户端必须及时连接，过期后重新获取；不能把聊天 Bearer token 当媒体 token。\n\nactive 单聊结束时删除媒体房间；群成员离开则移除该媒体参与者，只有群里无人继续参与时才结束整场并清理房间。删除好友、屏蔽、群解散或成员移除等路径会先处理相关媒体权限；媒体服务故障可能使动作返回 CALL_MEDIA_UNAVAILABLE，客户端需展示失败并可重试。\n\n服务端约每秒检查响铃超时；默认约每 10 秒检查 active 房间人数，连续空房达到默认 90 秒宽限后结束通话并记录 connection_lost。检查媒体人数失败时不会据此认定空房。状态机的 active 仍不证明麦克风、摄像头、远端轨道或网络质量正常。\n\n## VoIP 推送与验收边界\n\niOS 通过 POST /voip/devices 注册设备 UUID 与十六进制 pushToken，记录绑定当前有效聊天 session。DELETE /voip/devices 注销设备。来电仅发给仍有有效 session 的待接听邀请者；APNs 返回 410、BadDeviceToken、Unregistered 或 DeviceTokenNotForTopic 时移除该 token。\n\n推送载荷包含 aps 空对象，以及 callId、scopeKind、scopeId、mode、callerName，群通话另有 groupName。它没有聊天正文或媒体 token，但姓名、群名与通话关系仍属于元数据。推送类型为 voip、优先级 10、到期约 60 秒；缺少配置时不发送。APNs 的 HTTP 成功也不证明设备已经展示系统来电或成功接听。\n\n仓库已有通话信令、媒体 SDK、系统通话桥接与推送发布器。真实双设备跨网络音视频、断线重连、后台与锁屏来电、生产 APNs、权限拒绝后的恢复仍需逐场景验收。请结合测试、运维、iOS和Android读取具体结果。\n\n城市定位与精确共享、两端停止失败及最后坐标保留的具体区别见位置共享。"},{"product":"星线 App","title":"iOS 客户端","url":"/app/ios/","text":"# iOS 客户端\n\n星线 iOS 是使用 SwiftUI 编写的原生 iPhone 客户端。本文说明工程入口、状态管理、本机数据与系统能力接入，面向需要运行、维护或扩展客户端的开发者。\n\n## 工程与系统要求\n\nios-app/project.yml 是 XcodeGen 工程配置来源，生成的工程名和主 scheme 都是 StarlineWorkbench。配置声明 Swift 6.0、最低 iOS 17.0、设备族为 iPhone，Bundle ID 为 cn.thestarline.workbench。当前没有 iPad 专用或 watchOS target；独立手表版仍属待办。\n\n项目直接导入 FoundationModels，开发环境需要包含该框架的 SDK；仓库 README 的开发流程使用 Xcode 27 和 XcodeGen。最低运行系统与编译 SDK 是不同要求：iOS 17 可以运行基础功能，系统模型识别仅在 iOS 26 及以上且模型可用时启用。\n\n主要外部依赖为 LiveKit Swift SDK（from: 2.17.0）和本仓库 chat-crypto/dist/StarlineChatCrypto.xcframework。Swift Package 版本声明允许兼容版本解析，不能将下限当作每次构建的实际锁定版本。加密核心需按仓库构建流程准备，详见消息与媒体。\n\n## 启动与状态架构\n\nStarlineWorkbenchApp 在启动时创建 ChatStore，并将其交给 CallSystemBridge。根视图分别持有 LocalWorkspaceStore、MemoryStore、MomentsStore 和附近地点状态，通过 SwiftUI Observation、@State 与环境传递给功能视图。\n\n默认底部为“聊天、工作台、我的”；记忆可以通过设置加入底部导航。默认页面和扫码按钮开关使用 @AppStorage 保存。工作台路由使用 NavigationStack，任务编辑、扫码和账号确认使用独立展示状态。界面 locale 当前固定为简体中文。\n\n启动异步恢复待处理登录与聊天会话，回到前台重新检查会话。onOpenURL 先尝试身份回调，再处理好友链接，避免把登录回调当作普通扫码内容。身份流程与回调限制见认证。\n\n## 数据与网络流\n\n任务、网站收藏和最近访问由 LocalWorkspaceStore 写入 App 私有的 workspace.json。记忆由单独的 MemoryStore 管理，其索引、附件和密钥策略见本地记忆。这两类本机数据不因聊天登录而自动同步。\n\n聊天通过 ChatAPI、URLSession 和模型解码访问聊天服务；默认地址为 https://chat.thestarline.cn。Debug 可用 STARLINE_CHAT_BASE_URL 指定测试地址，代码只接受 HTTPS 或 HTTP 回环主机，并拒绝包含用户信息、查询或片段的覆盖值；Release 不读取该环境覆盖。不要把测试地址或真实凭据写进源码。\n\n新事状态由 MomentsStore 复用聊天身份管理；音视频使用 LiveKit，系统来电使用 CallKit/PushKit。扫码使用 VisionKit 的二维码识别，相册图片识别和手动输入保留为替代入口。普通网页与账号确认网页分别管理浏览会话。\n\n## 运行与扩展\n\n在仓库的 ios-app 目录执行：\n\n``sh\nxcodegen generate\nopen StarlineWorkbench.xcodeproj\n`\n\n选择 StarlineWorkbench scheme 运行；新增源文件或修改系统能力后同步 project.yml 与工程。新增领域数据应先明确属于本机工作台、加密记忆还是服务端聊天，随后在对应 Store 增加操作，再由视图呈现错误和加载状态，避免直接在视图中混合网络和持久化。\n\n真机签名须使用维护者自己的开发团队与描述文件。工程声明相机、麦克风、使用期间定位，以及 location/audio/voip 后台模式；APNs 环境按 Debug/Release 区分。配置存在不能证明推送、后台定位或通话已完成真机验收，详见权限与可访问性。\n\n## 模块边界与排错入口\n\n| 现象或需求 | 优先检查 | 不能据此推断 |\n| --- | --- | --- |\n| 本机任务或网站读取失败 | LocalWorkspaceStore 的快照解码与原子写入 | 聊天账号重新登录会恢复工作台 |\n| 记忆库无法读取 | MemoryStore.loadError、Keychain 可用性和文件认证 | 空列表就是原来没有记忆，或改账号密码能恢复密钥 |\n| 浏览器成功但账号为空 | ChatNativeAuth 的 pending、回调和 exchange | 打开 Account 页面已经取得聊天 token |\n| 消息一直 sending／failed | ChatStore pending 与 clientId 合并 | 普通队列具备持久离线发件箱 |\n| 通话 active 但无画面 | ChatCallCoordinator 的 Room、权限与远端轨道 | 信令 active 等于媒体接通 |\n| 后台位置未更新 | ChatLiveLocationManager、权限、共享范围与更新时间 | 城市贴纸识别会自动开启持续共享 |\n\n会话和 pending 使用聊天 Keychain service，属性为首次解锁后仅本设备可用；记忆密钥使用另一 service，要求当前设备解锁。LocalWorkspaceStore` 的普通快照不因此共享记忆加密。普通聊天队列属于当前 Store 内存；独立 MLS 传输器才有自己的加密 checkpoint，当前主界面不能继承它的全部恢复能力。\n\n新增功能时需区分主线程状态与异步响应。当前多个关键路径会在 await 后检查 token、用户、聊天目标或 operation，防止迟到结果污染新状态；这不表示所有新增请求可以省掉检查。把页面隐藏、后台暂停、网络重试、进程重建和明确退出各自测试，生命周期细节见客户端生命周期。\n\n个人任务的截止筛选和网站去重见工作台，Date 编码和 vault 格式见本机数据格式，记忆打包与 Account 对接缺口见记忆转出。客户端当前只保存转出契约；权威 Account 版本缺少对应 redeem 路由，不能由分享代码存在推断多因素转出已可用。\n\n## 验证边界\n\n仓库包含领域、存储、加密、聊天、城市贴纸及 UI 测试。本篇依据当前源码编写，本轮未重新构建或启动模拟器，也未核验商店发行版本。历史测试与本次执行结果应分别记录；公网登录、两台真机跨网络通话、后台来电及实际权限恢复仍须走真实路径，参考测试与发布。"},{"product":"星线 App","title":"Android 客户端","url":"/app/android/","text":"# Android 客户端\n\n星线 Android 使用 Kotlin 与 Jetpack Compose 实现聊天、工作台、记忆和个人设置。当前工程版本标记为预览版；本文说明源码组织、构建配置、数据流和平台能力边界。\n\n## 工具链声明\n\n| 配置 | 当前源码值 |\n| --- | --- |\n| applicationId / namespace | cn.thestarline.workbench |\n| 最低系统 | Android 8.0，API 26 |\n| compileSdk / targetSdk | 35 / 35 |\n| Java / Kotlin JVM target | 17 / 17 |\n| Kotlin 与 Compose 编译插件 | 2.0.21 |\n| Android Gradle Plugin | 8.7.3 |\n| Gradle Wrapper | 9.2.1 |\n| versionName / versionCode | 1.0.6-preview / 7 |\n\n这些是仓库配置声明。本轮没有执行构建，Gradle 与 AGP 的兼容性、开发机 JDK 和最终产物版本需在发布环境核对；不能仅凭配置表认定工具链已验证。Compose BOM 声明为 2024.12.01，通话使用 LiveKit Android 2.29.0，扫码使用打包式 ML Kit、CameraX 与 ZXing。\n\n## 入口与页面状态\n\nMainActivity 创建 AppState，接收初始 Intent 和后续深链，再通过 StarlineApp 组合页面。AppState 集中维护当前 tab、page、会话、聊天列表、新事、任务、收藏和记忆状态，使用 Compose 可观察状态与协程更新界面。\n\n页面由 tab:page 状态映射到聊天、账号、任务、浏览器、记忆编辑、转出和扫码等 Composable。主布局处理状态栏、导航栏与输入法 inset，内容宽度上限为 720dp；底部导航按当前页面显示。页面切换和新事卡片过渡会读取系统动画开关。Activity 销毁时调用状态清理，停止所属任务和资源。\n\n修改现有功能时应沿用 AppState 的操作入口和页面状态，避免在独立 Composable 中再建一套会话或持久化状态。新数据模型应明确字段兼容规则；当前大量服务对象使用 JSONObject，新增字段应防止缺失值导致崩溃。\n\n## 网络与认证\n\nServiceApi 在 IO 协程中使用 HttpURLConnection，负责 JSON 请求、媒体上传、受大小限制的下载以及服务端错误转换。普通请求、事件轮询和媒体操作使用不同超时。AppState 的事件循环调用 /events 并刷新当前会话与页面数据。\n\nstarlineChatBaseUrl Gradle 属性生成 BuildConfig.CHAT_BASE_URL，默认指向 https://chat.thestarline.cn。Release 清单禁止明文流量，Debug 清单单独允许本地 HTTP 联调。即使构建属性接受 HTTP，正式包仍受清单限制；测试服务和账号服务应使用同一环境。\n\n登录使用本机 PKCE 事务，打开 Account 返回的验证页面，经 starlineworkbench://chat-auth 回到 App。待处理事务和会话使用本机加密文件保存。配置与安全约束见认证。\n\n## 本机数据与系统能力\n\nLocalData 将任务、收藏、最近访问和偏好保存在私有 SharedPreferences；会话、记忆索引和附件经过 SecureFile 加密写入私有文件。密钥由 Android Keystore 生成，算法为 256 位 AES-GCM，写入先落临时文件并同步后重命名。清单设置 allowBackup=false。任务和普通偏好并未因此变成 AES-GCM 加密数据，数据分类见安全与隐私。\n\n扫码相机由 CameraX 绑定生命周期，ML Kit 在本机解析二维码；也可选择图片或手动输入。实时位置通过 LiveLocationService 前台服务和持续通知维持，城市贴纸识别采用另一条短时定位路径。文件预览使用私有 FileProvider 与临时 URI 授权。权限细节见权限与可访问性。\n\n## 与 iOS 的具体差异\n\n| 范围 | Android 当前行为 |\n| --- | --- |\n| 个人任务 | 待办／已完成／全部，无独立任务搜索与逾期筛选；日期选到当天末尾 |\n| 首页今日待办 | 包含正数到期且不晚于今天末尾的未完成项，实际也包含历史逾期 |\n| 网站去重 | 比较完整 URL 字符串，不复用 iOS 去 fragment、默认端口的规范化 |\n| 记忆主题 | 固定模板和关键词；不调用 iOS FoundationModels |\n| 原生 pending | 保存 verifier/state/到期时间，不持久化 API 基址或换票 ticket |\n| 授权来源 | 当前仅信任正式 Account HTTPS，覆盖聊天地址不会自动放行本地 Account |\n| 普通发送重试 | 未确认 clientId 保存在进程内映射，按账号、聊天范围和具体提交内容绑定 |\n| 记忆转出 | 授权后先选目标 URI，再本机生成文件；无通用导入入口 |\n\n这些差异需要在调用方和测试预期中体现，不能用 iOS 的页面、持久化策略或测试结果代替 Android 验收。任务和浏览器实现见工作台，存储字段与兼容见本机数据格式。\n\nMainActivity 销毁会 dispose 当前 AppState、停止所属协程并释放通话资源；持续位置共享另由前台服务管理。切到后台与 Activity 销毁不同，关闭聊天页也不自动结束位置或通话。普通事件轮询不是后台通知服务，短时重连、进程恢复和锁屏来电需分别验证，见客户端生命周期。\n\n## 开发与验收\n\n在 android-app 目录可执行：\n\n``sh\n./gradlew :app:testDebugUnitTest :app:assembleDebug\n`\n\n设备测试放在 app/src/androidTest，本地单元测试放在 app/src/test。本轮只审阅源码，没有执行以上命令或安装 APK。已有测试文件不表示当前提交已经通过。\n\nAndroid 预览客户端与 iOS 并非所有能力对等：iOS 系统模型主题生成没有移植，Android 使用本机关键词推荐；Rust OpenMLS 加密聊天尚未接入。后台来电推送、真机跨网络音视频、真实相机与完整多因素转出应分别验收，不能由模拟器页面或 active` 信令状态推断完成。参见本地记忆、消息与媒体、测试和发布。"},{"product":"星线 App","title":"客户端启动、恢复与资源生命周期","url":"/app/client-lifecycle/","text":"# 客户端启动、恢复与资源生命周期\n\n客户端生命周期包含本机数据加载、登录事务、已登录会话、增量刷新、消息重试、媒体连接和持续位置共享。它们的恢复条件不同：重新打开 App 不等于重新登录，关闭聊天页不等于结束通话或位置共享，进程内队列也不等于已持久化的离线发件箱。\n\n## 启动入口与状态所有权\n\n| 状态 | iOS 所有者 | Android 所有者 |\n| --- | --- | --- |\n| 本机任务／收藏 | 根视图持有 LocalWorkspaceStore | AppState 中可观察列表与 LocalData |\n| 记忆 | 根视图持有 MemoryStore | AppState.memories/memoryLocked |\n| 聊天 token 与用户 | ChatStore，token 来自 Keychain | AppState，token 来自加密文件 |\n| 原生登录事务 | ChatNativeAuth 与独立 pending 存储 | NativeAuthCoordinator 与加密 pending |\n| 普通事件刷新 | ChatStore.pollingTask | AppState.pollJob |\n| 通话 | ChatCallCoordinator、系统来电桥接 | AppState、LiveKit Room 与轨道任务 |\n| 位置持续采集 | ChatLiveLocationManager | 独立 LiveLocationService |\n\niOS App 初始化 ChatStore 并绑定 CallSystemBridge；根视图清理旧记忆转出和预览临时文件，加载记忆与工作台快照。工作台读取失败会显示保护性错误，而不是空数据首页。随后 .task 先尝试恢复有 ticket 的原生登录，再恢复聊天会话。记忆加载失败有独立错误状态，不应触发卸载或重置。\n\nAndroid MainActivity.onCreate 创建 AppState、处理初始 Intent，再组合页面；onNewIntent 负责已运行时的深链。状态初始化先加载普通本机列表和加密记忆，记忆读取异常会设置 memoryLocked；若已有 token，异步请求 /me 恢复资料、列表和轮询。授权回调与本机数据加载不应相互清除。\n\n## 已登录会话恢复\n\n正常恢复是：读取设备凭据，请求 /me，核对响应仍属于发起请求的 token，设置当前用户和 eventCursor，再刷新会话等快照并启动监听。服务端返回 401 表示凭据无效，客户端清理该聊天会话；断网、超时或 503 不足以证明 token 已失效，应保留凭据并显示错误。\n\niOS restore 在同一 Store 生命周期内只自动执行一次；回到 active 时会调用 resumePendingSignIn 和 recheckSession。后者主要在当前用户为空、Keychain 仍有 token 时恢复，不是每次前台切换都强制重新登录。Android 恢复在 AppState 初始化触发，普通网络操作的 401 清理也会核对发起会话，防止旧响应退出新账号。\n\n设备聊天会话默认有效 24 小时，没有 refresh token 或永久登录保证。Account 浏览器 Cookie 与聊天 token 各自到期。修改 Account 密码没有本机记忆重加密逻辑；是否撤销已有聊天 token 还需按聊天服务自身会话机制判断，不能将 Account 会话撤销自动扩大到所有业务凭据。\n\n## pending 登录：两端的持久化差异\n\n| 阶段 | iOS | Android |\n| --- | --- | --- |\n| 打开浏览器前 | 持久化 verifier、state、expiresAt、API 基址 | 持久化 verifier、state、expiresAt |\n| 回调校验 | 预期 scheme/host、参数唯一、state、期限、API 基址 | 预期 scheme/host、无异常 URL 部件、唯一参数、state、期限 |\n| 换票前 | 把 ticket 写回 pending | 先清除 pending，再在内存中携带 ticket 换票 |\n| 换票中进程终止 | 在 ticket 和 flow 仍有效时可尝试 resume() | 当前没有持久 ticket 的恢复入口，需要重新发起授权 |\n| 成功替换会话 | 先保存 session，再尽力清 pending | 先加密保存新 token，再替换旧账号界面状态 |\n| 取消或旧请求迟到 | generation 和当前 pending 复核 | generation、operation 和 exchanging 复核 |\n\niOS 的 pending expiresAt 与 Android 相同，是协议 Unix 毫秒，不是 Swift Date 默认编码。冷启动恢复仍受一次性票据和服务端期限约束；如果服务端已经消费 ticket 而响应丢失，保存过 ticket 也不代表总能无损重放。网络恢复与服务端幂等能力需分别看待。\n\n两端授权入口信任规则也不同：iOS Debug 在明确使用回环聊天服务时允许回环 Account HTTP；Android 原生授权地址当前只允许正式 Account HTTPS，没有同等 Debug 本地例外。修改聊天 base URL 或转发端口不会自动增加授权来源。登录详细协议见认证，隔离条件见配置参考。\n\n## 普通事件游标与断线重试\n\n| 项目 | iOS | Android |\n| --- | --- | --- |\n| 长轮询 | /events?after=<eventCursor>&wait=20 | /events?after=<cursor>&wait=20 |\n| 客户端请求超时 | 事件 32 秒、普通 JSON 15 秒 | 连接 15 秒；事件读取 35 秒、普通读取 20 秒 |\n| 非 401 失败等待 | 约 3 秒 | 约 4 秒 |\n| 成功响应 | 更新 cursor，再按事件类型刷新相关资源 | cursor 取不倒退的最大值，并刷新聊天、通话及当前相关页面 |\n| 状态期限 | Store 实例中的内存游标 | AppState 实例中的内存游标 |\n\n事件不是完整聊天数据库。iOS 在 message/group_message 后重新读当前会话并标记已读，call_changed 后查当前通话，成员和屏蔽变化触发相应资源重查；Android 每轮刷新聊天，当前是聊天页时刷新消息，新事相关页面时刷新动态。响应迟到时需核对原 token 或当前 thread 版本，避免旧会话覆盖新页面。\n\n普通 cursor、消息 ID 与 MLS sequence 是不同序列。两端普通轮询没有持久化为一份可恢复的消息总账；冷启动先读服务器最新快照和 /me 的游标。iOS 独立 MLS 传输器另有加密 checkpoint 和 sequence 规则，不能把其持久离线能力归到普通聊天界面。协议见MLS与实时与通话。\n\n## 消息发送、失败与队列寿命\n\niOS 为一次发送生成 clientId，在当前账号和会话范围的 pending 列表中显示 sending。收到响应后按服务端消息合并，并删除对应 pending；请求失败则标 failed。若轮询已经收到相同 clientId，迟到的失败不会再显示一条假失败。用户重试 failed 时复用原 clientId 和原正文／媒体引用；退出账号会清空这些内存队列。\n\nAndroid MessageSendIdCoordinator 按 accountId + scopeKind + scopeId + text + mediaId 保存未确认请求的 ID。相同提交在不确定结果后重试时沿用 ID，明确响应成功后清除此映射；账号退出会清空。聊天输入草稿可由 rememberSaveable 管理，但 ID 映射仍是进程内状态，不构成磁盘发件箱。进程被系统销毁后，不应宣称所有待发消息都会自动继续发送。\n\n维护者处理超时应区分：服务端未接受、已接受但响应丢失、消息已同步回来。继续使用原重试入口和 clientId，避免再建一条同内容请求。修改正文或媒体改变请求身份；使用同 ID 提交不同内容会触发服务端冲突。普通消息上限和幂等见消息与媒体。\n\n## 前后台、通话与位置\n\n源码中的普通轮询没有仅因切到后台就自动改成可永久运行的系统服务。iOS／Android 对后台执行各有限制，事件循环存在不表示锁屏时仍能持续联网；当前 Android 也没有完整后台来电推送交付。系统唤醒、媒体后台和一般消息刷新应分别验收。\n\n通话媒体不是某个聊天页面的临时子视图。iOS 记录本机参与的 call ID，恢复 active 状态时避免自动接入另一设备才参与的通话；连接媒体先取短期 token，再连 LiveKit，开启轨道后反复核对连接 attempt 和当前 call。断开会取消订阅任务、释放 Room 引用并恢复音频输出；Android 用媒体 generation 与 call revision 防迟到，断开并 release Room，取消半秒级轨道观察任务。收起通话页面只是隐藏界面，结束需要走相应信令。\n\n位置共享在显式开始后独立于聊天页面。iOS 启用后台位置更新，按至少约五秒间隔向仍共享的范围上传，断网时保留范围并显示最后更新时间；重启后查询 /live-locations/mine 再恢复。Android 前台服务带持续通知，约每 30 秒重查范围，位置上传至少约五秒间隔；权限撤销、无范围或 401 会停止或提示。服务返回 START_NOT_STICKY，不能承诺进程被杀后自动恢复服务。\n\n停止位置采集与删除服务端最后坐标是两件事。Android 通知“结束共享”先停止采集，再尽力向服务器删除并重试；本机退出也会停止服务后尝试远端清理。iOS 退出先停止本机跟踪，但不能仅凭本机退出视为所有远端共享记录已删除。具体权限与位置语义见位置共享。\n\n## 退出与资源检查步骤\n\n1. 取消原生 pending，阻止迟到登录结果覆盖状态；保留个人任务、收藏和记忆。\n2. 停止轮询，释放通话媒体、当前消息缓存和账号界面状态。Android 先清本机，随后尽力远端清理；iOS 尝试服务端注销后清本机。\n3. 成功的 /auth/logout 只撤销当前聊天 session；请求失败时，本机退出仍可能完成，不能声称已撤销服务器凭据。\n4. Activity 销毁时 Android 调用 dispose，取消所属协程和通话资源；独立位置服务按自己的生命周期清理。浏览器、扫码相机与附件预览也需各自释放。\n5. 重开后分别验证本机数据仍在、无效会话需登录、短时离线未抹掉凭据，以及旧请求不写入新账号。\n\n本页依据 iOS StarlineWorkbenchApp、ChatStore、ChatNativeAuth、ChatCallCoordinator、ChatLiveLocation，以及 Android MainActivity、AppState、NativeAuth、MessageSendIdCoordinator、LiveLocationService。本轮仅静态核对，未执行杀进程、后台、跨网或双设备测试；具体回归组合见QA矩阵。"},{"product":"星线 App","title":"个人工作台、任务与网站","url":"/app/workbench/","text":"# 个人工作台、任务与网站\n\n工作台集中展示本机个人任务、网页收藏和最近访问。它不要求先加入企业组织，也没有把任务提交到共享企业后台。iOS 与 Android 共用产品方向，但当前筛选、日期、网址保存和持久化实现存在差异，应按平台理解实际行为。\n\n## 责任与数据边界\n\n| 内容 | iOS | Android |\n| --- | --- | --- |\n| 任务状态 | LocalWorkspaceStore.tasks | AppState.tasks 与 LocalData.tasks() |\n| 收藏 | bookmarks: [SavedSite] | bookmarks JSON 数组 |\n| 最近访问 | recentSites: [SavedSite] | recents JSON 数组 |\n| 落盘方式 | 一份 workspace.json 快照，原子替换 | SharedPreferences 中的 JSON 字符串 |\n| 是否采用记忆库 AES-GCM | 否 | 否 |\n| 账号切换 | 不清除个人工作台数据 | 不清除个人工作台数据 |\n\n上述文件或偏好属于 App 私有存储，但不能据此称为记忆 vault 的加密内容。它们不自动同步到另一台设备，不属于记忆转出 JSON，也不会因为聊天账号退出而删除。设备丢失、清除应用数据或卸载后的恢复不由聊天服务保证。字段和时间编码见本机数据格式。\n\n共享后台中的 /enterprise/* 管理组织、企业成员、频道、企业待办和公告，是独立星企产品的领域。个人任务没有组织 ID、负责人、协作评论、分派、共享或企业审计字段。当前工作台也没有第三方任务自动汇聚；不能由后台路由存在推导出这些前台功能已经可用。\n\n## 任务模型与编辑\n\n| 概念 | iOS 字段 | Android 字段与差异 |\n| --- | --- | --- |\n| 标识 | id: UUID | id: String，新建时生成 UUID |\n| 标题 | title: String，保存前去首尾空白 | title，编辑器限制 160 个 UTF-16 代码单元并 trim |\n| 说明 | notes: String | notes，编辑器限制 2,000 个 UTF-16 代码单元并 trim |\n| 截止 | dueDate: Date?，可选具体日期和时间 | dueAt，Unix 毫秒；缺失或 0 表示未设截止 |\n| 完成 | isCompleted: Bool | isCompleted: Boolean，读取缺省 false |\n| 关联网页 | sourceURL: URL?，有完整输入校验 | 浏览器新建任务时写 sourceURL 和网址备注；任务弹窗不提供独立网址编辑框 |\n| 创建／修改 | createdAt、updatedAt | createdAt；编辑和勾选完成时写 updatedAt，某些初次创建路径可能尚无该键 |\n\niOS 编辑器要求非空标题，允许补充说明、设置或清除截止时间、调整完成状态及关联 HTTP/HTTPS 网站。初次建任务保留创建时间，后续编辑更新修改时间；保存失败时编辑页保持打开，原输入仍可重试。当前 iOS 编辑器没有 Android 对应的 160／2,000 长度截取，不应把两者当统一协议上限。\n\nAndroid 用可滚动弹窗编辑名称、备注和到期日。日期选择器把所选日期设为设备时区的 23:59:59、毫秒为 0；清除后保存 dueAt=0。保存基于原 JSON 的副本修改可见字段，因此浏览器任务已有的 sourceURL 不会仅因打开弹窗而丢失。已存在任务可通过删除确认框移除；取消编辑不提交本次表单。\n\n## 筛选、排序与“今天”\n\n| 行为 | iOS 当前实现 | Android 当前实现 |\n| --- | --- | --- |\n| 全部待办 | isCompleted == false | “待办”同样按完成布尔值过滤 |\n| 已完成 | 只包含已完成项 | 同左 |\n| 包含已完成与未完成 | 无独立“全部”范围 | “全部”范围 |\n| 今天 | 未完成，且截止与设备当前日历为同一天 | 首页按未完成、正数 dueAt 不晚于当天末尾选择，包含以前逾期任务 |\n| 已逾期 | 未完成且 dueDate < now，有独立入口 | 行内可显示逾期颜色，没有独立逾期筛选 |\n| 搜索 | 在当前范围内查标题或说明，使用本地标准字符串匹配 | 当前任务页没有搜索框 |\n| 列表排序 | 截止时间升序，无截止放末尾；截止相同按创建时间倒序 | 保留内存列表顺序；新任务插前，编辑不重新排序 |\n\niOS 的“今天”和“已逾期”可以重叠：今天上午截止而尚未完成的任务同时符合两者。没有截止时间的任务仅出现在全部待办或已完成中。工作台今日任务按截止时间排序，最近访问只预览前三项，逾期待办入口进入相应任务范围；从编辑页保存回来不会重建任务页的搜索和筛选状态。\n\nAndroid 首页最多展示三个上述“今日待办”，另有待处理和网页收藏等计数。当天末尾在 Composable 中通过 remember 计算，长时间停留跨过午夜时不能假定立即重新计算；刷新与跨日行为需要独立回归。其任务页“全部”只是完成状态范围，不表示来自企业或云端的全部任务。\n\n## 收藏和访问记录\n\niOS 的 SavedSite.normalizedURL 会小写 scheme 和 host、去掉 HTTP 80／HTTPS 443 默认端口和 fragment。收藏按规范化 URL 去重：重复收藏更新标题并移到前面，保留既有标识；最近访问按规范化地址合并并只留 30 项。删除收藏和删除历史是两个操作，不会互相代替。\n\nAndroid 以完整 URL 字符串比较收藏和历史：相同字符串的重复收藏替换原位置，最近访问则移到前面并截为 30 项。大小写、默认端口或 fragment 不会自动套用 iOS 的去重规则。网页标题为空时界面可退回显示主机名。两端保存的只是标题、网址与访问元数据，不是网页全文离线副本。\n\n## 从浏览到任务的步骤\n\n1. 在工作台打开网站入口、收藏或最近访问；输入地址时没有 scheme 可按页面逻辑补成 HTTPS。\n2. 确认实际主机和页面加载结果。iOS 对 HTTP 显示继续确认；Android 显示 HTTP 提示，并受 Release 明文网络禁用规则约束。遇到证书错误不能继续放行。\n3. 选择收藏，或选择“存为任务”。iOS 打开任务编辑器并预填页面标题和网址，由用户保存；Android 在加载成功后直接创建任务，把网址同时写入备注与 sourceURL。\n4. 在任务列表调整内容或完成状态。网页任务只是保留来源，不会解析页面待办、登录 Cookie 或企业任务状态。\n\niOS 普通浏览器使用 WKWebView，账号确认模式使用独立非持久网站数据存储并限制为 Account HTTPS 来源；原生登录另走系统授权浏览器。Android 工作台使用 WebView，启用 JavaScript 和 DOM storage，禁止混合内容，SSL 错误调用取消；离开其组合界面时停止加载并销毁 WebView。系统浏览器中的账号身份不应与工作台网页数据混为一谈。\n\n## 正常与失败结果\n\n| 场景 | 应如何理解 |\n| --- | --- |\n| 标题为空 | 两端阻止确认保存；不是创建一项无名任务 |\n| iOS 关联 URL 不是完整 HTTP/HTTPS | 留在编辑器并显示地址错误 |\n| iOS 快照读取失败 | 根视图提示本机数据无法读取；不使用空快照覆盖 |\n| iOS 写文件失败 | Store 先持久化再替换内存，编辑保留输入 |\n| Android 普通数组解析失败 | LocalData.array 对非加密数据退回空数组，没有记忆库的锁定保护；不可据此宣称旧文件已经修复 |\n| Android 内存已修改但持久化失败 | 当前操作不是跨内存与偏好的数据库事务；界面状态不能作为磁盘写入回执 |\n| 网络失败 | 已有个人任务和收藏仍属于本机数据；网页和聊天的网络错误分别处理 |\n\n扩展工作台时，先确定数据是否仍属个人本机；再明确字段、默认值、筛选含义、写入失败策略和旧数据兼容。若新增协作，应另接服务端权限，而不是在个人 JSON 增加一个“企业”标签就宣称支持团队。改变日期规则还需检查时区、午夜、无截止、已完成和逾期的交集。\n\n## 源码与静态核验\n\niOS 入口为 Sources/Domain/WorkTask.swift、SavedSite.swift、TaskQuery.swift、Data/LocalWorkspaceStore.swift 及 Features/Tasks、Features/Browser；Android 入口为 WorkScreens.kt、AppState.kt 与 AppData.kt。已有领域和存储测试可供回归选用，详见QA矩阵。本篇仅完成源码与文档静态核对，没有执行构建、模拟器、真实网页或产品流程。"},{"product":"星线 App","title":"本地记忆与加密存储","url":"/app/local-memory/","text":"# 本地记忆与加密存储\n\n“记忆”用于保存个人文字、结构化选项、图片与文件，核心读写、搜索和模板推荐在设备本机完成，不要求先登录。账号退出不会自动清除记忆；当前没有跨设备自动同步或通用导入恢复能力。\n\n## 模型与数据流\n\niOS 的 MemoryEntry 包含 UUID、标题、正文、附件数组、创建和更新时间、MemoryKind、字段值字典以及可选的 generatedSchema。附件元数据记录标识、原名称、存储名称、字节数与图片标记。视图将编辑结果交给 MemoryStore.save，存储层清理字段、加密新增附件、原子写索引，成功后再删除不再引用的旧附件；失败时清理本次新增文件。\n\nAndroid 使用 JSON 记录，常见键包括 id、title、body、kind、时间戳及 attachments，模板字段直接位于记录对象中。MemoryScreens 处理编辑和附件选择，AppState 更新记录列表，LocalData 写入加密索引。两端转出容器相同，但内部记录字段与时间表示不同，不应假设可以直接互换。\n\n固定主题包括自由记录、医疗健康、签证、证件、预约和订阅；宠物接种有专用字段。签证目的地使用本地国家地区目录，类别引导不自动推算有效期。医疗字段见医疗健康记录。\n\n## 限制与兼容\n\n| 内容 | 当前约束 |\n| --- | --- |\n| 标题 | 最多 80 字符，空标题可由正文首行或模板名补全 |\n| 正文 | 最多 20,000 字符 |\n| 单个附件 | 最多 20 × 1024 × 1024 字节 |\n| 每条附件数 | 最多 20 个 |\n| 每条附件总量 | 最多 50 × 1024 × 1024 字节 |\n| 一般字段 | 最多 200 字符 |\n| 诊断结果、用药与医嘱 | 各最多 2,000 字符 |\n\n这些限制由各端原生字符串截取实现，不能理解为跨语言一致的字节长度。iOS 存储层拒绝空附件；Android 文档选择路径允许 0 字节附件，扩展时需保留这一实现差异。仅选择模板但没有标题、正文、字段或附件，不会保存空记忆。\n\n旧版兼容由两端分别处理：iOS 缺少主题字段时按自由记录解码，并支持旧明文库迁移；Android 保留旧键别名，同主题编辑只替换当前可见字段，保留未展示的历史字段。切换主题可能清空原主题选项，界面先要求确认。\n\n## 加密与错误保护\n\niOS 采用 CryptoKit AES-GCM，加密文件以 SLMV2 标识版本；索引与每个附件使用不同 purpose 作为认证附加数据。随机 256 位密钥放入 Keychain，属性为设备解锁时可用且仅限本设备。索引与附件使用 complete 文件保护。旧明文数据先在暂存目录加密并回读核验，再移入正式 vault，随后清理旧文件。\n\nAndroid 采用 Android Keystore 生成的 256 位 AES 密钥，以 AES/GCM/NoPadding 加密索引和附件，文件保存 IV 与密文，GCM 标签为 128 位；写入采用临时文件、同步和重命名。两端文件格式并不相同。\n\n本机记忆使用设备级随机密钥，不由 Account 密码派生，也不按聊天账号隔离。同一安装中的不同聊天账号访问同一本机记忆库；修改 Account 密码、退出或切换聊天账号，都不会删除记忆或重新加密其内容。账号身份验证与设备上的记忆存储是独立边界。\n\n索引损坏、认证失败或密钥不可用时，iOS 设置 loadError，Android 设置 memoryLocked，均禁止覆盖原记忆。不能把读取失败当成一个可写的空库，也不应引导用户通过卸载处理读取错误。卸载或清除应用数据会失去私有记录；密钥与文件需共同可用。\n\n## 搜索与本机推荐\n\n搜索覆盖标题、正文、主题、结构化字段值和附件名。空白分隔的多个词必须全部命中，但可命中不同字段；不读取附件正文，不是 OCR 或向量检索。\n\n推荐最多读取 2,000 字符输入。Android 使用关键词和固定字段；iOS 在 iOS 26 及以上、系统模型可用时尝试 FoundationModels 生成主题与字段，失败则回退到本机基础选项。明确医疗内容直接采用稳定医疗字段，宠物疫苗保留专用识别。模型结果是待用户确认的记录结构，不会自动填入未提供的个人信息。\n\n聊天“保存到记忆”提取消息正文；iOS 明确排除位置消息，并标记未捕获的媒体。不能将保存文字理解为自动备份整段聊天或附件。\n\n## 模板字段与识别后的编辑\n\n| 模板 | 固定字段 ID |\n| --- | --- |\n| 医疗健康 | healthRecordType, healthSubject, healthRecordDate, medicalInstitution, departmentDoctor, medicalResult, medicationInstructions, followUpOn |\n| 签证 | destination, visaType, status, entries, validFrom, validUntil, reference |\n| 证件 | documentType, number, issuedOn, expiresOn |\n| 预约 | subject, location, startsAt, status |\n| 订阅 | service, plan, amount, renewsOn, status |\n| 宠物接种 | pet_species, pet_name, pet_life_stage, pet_age, dose_number, vaccine_valency, vaccine_name, vaccinated_on, next_vaccination |\n| 通用推荐 | subject, date, place, status, remark |\n\n自由记录没有必填模板字段。签证的 destination 保存目的地值，界面可解析为国家或地区名称；签证类型和有效期由用户按实际文件确认，类型候选不会替用户推算截止日期。预约的 startsAt 含本地时间，日期型字段只保存日历日期。订阅金额仍为用户输入字符串，没有币种换算或自动扣款。宠物针次、联数与下次日期也只是记录，不构成接种计划或提醒。\n\niOS 手动医疗模板使用 kind=health；从识别建议应用生成 schema 时，编辑器使用 kind=note 加 generatedSchema 保存主题与选项。需要寻找全部医疗主题时不能只筛原始 kind。Android 的固定医疗模板保持 health，开放主题则使用 suggested 与 suggestedCategory。原始结构和字段类型见本机数据格式。\n\n新建 iOS 自由记忆时，至少两个字符输入会触发延迟建议：少于五字符等待约 2.5 秒，其他约 1.5 秒；自动建议有次数和输入状态保护，页面离开会取消请求。建议出现不会保存记录，用户仍需确认选项并保存；修改输入后的迟到识别结果会被核对后丢弃。Android 通过按钮按文字推荐当前固定或通用选项。\n\niOS 重新应用生成 schema 时，按稳定字段 ID、相同显示标题及少量签证旧标题别名搬移已有值；这不是任意语义迁移。手动更换类型会确认清除原选项，正文和附件保留。Android 同主题编辑保留未展示旧键，更换类型则清理相关选项。业务扩展应稳定字段 ID，分别测试“清空字段”“换类型”“重选建议”和“编辑旧记录”，不能只验证新建成功。\n\n附件预览需临时解密为明文。iOS 使用受保护预览文件并在结束后清理；Android 在缓存中写明文，通过 FileProvider 临时读权限交给用户选择的查看应用，离开详情时尽力删除预览文件。临时文件清理不撤回外部应用已经复制的内容。图片、文件的读取和导出也会占内存，单条附件上限不等于任意数量记录都能一次处理。\n\n## 转出与验证\n\n以下描述客户端已编写的转出实现契约。当前作为维护基线的 Account 主目录版本尚缺 /api/memory-export/redeem 路由，跨服务真实闭环仍待核对和验收，不能将客户端入口或本地文件生成代码视为当前可用的完整转出服务。\n\n按该契约，转出需要登录并通过 Account 的一次性许可流程。客户端发送的是验证状态和票据，记忆正文与附件由本机生成；具体身份校验见认证。本地输出格式定义为明文 JSON，容器含 format=cn.thestarline.memory-export、version=1、entries；每项含原记录和按附件 ID 编码的原始附件数据。\n\niOS 在验证成功后创建受保护临时文件，交给系统分享页，并在结束后清理；Android 在授权和保存位置选择后生成临时文件，流式复制到目标 URI，取消或失败时清理未完成输出。转出文件中的 Base64 不是加密，保存位置和接收方由用户选择。\n\n仓库测试覆盖重开、字段限制、损坏保护、加密附件与完整转出等路径。本轮未重跑测试，也未完成真实账号多因素转出验收；完整容器、平台取消阶段和清理限制见记忆转出，回归范围见测试与安全与隐私。源码主要位于 iOS MemoryStore/MemoryLocalRecognizer/MemoryEditorView 与 Android MemoryTemplates/MemoryScreens/AppData。"},{"product":"星线 App","title":"双端本机数据格式与兼容","url":"/app/local-data-formats/","text":"# 双端本机数据格式与兼容\n\n本页记录任务、网站、记忆、附件和本机授权状态在当前源码中的形状。示例均为合成值，只用于解释字段；不提供读取用户私有数据或导出密钥的操作。原生磁盘格式是实现细节，没有通用跨平台导入承诺，修改前应保留旧数据兼容和失败保护。\n\n## 数据分类\n\n| 类别 | iOS | Android |\n| --- | --- | --- |\n| 工作台 | workspace.json，JSON 对象快照 | SharedPreferences 名称 starline_settings 中的 JSON 字符串 |\n| 记忆索引 | 逻辑文件 vault/index.enc，解密后为记录数组 | memories.bin，解密后为 JSON 数组 |\n| 记忆附件 | vault/files/<UUID>.enc | memory-<id>.bin |\n| 聊天会话 | 独立 Keychain 项 | session.bin，解密后为会话字符串 |\n| 原生登录 pending | Keychain 内 JSON | native-auth.bin，解密后为 JSON |\n| 开关与默认页 | UserDefaults / @AppStorage | 同一个私有 SharedPreferences |\n\n这里的文件名是程序中的逻辑名称，不表示部署目录。任务、收藏和普通偏好不使用记忆 AES-GCM 容器；聊天 token、记忆正文和附件不能写回普通偏好或日志。记忆密钥为设备级随机值，同一安装中的聊天账号共用记忆库；改 Account 密码、退出或切换账号不会删除或重加密记忆。\n\n## iOS 工作台快照\n\nLocalWorkspaceStore.Snapshot 的三个顶层键是 tasks、bookmarks、recentSites。tasks 解码时必需；旧快照缺少后两项时按空数组兼容。当前没有单独的 schemaVersion。示例：\n\n``json\n{\n  \"tasks\": [{\n    \"id\": \"11111111-1111-4111-8111-111111111111\",\n    \"title\": \"示例任务\",\n    \"notes\": \"合成说明\",\n    \"dueDate\": 3600,\n    \"sourceURL\": \"https://example.test/task\",\n    \"isCompleted\": false,\n    \"createdAt\": 0,\n    \"updatedAt\": 0\n  }],\n  \"bookmarks\": [{\n    \"id\": \"22222222-2222-4222-8222-222222222222\",\n    \"title\": \"示例网站\",\n    \"url\": \"https://example.test/\",\n    \"createdAt\": 0\n  }],\n  \"recentSites\": []\n}\n`\n\n默认 JSONEncoder 把 Swift Date 编为从 2001-01-01 00:00:00 UTC 起算的秒数，可含小数；这里的 0 不是 1970 年，3600 也不是 Unix 毫秒。URL 编为字符串，UUID 编为字符串。可选 dueDate、sourceURL 为空时通常省略键。后端聊天时间使用 Unix 毫秒，不能把相同数字直接复制到本机 Date 字段。\n\n快照一次编码三个数组，.atomic 写文件成功后再替换 Store 内存。损坏或缺少必需字段会抛出读取错误，根界面不会把它当新安装。它没有单记录日志、数据库事务回放或云端副本。具体筛选与网址规范化见工作台。\n\n## iOS 记忆明文模型\n\n加密索引解开后是 [MemoryEntry]。医疗示例只填写一个自定义输入值：\n\n`json\n{\n  \"id\": \"33333333-3333-4333-8333-333333333333\",\n  \"title\": \"合成医疗记录\",\n  \"text\": \"用于格式说明\",\n  \"attachments\": [{\n    \"id\": \"44444444-4444-4444-8444-444444444444\",\n    \"name\": \"sample.txt\",\n    \"storedName\": \"44444444-4444-4444-8444-444444444444\",\n    \"byteCount\": 6,\n    \"isImage\": false\n  }],\n  \"createdAt\": 0,\n  \"updatedAt\": 0,\n  \"kind\": \"health\",\n  \"fields\": {\"healthSubject\": \"合成对象\"}\n}\n`\n\n| 字段 | 类型与语义 |\n| --- | --- |\n| id | UUID，编辑保持不变 |\n| title、text | 字符串；正文名是 text，不能套用 Android 的 body |\n| attachments | 元数据数组，附件正文另存；byteCount 是原始字节数 |\n| createdAt、updatedAt | Swift Date 默认参考日期秒数 |\n| kind | note/health/visa/document/appointment/subscription |\n| fields | 字符串到字符串字典，日期和数字型表单值仍是字符串 |\n| generatedSchema | 可选主题对象：category、icon、fields；每个生成字段含 id/title/type/options |\n\n生成字段 type 可为 text/choice/date/dateTime/number。表单日期为 yyyy-MM-dd，带时间为 yyyy-MM-dd'T'HH:mm，不含时区；不能等同为 UTC 瞬间。旧记录缺少 kind 时取 note，缺少 fields 时取空字典；旧生成 schema 缺 icon 时按主题选择兼容图标。保存层只接受当前模板或生成 schema 允许的字段 ID，因此未知字段不会自动透传到新 iOS 记录。\n\n## iOS vault 封装与密钥项\n\n当前容器是 ASCII SLMV2 头加 CryptoKit AES.GCM.SealedBox.combined。combined 含 nonce、密文与认证标签；正文与索引不采用 JSON 内 Base64 封装。索引与附件使用同一随机 256 位密钥，但附加认证数据 AAD 分别是 UTF-8 的 index 与 attachment:<附件UUID的uuidString>，UUID 的具体字符串必须与保存时一致。换附件名不改变其绑定 ID；拿另一附件的密文替换而不匹配 purpose 会认证失败。\n\nKeychain service 为 cn.thestarline.workbench.memory，account 为 device-vault-v2，属性为 WhenUnlockedThisDeviceOnly。这些名字不是密钥值，也没有基于账号密码的密钥推导或在线找回。正常写入使用 complete 文件保护；读取需设备安全存储与文件共同可用。文件头提供格式识别，真正的内容完整性由 GCM 认证负责。\n\n## Android JSON 与加密容器\n\n工作台偏好键 tasks/bookmarks/recents 对应 JSON 数组字符串。任务字段常见为 id/title/notes/isCompleted/createdAt，编辑时增加 dueAt/updatedAt；网址任务还含 sourceURL。日期是 System.currentTimeMillis() 的 Unix 毫秒，dueAt=0 或缺失表示无到期。收藏和历史含 id/title/url/createdAt。数组的元素是 JSON 对象，不是 Swift Snapshot。\n\nAndroid 医疗记忆的真实形状为：\n\n`json\n{\n  \"id\": \"55555555-5555-4555-8555-555555555555\",\n  \"title\": \"合成医疗记录\",\n  \"body\": \"用于格式说明\",\n  \"kind\": \"health\",\n  \"createdAt\": 978307200000,\n  \"updatedAt\": 978307200000,\n  \"healthSubject\": \"合成对象\",\n  \"attachments\": [{\n    \"id\": \"66666666-6666-4666-8666-666666666666\",\n    \"name\": \"sample.txt\",\n    \"mimeType\": \"text/plain\",\n    \"size\": 6\n  }]\n}\n`\n\n正文为 body，模板字段直接位于记录对象中，没有 iOS 的 fields 外层。类型包含 general/pet/health/visa/document/appointment/subscription，本机通用推荐使用 suggested 加 suggestedCategory；旧 travel/calendar 仍可显示。附件使用 size、mimeType，没有 iOS 的 byteCount/isImage/storedName。JSONObject 的键顺序不是契约，未知历史键可能存在，不能按示例重建并丢弃其他字段。\n\nSecureFile 从 Android Keystore 读取或生成别名 starline_android_data_v1 的 AES-256 密钥，使用 AES/GCM/NoPadding。磁盘字节为 IV || ciphertext-and-tag，读取按前 12 字节 IV、128 位 GCM 标签处理，至少 28 字节；当前没有 SLMV2 头，也没有 updateAAD 的索引／附件 purpose 绑定。格式上不与 iOS 互通，不能通过换后缀或复用 iOS key 读取。\n\n写入先保存到同名 .tmp 文件，调用文件描述符同步，再重命名替换。这个流程不意味着多份附件与索引构成完整数据库事务。加密数组读失败会抛“原文件已保留”，AppState 将记忆设为 locked；普通工作台数组解析失败则回退空数组，两者保护级别不同。\n\n## 偏好、会话与 pending\n\n| 内容 | iOS 键或结构 | Android 键或结构 |\n| --- | --- | --- |\n| 默认页面 | workbench.defaultTab | default_tab |\n| 底部记忆入口 | workbench.memoryInTabBar | show_memory_tab |\n| 页面扫码开关 | workbench.scanOnChat/scanOnHome/scanOnProfile | scan_on_chat/scan_on_work/scan_on_profile |\n| 聊天安全存储 | service cn.thestarline.workbench.chat；account device-session | 加密 session.bin |\n| 登录 pending | codeVerifier/state/expiresAt/apiBaseURL/ticket? | verifier/state/expiresAt |\n\n聊天 Keychain 的可访问属性为 AfterFirstUnlockThisDeviceOnly，与记忆密钥的 WhenUnlocked 不同。pending 的 expiresAt 两端都是协议 Unix 毫秒，不能套用 Swift Date 的默认 JSON 日期规则。Android 不持久化换票 ticket，也没有 pending API 基址字段；恢复边界详见客户端生命周期。本页不展示任何实际 token、verifier 或密钥。\n\n## 旧数据迁移与失败处理\n\niOS 检测旧 memories.json 时先加载旧记录，在暂存 vault 中逐附件加密并回读验证，再写入并验证加密索引，随后移动成正式 vault，最后清理旧明文。旧附件 storedName 必须是安全单段文件名，不能包含路径穿越。清理旧明文失败有独立错误，不应描述为所有数据都已安全清理。\n\n已有加密索引时不会为了读取失败而生成新密钥；出现无有效索引的残留 vault、认证失败或缺失密钥会留下错误状态，禁止覆盖。附件删除使用尽力清理，不能从索引删除成功推断闪存物理擦除。Android 兼容宠物和签证旧字段别名；同主题编辑保留未展示旧键，切换主题则明确替换相关字段。原子索引写入、旧格式解析和字段迁移需分别测试。\n\n## 解析与扩展检查顺序\n\n1. 先确认数据类别。一个带有相同记录标题的工作台任务和记忆没有共享 ID 或自动关联；一个 Base64 字符串也不能据此判断是密钥、附件还是密文。按容器与字段定义解释，不猜测内容。\n2. 确认时间单位、基准和是否含时区。Swift 快照参考日期秒数、Android Unix 毫秒、协议 expiresAt 和表单日期分属不同规则。转换测试至少覆盖参考日期、非整秒、缺失截止、设备时区变化和日期型空值。\n3. 确认空值策略。iOS 的可选键可省略，但必需数组或 UUID 损坏会使解码失败；Android optString/optLong/optBoolean 可能给出空串、0 或 false。缺键不应自动解释为用户主动清空，也不能用默认值覆盖已经读错的数据。\n4. 确认元数据与文件一致。附件数、ID 和原字节大小应吻合；扩展名是名称信息，不能替代 MIME、图片解码或 GCM 认证。转出包附带的原始字节需要按记录关联；磁盘 vault 只靠元数据不能恢复丢失密钥。\n5. 用独立合成夹具验证旧版读取、新版保存、进程重开和错误保护。添加字段时明确默认值和未知字段保留策略，不能将 Android 的宽松 JSON 行为推到 Swift Codable；改变 AAD 或密钥别名须有明确版本迁移，不能只改字符串。\n\n检查成功也只说明该类夹具符合当前解析器，不构成通用导入器、跨端转换器或断电恢复保证。当前没有自动修复受损 vault 的流程；应保留原文件和可复核错误，避免直接编辑加密字节、复制安全存储、生成新密钥或用空库覆盖作为修复。\n\n代码入口包括 iOS LocalWorkspaceStore、MemoryStore、MemoryVaultCrypto、MemoryGeneratedSchema 与 Android AppData、MemoryScreens、MemoryTemplates`。2026-10-23 本轮仅静态核对，未读取用户磁盘数据、运行迁移或执行产品测试。"},{"product":"星线 App","title":"记忆转出容器、许可与失败处理","url":"/app/memory-export/","text":"# 记忆转出容器、许可与失败处理\n\n客户端已经编写记忆转出的许可桥接和本机打包代码。当前作为维护基线的 Account 主目录源码没有对应记忆转出模块与 /api/memory-export/redeem 路由，因此本页描述实现契约与本机文件格式，不代表当前跨服务转出已经可用。App 工作树中的历史 Account 副本不能代替权威 Account 的运行版本与真实验收。\n\n## 转出包含什么\n\n转出目标是当前安装的本机记忆列表及其原始附件。它不包含工作台任务、网页收藏、浏览器 Cookie、聊天历史、聊天 token、MLS 私钥或设备 vault 密钥。记忆中用户自己录入的敏感信息仍会作为记录原文进入文件。\n\n本机记忆不按聊天账号分库，同一安装中不同账号看到同一库。Account 许可验证的是当前账号完成了额外验证，不能据此证明每条本机记忆属于该账号，也不是为该账号分发某份云备份。改 Account 密码、退出或切换账号不会删除或重加密记忆；设备密钥和账号凭据的责任见本地记忆。\n\n## 外层 JSON 契约\n\n| 字段 | 类型 | 语义 |\n| --- | --- | --- |\n| format | 字符串 | 固定 cn.thestarline.memory-export |\n| version | 整数 | 当前为 1，表示转出容器版本 |\n| entries | 数组 | 每条转出记录；不是按用户 ID 分组 |\n| entries[].record | 对象 | 该平台的完整记录数据 |\n| entries[].attachmentData | 对象 | 附件 ID 到原始字节标准 Base64 字符串的映射 |\n\n下面是一个完整的合成 Android 容器，附件明文仅为六个 ASCII 字节 sample：\n\n``json\n{\n  \"format\": \"cn.thestarline.memory-export\",\n  \"version\": 1,\n  \"entries\": [{\n    \"record\": {\n      \"id\": \"11111111-1111-4111-8111-111111111111\",\n      \"title\": \"格式示例\",\n      \"body\": \"合成说明\",\n      \"kind\": \"general\",\n      \"createdAt\": 978307200000,\n      \"updatedAt\": 978307200000,\n      \"attachments\": [{\n        \"id\": \"22222222-2222-4222-8222-222222222222\",\n        \"name\": \"sample.txt\",\n        \"mimeType\": \"text/plain\",\n        \"size\": 6\n      }]\n    },\n    \"attachmentData\": {\n      \"22222222-2222-4222-8222-222222222222\": \"c2FtcGxl\"\n    }\n  }]\n}\n`\n\n容器没有创建时间、平台名称、账号归属、签名、文件摘要、加密算法或自动恢复指令字段。version=1 也不表示 iOS 与 Android 内层记录完全一样。JSON 对象键顺序不能用于判断相同内容；iOS 对逐条编码对象启用 sortedKeys，Android 保留原记录 JSON 字符串，不能依赖字节级排序一致。\n\n## 平台内层差异\n\n| 概念 | iOS record | Android record |\n| --- | --- | --- |\n| 正文 | text | body |\n| 自由记录类型 | kind=note | kind=general |\n| 模板字段 | fields 字符串字典 | 直接在记录顶层保存各字段 |\n| 动态主题 | 可选 generatedSchema，含主题、图标及字段定义 | 固定模板，或 suggestedCategory 与通用字段 |\n| 创建与修改时间 | Date 默认编码：2001-01-01 起算秒，可含小数 | Unix 毫秒 |\n| 附件元数据 | id/name/storedName/byteCount/isImage | id/name/mimeType/size |\n| 历史未知键 | 按 Codable 已知模型编码 | 原 JSON 可保留未展示的历史键 |\n\n表单日期如 followUpOn 仍是 yyyy-MM-dd 字符串，预约时间如 startsAt 为不带时区的本地日期时间字符串；它们不是 createdAt 的另一种写法。iOS 附件映射键使用 UUID.uuidString，应与该 record 的附件标识关联。Android 对同一记录的附件 ID 去重后写映射。记录级附件清单与 attachmentData 需要一起检查，不能仅看文件名判断附件是否完整。\n\n打包读取的是通过本机密钥解开的原始附件，文件中的 Base64 不再保留原 vault 加密层。它不是客户端缓存图、缩略图或服务器媒体链接。两端格式细节和示例见本机数据格式。\n\n## 许可桥接的请求步骤\n\n1. 用户从“设置／数据与隐私”发起；客户端要求已登录且记忆可读取，Android 还阻止空列表开始。\n2. 客户端携带聊天 Bearer token 请求 GET /memory-export/start。聊天服务要求身份桥接配置可用，创建绑定当前用户的 state 和服务端 verifier，本地 flow 有效五分钟。\n3. 响应含 url、state；URL 指向 Account 的 /memory-export，参数包括 state、code_challenge、client_id。客户端预期 Account 完成主验证与邮箱二次验证，随后返回 starlineworkbench://memory-export 的 state 与一次性 ticket。这部分需要在权威 Account 版本完成对接。\n4. 客户端将回调 state 与本机操作匹配，再调用 POST /memory-export/exchange，JSON 仅含 state、ticket。\n5. 聊天服务消费匹配当前用户的 flow，以服务端机密客户端认证请求 Account /api/memory-export/redeem，发送 state、ticket 和保存的 verifier，并检查返回用户与当前 Account subject 一致。\n6. 客户端只有收到 authorized=true 才进入本机打包或保存位置选择阶段。记忆正文和附件不在这些许可请求中上传。\n\n客户端没有 Account 机密客户端 secret，不能跳过兑换、将普通登录 ticket 改作转出票据或因网页显示成功而直接生成文件。聊天服务先消费本地 flow 再访问 redeem，后续失败不能假设同一 flow 还可重复使用，应重新开始许可流程。当前缺失 Account 对接意味着文件格式单测通过仍不足以完成第 3–6 步。\n\n## iOS：验证后生成，再交系统分享\n\niOS DataPrivacyView.exportMemories 等待 ChatStore.authorizeMemoryExport。系统授权会话返回后，代码核对 state、原 token 与用户，再向聊天服务兑换许可。成功后 MemoryExportDocument.write 创建带随机后缀的临时目录，将其排除系统备份，并创建 complete 文件保护的“星线记忆.json”。\n\n写入先输出容器头，再逐条编码 record；每条先从 MemoryStore 解密其全部附件为 Data 字典，JSONEncoder 将 Data 编成 Base64。完成后同步文件，再交 UIActivityViewController。代码避免先把整个输出拼成一段巨大字符串，但单条的附件和编码结果仍占内存，不是常量内存的备份引擎。\n\n验证取消或失败发生在写文件之前。系统分享页出现时，临时明文文件已经生成；取消分享只结束该次分享，不能描述为“从未生成过明文”。分享完成或页关闭时清理临时目录，下次启动也会清理残留的转出目录。清理使用尽力删除，不保证收件应用或用户已保存副本同时消失。\n\n## Android：授权、选位置、打包复制\n\nAndroid 的操作阶段为 IDLE → STARTING → VERIFYING → EXCHANGING → READY → CHOOSING → SAVING → COMPLETE。状态对象绑定启动时的 token 和 userId，并用 operation 序号阻止旧操作继续。回调 state 不属于当前等待操作时忽略；账号切换、退出、离开转出页或主动取消都会使旧许可失效。\n\nREADY 后通过 CreateDocument(application/json) 请求用户选保存位置。没有选择目标 URI 就取消，不调用本机 writer。选择后捕获当前记录 JSON 与附件 ID，后台逐条写临时容器，再以 64 KiB 缓冲复制到目标 URI。写入记录、附件与复制循环之间都会检查当前协程、账号和许可仍有效。完成后才显示已保存数量并使本机许可失效。\n\nAndroid writer 对缺失附件或无效 ID 抛错，防止静默输出缺附件的“成功”文件；失败或取消会删临时目录，并尽力通过 DocumentsContract 删除未完成目标文档。第三方文档提供者可能拒绝删除，因此清理是尝试，不是对任意云盘的无条件保证。成功后临时目录在 finally 中删除，启动也清理旧残留。\n\n## 失败结果与排查\n\n| 场景 | 当前含义与处理 |\n| --- | --- |\n| 未登录、账号资料未恢复 | 先完成正常聊天登录，不用本机伪造身份 |\n| vault 损坏或缺密钥 | 原数据保护锁仍生效，转出不能绕过解密失败 |\n| 开始接口不可用 | 检查桥接配置和当前 Account 对接，不能只重试分享页 |\n| state、账号或 ticket 不匹配 | 当前操作无效，重新完整验证 |\n| redeem 缺失、失败或超时 | 无有效许可；当前基线确有缺口，不标“已转出” |\n| 某附件读取失败 | 整次 writer 失败并尝试清理，不跳过该附件冒充完整 |\n| 目标位置不可写、空间不足 | 保留源记忆，报告保存失败，重新选择位置 |\n| 进程在验证或写入时被终止 | 本轮没有断电／杀进程验收；不能假设自动续传或目标文件完整 |\n\n正常路径也需检查 JSON 可解析、entries 数量、每条元数据和附件映射的关系，以及原始附件字节。检查使用合成资料，不把真实记录、票据、verifier 或输出全文写入公开报告。Base64 长度约为原始字节数的 4/3，另有 JSON 元数据开销；足够的原附件空间不保证目标空间也足够。\n\n## 明文、换机与恢复边界\n\n输出 JSON 可直接被能读到文件的程序解析，Base64 是编码而非加密。保存到外部文档提供者或发送给其他应用后，接收方的权限、同步、保留和删除策略由相应服务决定；App 清理临时文件不能撤回那些副本。源码没有为转出包设置密码、接收人密钥或自动过期机制。\n\n当前没有把该文件导回 App 的通用入口，也没有自动云同步、自动恢复、冲突合并或跨端 schema 转换。因此“转出原始资料”不等于“换机后可在 App 恢复”。复制加密 vault 文件也不能代替迁移：文件还依赖设备安全存储中的密钥。卸载前应先确认需要保留的资料已安全取得，不能仅凭一份文件名或未完成的许可页判断备份可用。\n\n## 源码依据与本轮范围\n\n客户端实现位于 iOS Features/Profile/MemoryExportDocument.swift、ProfileView.swift、ChatStore.authorizeMemoryExport 和 Android MemoryExport.kt、MemoryExportDocument.kt；聊天侧为 src/server.mjs 的 memory-export 路由与 database.mjs` 的五分钟 flow。Account 能力按当前维护主目录核对。本轮只审阅这些源码和文档，未调用许可 API、生成真实数据包、运行模拟器或执行多因素流程；后续验收见QA矩阵与协议操作示例。"},{"product":"星线 App","title":"医疗健康记录","url":"/app/medical-records/","text":"# 医疗健康记录\n\n医疗健康记录是本地记忆的一种结构化模板，用于整理就诊、检查检验、用药、疫苗与复诊资料。它沿用本机加密、附件、编辑、搜索与转出机制，内容由用户输入和确认。\n\n## 模型与字段\n\n手动选择固定模板时，两端使用稳定类型标识 health。iOS 在 MemoryKind.health 中定义字段，Android 在 MemoryTemplates 中定义对应模板，八个字段 ID 保持一致。iOS 若应用识别生成的医疗 schema，编辑器会以 kind=note 加 generatedSchema 保存主题与固定字段；程序不能仅按 kind=health 判断所有医疗主题。\n\n| 字段 ID | 显示名称 | 输入与限制 |\n| --- | --- | --- |\n| healthRecordType | 记录类型 | 就诊、检查检验、用药、疫苗接种、其他 |\n| healthSubject | 记录对象 | 文本，最多 200 字符 |\n| healthRecordDate | 记录日期 | 可选日期 |\n| medicalInstitution | 医疗机构 | 文本，最多 200 字符 |\n| departmentDoctor | 科室与医生 | 文本，最多 200 字符 |\n| medicalResult | 诊断／检查结果 | 多行文本，最多 2,000 字符 |\n| medicationInstructions | 用药与医嘱 | 多行文本，最多 2,000 字符 |\n| followUpOn | 复诊日期 | 可选日期 |\n\n标题、正文和附件仍是独立内容：标题最多 80 字符，正文最多 20,000 字符；单附件最多 20 MiB，每条最多 20 个、合计 50 MiB。日期按明确格式保存，未选择日期时保持为空，选择模板不会自动把当天写成记录或复诊日期。\n\n## 输入、保存与检索\n\n用户进入“记忆”后新建或编辑记录，选择“医疗健康记录”，填写所需字段并添加图片或文件，再明确保存。字段均可留空；仅选中模板不会生成空记录。长文本编辑器保留换行；缩写、检验指标名称和英文原文按输入保存，不进行语义改写。\n\niOS 将字段放入 MemoryEntry.fields，保存时仅接受模板允许的字段 ID，并清理首尾空白、执行长度限制。Android 将字段放入记录 JSON，同主题编辑会替换可见字段，同时保留未展示的旧键和未知字段，防止编辑新表单时丢失历史内容。两端都沿用记忆加密存储；加密失败或原库无法读取时禁止覆盖，见本地记忆。\n\n详情页展示已填写字段，列表可用前两项有效字段形成摘要。搜索可命中医疗机构、医生、记录对象、结果、医嘱、日期、正文和附件名；它不会分析附件中的检查内容，也不会自动从图片提取指标。\n\n## 本机推荐的约束\n\n输入出现“医疗、就诊、检查报告、用药、复诊”等关键词时，客户端可以推荐健康模板。iOS 对明确人类医疗输入直接返回固定健康字段，不调用系统模型生成新的诊断选项；Android 使用本机关键词匹配。宠物与疫苗词共同出现时仍优先进入宠物接种字段，英文动物匹配采用单词边界，避免把其他单词误识别为猫或狗。\n\n推荐只决定记录类型与可填写结构，不推断诊断、处方、药量或用户尚未提供的信息。用户仍需确认模板，并按原始资料填写。\n\n## 编辑、搜索与关联规则\n\n每条记录以 UUID 区分，不以医院、姓名或日期作为唯一键。同一对象在同一天可有多条独立记录，保存不会自动合并；更改记录对象或机构不会创建另一位“患者档案”。当前没有患者实体、医院目录关联、药品知识库、检验参考值模型，也没有记录之间的关系索引。附件只按附件 ID 关联该条记忆，不会自动解释为某项检验。\n\niOS 保存时把同 ID 的旧记录替换为更新后的记录并移到列表前端，保留 createdAt，更新 updatedAt；Android 修改已有 JSON 保持当前列表位置，新建插到前面。两端都能在列表显示字段摘要，但摘要只取已填写字段中的前两项，不是按医学重要性排序。未显示在摘要中的医嘱仍可在详情和搜索中访问。\n\n例如合成检验值“TSH 2.0; ALT 30”会作为文本保存，大小写或标点不会被改为医学解释；字段值首尾空白可被清理，内部换行保留。普通搜索使用字符串匹配，多个空白分词须全部命中但可以分布在不同字段；搜“机构名 检查”并不保证两词位于同一个字段。日期字段可按其原始格式被匹配，不能把关键词搜索结果称为临床全文分析。\n\n删除正文、清空某个可见字段、移除附件与删除整条记录是不同操作。Android 同主题编辑会明确移除已清空的可见字段，保留未展示旧键；iOS 按当前允许字段重新保存。删除整条记忆先更新索引，再清理其文件，两端的清理失败策略不同，不能承诺闪存安全擦除或撤回已经转出的明文副本。\n\n## 扩展方式\n\n新增医疗字段时应同步更新两端模板、展示、搜索和字段长度规则，保持已有 ID 稳定；新类型不能覆盖旧记录。若计划引入 OCR、提醒、健康平台或云端能力，需要分别设计用户授权、数据来源、删除和失败恢复路径，不能仅增加一个日期字段就宣称已经支持提醒。\n\n## 当前范围与验证\n\n当前实现不包含自动复诊通知、医院系统对接、HealthKit、Health Connect、云端病历同步或自动医学判断；复诊日期只是可选记录值。记忆转出需走账号许可流程，输出包含原文与附件的明文 JSON，详见认证和安全与隐私。\n\n仓库中 iOS 存储测试覆盖医疗长字段截断、重开、编辑、附件加密及空记录；Android 单元和设备测试包含固定字段、多行文本、临床缩写、搜索、旧字段保留与加密附件。测试代码存在不等于本次已执行通过：本篇编写未运行构建、模拟器或真实医疗数据。回归应使用虚构资料，并按测试记录当前设备和结果。"},{"product":"星线 App","title":"城市建筑贴纸","url":"/app/city-stickers/","text":"# 城市建筑贴纸\n\n城市建筑贴纸根据用户识别或手选的城市提供一张本地建筑插画，再通过聊天服务登记成系统贴纸媒体。它与自定义图片贴纸分开管理，不占用户自定义贴纸数量。\n\n## 目录与素材架构\n\n源素材位于 city-stickers：catalog.json 为版本 1 的城市白名单，svg 保存程序化矢量源，images 保存运行时 PNG，SHA256.json 为输出摘要，contact-sheet.png 供整体检查。当前目录共 44 城，包括北京、上海、香港、澳门、台北、东京、巴黎、伦敦、纽约等，覆盖以目录为准。\n\n每个 CitySticker 含 id、countryCode、中文城市名、nameEn、aliases、landmark 和固定 asset 文件名。地标示例包括北京天坛祈年殿、成都安顺廊桥、济南超然楼、巴黎埃菲尔铁塔。素材是风格化建筑插画，不应当作测绘或地理证明。\n\nbuild.py 定义城市、别名和独立建筑构图，使用 320×320 SVG 画布、统一色板和基础描边；构建时用 PyMuPDF 与 Pillow 导出 512×512 透明 RGBA PNG，并断言每张小于 512 KiB。客户端运行无需这些生成库。\n\n## 城市识别与选择\n\n贴纸面板第一格固定为城市入口，后面保持自定义贴纸顺序。打开面板会尝试设备城市解析与已登录服务的 /location/city IP 粗略城市。较快的 IP 可以先显示，设备城市成功后优先替换。界面分别标示“系统定位／城市级”“IP 粗略定位”与“手动选择”。\n\niOS 使用前台单次 CLLocationManager 请求，精度目标为三公里，经系统逆地理服务解析城市，十秒超时后结束。Android 在已有粗略定位权限时使用短时设备请求，在送系统 Geocoder 前将坐标量化到 0.02 度；未授权时保留 IP，并提供主动申请粗略定位入口。两端都可刷新或搜索手选，城市结果在进程内约缓存十分钟。\n\n城市与别名匹配结合国家地区代码，香港、澳门的行政区域返回值有专门处理。不同国家的同名城市不应混用；未收录城市保持选择入口，不拿附近城市替代。两端名称规范化实现略有差异，新增别名应双端回归。\n\n这条城市识别路径不会开启实时位置共享或后台定位，也不会把设备坐标发送给星线聊天服务；系统定位和逆地理服务按系统自身规则处理请求。实时共享属于用户明确开启的另一条数据流，见消息与媒体。\n\n## 发送流程与竞态处理\n\n用户选择城市后，客户端向 /stickers/city 发送 cityId，获得系统贴纸 media，再经正常单聊或群聊发送。iOS 校验媒体类型为 sticker、MIME 为 image/png 且大小在限制内；客户端只从打包目录加载预览，不接收任意素材路径。\n\n准备请求绑定当前账号和目标聊天。用户离开原会话、退出账号、关闭面板或手动选择新城市时，旧请求的迟到结果应被取消或丢弃。iOS 使用操作 generation 与账号／会话复核，Android resolver 也使用 generation、协程取消和当前面板校验，防止把旧定位或旧媒体发入新会话。\n\n## 新增城市与资产发布\n\n新增城市时在 build.py 添加稳定 ID、国家地区码、别名、建筑名和对应构图，再执行素材构建。脚本会同步到 iOS Sources/CityStickers、Android assets/city-stickers 与聊天服务 assets/city-stickers。发布必须保持三端目录、ID 和 PNG 一致，并检查实际透明图与联系表；只改名称、复用不相关建筑不能算作新城市覆盖。\n\n## 当前目录与跨端匹配差异\n\n以下清单按2026-10-23工作区的版本1目录整理，城市ID是发送与资产去重使用的稳定标识。它不是覆盖所有城市的声明。\n\n| cityId | 城市 | 国家或地区代码 | 建筑 |\n| --- | --- | --- | --- |\n| beijing | 北京 | CN | 天坛祈年殿 |\n| shanghai | 上海 | CN | 东方明珠 |\n| guangzhou | 广州 | CN | 广州塔 |\n| shenzhen | 深圳 | CN | 平安金融中心 |\n| chengdu | 成都 | CN | 安顺廊桥 |\n| chongqing | 重庆 | CN | 洪崖洞 |\n| hangzhou | 杭州 | CN | 雷峰塔 |\n| nanjing | 南京 | CN | 中华门 |\n| suzhou | 苏州 | CN | 虎丘塔 |\n| wuhan | 武汉 | CN | 黄鹤楼 |\n| xian | 西安 | CN | 钟楼 |\n| tianjin | 天津 | CN | 天津之眼 |\n| qingdao | 青岛 | CN | 圣弥厄尔教堂 |\n| xiamen | 厦门 | CN | 世茂双子塔 |\n| changsha | 长沙 | CN | 天心阁 |\n| harbin | 哈尔滨 | CN | 圣索菲亚教堂 |\n| zhengzhou | 郑州 | CN | 二七纪念塔 |\n| jinan | 济南 | CN | 超然楼 |\n| lhasa | 拉萨 | CN | 布达拉宫 |\n| fuzhou | 福州 | CN | 三坊七巷 |\n| quanzhou | 泉州 | CN | 开元寺双塔 |\n| dali | 大理 | CN | 崇圣寺三塔 |\n| guilin | 桂林 | CN | 日月双塔 |\n| guiyang | 贵阳 | CN | 甲秀楼 |\n| hongkong | 香港 | HK | 中银大厦 |\n| macau | 澳门 | MO | 大三巴牌坊 |\n| taipei | 台北 | TW | 台北101 |\n| tokyo | 东京 | JP | 东京塔 |\n| osaka | 大阪 | JP | 大阪城 |\n| kyoto | 京都 | JP | 清水寺 |\n| seoul | 首尔 | KR | N首尔塔 |\n| singapore | 新加坡 | SG | 滨海湾金沙 |\n| kualalumpur | 吉隆坡 | MY | 双子塔 |\n| bangkok | 曼谷 | TH | 郑王庙 |\n| paris | 巴黎 | FR | 埃菲尔铁塔 |\n| london | 伦敦 | GB | 伊丽莎白塔 |\n| newyork | 纽约 | US | 帝国大厦 |\n| sydney | 悉尼 | AU | 悉尼歌剧院 |\n| dubai | 迪拜 | AE | 哈利法塔 |\n| sanfrancisco | 旧金山 | US | 金门大桥 |\n| seattle | 西雅图 | US | 太空针塔 |\n| toronto | 多伦多 | CA | 加拿大国家电视塔 |\n| rome | 罗马 | IT | 斗兽场 |\n| istanbul | 伊斯坦布尔 | TR | 加拉塔塔 |\n\niOS 对名称执行大小写、变音符和全半角折叠，再去空格，主要依靠目录中的明确别名；Android 另会移除空白及标点、末尾的 city／市，并要求最终只匹配一项。相同国家地区内的歧义别名可能使 Android 返回空，而 iOS 采用首先匹配的条目，新增素材时应在目录层避免歧义，不用修改匹配器去猜用户位置。\n\n香港、澳门可由国家地区代码直接归入对应行政城市，也兼容系统返回CN且城市为明确已知别名的情况；“香港路”或“澳门附近”不应算香港澳门。国家地区代码缺失时仅靠名称可能不足以确认现实位置，UI保留来源提示和手选。系统城市更准确也不意味着其城市一定已收录。\n\n城市结果只保留在进程内，账号退出会重置当前选择；Android 还按账号键及权限状态检查缓存，手选可使待完成定位失效。十分钟是复用选择的条件，不是持续采集位置的间隔。关面板、切账号、切聊天和点击刷新分别改变请求的当前性，扩展时应测试较慢设备结果和较快IP结果的相反到达顺序。\n\n源码入口包括 city-stickers/build.py、iOS CitySticker/CityStickerSelection/CityStickerDeviceLocator/ChatEmojiPicker 以及 Android CitySticker/CityStickerLocation/CityStickerSheet。发送媒体与消息引用的边界另见消息与媒体。\n\n## 验证范围\n\n本次文档编写做了只读资产核验：主目录为 44 城，PNG 均为 512×512，文件范围为 15,043–36,569 字节，三端目录和全部 PNG 与源目录逐字节一致。这只证明工作区素材一致，不能证明线上服务或商店安装包已同步。\n\n仓库测试还覆盖国家匹配、行政区域、设备优先、IP 回退、未知城市、缓存、取消和发送目标变化。本轮未运行这些测试，真实卫星定位、系统 Geocoder 可用性、跨网络发送与商店包仍须按测试和发布验收。"},{"product":"星线 App","title":"权限、系统能力与可访问性","url":"/app/permissions-accessibility/","text":"# 权限、系统能力与可访问性\n\n星线按扫码、通话、城市识别、实时位置和文件操作分别接入系统能力。本文列明源码声明及触发路径，并说明现有可访问性实现与验收范围。\n\n## iOS 权限与能力\n\n| 能力 | 触发与用途 |\n| --- | --- |\n| 相机 | 扫码、拍摄和视频通话，在对应路径请求视频访问 |\n| 麦克风 | 发起或接入音视频通话时请求音频访问 |\n| 使用期间定位 | 城市贴纸、位置选择、附近地点及用户确认后的实时共享 |\n| 文件与照片选择 | 使用系统选择器读取用户明确选中的项目 |\n\nInfo.plist 和 project.yml 提供相机、麦克风、使用期间定位说明；后台模式声明 location、audio、voip。城市识别只发起一次前台请求，实时共享在用户开启后才允许后台定位，结束时关闭后台更新。声明后台模式本身不会让共享自动开始。\n\n扫码检查 DataScannerViewController.isSupported 和 isAvailable。相机不可用或未授权时保留照片选择、粘贴内容和系统设置入口；相册使用 PhotosPicker，文件使用 fileImporter，不要求扫描整个照片库。通话分别处理麦克风与摄像头授权，视频开关会检查摄像头访问。\n\n系统来电桥接使用 CallKit/PushKit，APNs entitlement 根据 Debug/Release 注入 development/production 环境。推送凭据、服务和描述文件是否可用，需要独立验收，不能由 entitlement 存在推断后台来电已交付。\n\n## Android 清单与运行时请求\n\n清单声明 INTERNET、CAMERA、RECORD_AUDIO、MODIFY_AUDIO_SETTINGS、ACCESS_NETWORK_STATE、FINE/COARSE_LOCATION、FOREGROUND_SERVICE、FOREGROUND_SERVICE_LOCATION 和 POST_NOTIFICATIONS。其中敏感权限还需运行时获得用户授权。\n\n扫码相机进入页面后请求 CAMERA，CameraX 随生命周期绑定与释放；拒绝后仍可选择图片或粘贴。通话依据接听、入会、开麦或开摄像头动作请求所需权限，支持视频通话的仅音频加入路径。\n\n城市贴纸优先使用已获得的 COARSE_LOCATION，界面提供主动授权按钮；即使没有定位权限也可用 IP 粗略城市或手选。实时共享同时处理精确／粗略位置，以及 Android 13 及以上的通知权限，再启动带持续通知的定位前台服务。清单没有申请 ACCESS_BACKGROUND_LOCATION；前台服务的系统限制和厂商后台策略仍需真机检查。\n\n应用关闭系统自动备份，Release 禁止明文网络，Debug 单独允许本地联调。FileProvider 不对外导出组件，只向选中的接收应用授予临时 URI 权限。文件选择通过系统文档选择器读取指定内容，没有请求广泛存储访问。相关数据边界见安全与隐私。\n\n## 拒绝、撤权与替代路径\n\n| 场景 | iOS 当前行为 | Android 当前行为 |\n| --- | --- | --- |\n| 扫码相机拒绝 | 保留照片／粘贴，显示系统设置入口 | 相机区域可再次申请，照片／粘贴仍可用；永久拒绝后不能假设重按必有弹窗 |\n| 扫描硬件不支持 | VisionKit 能力检查后显示替代入口 | CameraX 初始化失败显示错误，不把取景框存在算识别可用 |\n| 通话麦克风拒绝 | 准备媒体阶段报错，不能正常发布音频 | 动作提示所需权限，可重新授权 |\n| 视频摄像头拒绝 | 摄像头状态保持关闭，不能声称发送视频 | 视频发起要求相机；来电／加入可选择仅音频路径 |\n| 城市粗略位置未授权 | 单次请求可能触发授权；失败保留 IP 与手选 | 不强制先授予精确定位，用户可主动申请粗略位置 |\n| 实时位置所需通知拒绝 | 不适用 Android 通知前台服务门槛 | Android 13及以上通知未授权时不开始持续共享 |\n| 共享中撤销位置 | 停本机位置更新，页面须呈现最后更新时间 | 前台服务停采集并提示处理原有共享；不等于服务器已删除旧坐标 |\n| 文件或照片选择取消 | 不添加本次项目 | 不添加本次项目 |\n\n权限“已拒绝”和系统定位／相机服务关闭不同；同一个按钮重新点击也不能替代前往系统设置。普通文件选择、附件预览和 JSON 转出也属于不同授权范围：选择输入文件不表示允许读取整个目录，授予 FileProvider 读权限不表示接收应用不能复制内容，选择转出目标可能涉及用户所选云盘。详细明文和清理边界见记忆转出。\n\n回归按以下顺序记录：在明确的初始权限下进入功能；拒绝并确认替代入口；到系统设置改变权限后回前台；重新触发；进行中再次撤权；最后退出页面和进程，检查资源释放及服务器状态。不得为了截图统一清除已有 App 数据，测试夹具和权限状态应使用专用设备或明确授权的范围。\n\n## 已有可访问性实现\n\niOS 为扫码、记忆操作、任务完成、名片和消息等提供语义标签或提示；部分纯装饰插画隐藏于辅助技术。任务筛选在辅助功能大字号下由横排改为纵排；新事及个人页读取 Reduce Motion，减少过渡动画。测试使用的 accessibilityIdentifier 是定位标识，不能替代用户能听到的 accessibilityLabel。\n\nAndroid 底部导航提供 Role.Tab 与导航描述，设置开关使用 Switch 语义，任务操作描述包含目标任务。通话状态和错误使用 polite live region；装饰插画移除语义，动作图标提供中文描述。主要工具栏动作采用 48dp 点击区域；插画与页面过渡读取系统动画时长开关，关闭动画时使用静态状态。\n\n## 扩展与验收\n\n新增能力应将用途说明、运行时请求、拒绝后的可用路径和资源释放放在同一功能中检查。新增图标沿用已有矢量体系，统一尺寸、线宽和语义颜色；功能名称由标签表达，装饰图不重复播报。布局需检查窄屏、键盘弹出、较大字体、长文件名和多行内容的实际摆放。\n\n本篇依据源码，不宣称已通过完整 VoiceOver、TalkBack、大字号、对比度或无障碍合规认证。本轮未操作设备。回归需覆盖首次允许、拒绝、永久拒绝、系统设置撤权、前后台切换与进程恢复，并实际检查焦点顺序、朗读、触控区域及动画关闭后的状态，见测试。源码入口为 iOS ScannerView/ChatCallCoordinator/ChatLiveLocation/TaskListView 与 Android ScanCameraPreview/CallScreen/LiveLocationPanel/VisualArtwork。城市与医疗的专项边界分别见城市贴纸和医疗健康记录。"},{"product":"星线 App","title":"安全、加密与隐私边界","url":"/app/security-and-privacy/","text":"# 安全、加密与隐私边界\n\n本页描述当前源码可确认的防护、数据可见性和已知实现边界。它不是独立安全审计报告，也不表示生产部署、客户端权限、外部依赖或端到端安全已经全部验收。维护者应以实际构建、配置回读和完整双设备路径的结果更新状态。\n\n## 登录与凭据分层\n\nAccount 负责账号身份与授权，聊天服务负责业务会话。原生登录有 App→聊天服务的 S256 proof 和聊天服务→Account 的 S256 PKCE；state 与一次性 ticket 绑定，ticket 有效期 60 秒，聊天会话默认 24 小时。App 不需要存储机密 OIDC 客户端 secret。正式回调为 https://chat.thestarline.cn/auth/callback，自定义 scheme 只接收受 proof 约束的一次性 ticket。\n\n服务端仅保存 state、ticket 和聊天 token 的 SHA-256 摘要，业务请求按未过期 token 摘要鉴权；原始 token 仍是持有即授权的凭据，客户端和日志系统须保护。原生回调需验证 scheme、host、state、参数唯一性和到期时间。仅仅“浏览器打开了 Account 页面”不算登录完成。\n\niOS 使用 Keychain 的 AfterFirstUnlockThisDeviceOnly 保存会话和原生授权待完成状态。Android 使用 Keystore 支持的本地加密存储。设备安全存储与服务器端会话到期是互补措施，设备已解锁、恶意软件、屏幕录制、系统备份和权限边界仍需单独评估。/auth/logout 成功时只撤销当前聊天 session，不等于所有 Account 登录和其他设备都已撤销。两端退出均会清除本机状态并忽略远端退出失败，网络失败时旧 session 可能继续有效直至到期；Account 改密码或撤销其自身会话没有自动联动撤销聊天 session 的实现。\n\n## 服务端权限是核心约束\n\n每个聊天资源都先验证 Bearer 用户，再检查会话双方、群当前成员或内容可见性。单聊发送、发起或接听通话、开始或更新位置、MLS 准备与应用事件等还会检查好友和任一方向屏蔽。群主权限用于邀请、改名、移除和解散，不能仅靠隐藏客户端按钮实现。\n\n媒体下载要求所有者或可见消息授权；新事下载要求所有者或可见帖子授权。不可见资源通常使用 404 避免直接返回内容。普通单聊历史媒体的可见消息授权不重新检查is_friend或block，删好友/屏蔽不保证撤销原来仍可见的媒体；群消息授权分支要求当前成员，但所有者下载分支独立。不要把发送限制当历史读取限制。依据：chat-service/src/database.mjs:239-254。星企企业接口按组织成员、管理员与所有者执行独立检查，个人聊天身份不能绕过组织边界。返回 401、403、404、409 的具体语义见API 参考。\n\n## 哪些数据是服务端可读的\n\n| 数据 | 当前实现 |\n| --- | --- |\n| 普通聊天正文、附件与贴纸 | 正文与原始媒体 BLOB 在服务端保存；主聊天链路尚未全量改用 MLS |\n| MLS application 内容 | 已建立并激活的 MLS 范围存储应用密文；设备负责解密与发送者验证 |\n| 新事文字、图片、视频、评论 | 服务端可读，依赖访问控制和可见范围 |\n| 实时位置 | 经度、纬度、精度与时间在服务端可读保存 |\n| 个人资料联系电话 | 服务端 AES-256-GCM 加密保存；按可见性解密返回 |\n| 手机号查找索引 | HMAC-SHA256 摘要，登录后有效期 24 小时 |\n| 来电推送 | 通话、聊天范围、模式、来电人名称及可选群名；不含聊天正文和媒体凭证 |\n\nHTTPS 保护传输，磁盘加密保护本地存储，服务端加密保护特定字段，MLS 保护特定会话内容；它们的信任主体不同。当前不能把普通附件、实时位置、新事、通话媒体统称为端到端加密。代码中的上传接口没有客户端附件密钥、密文封装或附件解密协议。\n\n## MLS 的已实现约束\n\nRust 核心使用 OpenMLS，固定 MLS 1.0 与 X25519、AES-128-GCM、SHA-256、Ed25519 对应的固定套件。服务端校验密钥包的协议、签名、套件以及账号/设备身份，已注册设备不能静默改签名公钥；密钥包使用一次性领取标记。服务端解析消息类型与群 ID，终端负责 MLS 消息验证、解密和发送者身份核对，服务端格式校验不能代替终端认证。\n\niOS 独立传输器在首次见到设备时固定签名公钥，已知设备换钥时拒绝继续；解密所得发送者与服务端 user/device 元数据不一致时失败。此为代码中的信任固定机制，不能据此宣称已经提供可供用户线下核对的完整安全号码体验或第三方目录透明性。\n\n设备或成员变化使现有加密范围 blocked，服务端暂停应用密文发送；新 Commit、必要 Welcome 和确认完成后才激活。preparing/active/blocked 范围禁止旧明文发送接口。加密状态丢失时不得静默生成新密钥假装旧设备，也不得跳过失败的消息来推进 sequence。\n\nRust 状态快照使用 AES-256-GCM、随机 nonce 和固定附加认证数据封装，iOS 还将游标、待发密文、邀请与确认纳入加密 checkpoint；封装密钥存入设备安全存储。原生文件写入使用临时文件、同步与替换流程。恢复校验失败表示状态不可用，不能把它显示成“没有聊天记录”。这些机制仍需结合断电、进程中断、设备迁移和回滚测试验收。\n\n## 个人资料、新事和位置控制\n\n资料手机号可见范围为 private、selected、friends、everyone；everyone 仍是已登录聊天 API 用户。指定用户必须为当前好友，最多 100 位，双方屏蔽后不可通过这些规则获取手机号。可展示联系电话与 Account 已验证手机号查找索引不同，修改资料不等于修改 Account 安全手机号。\n\n新事的 friends、selected、public、private 在每次读取时结合当前好友、屏蔽和个人隐藏判定，public 仍需要登录。点赞、评论和通知会产生参与关系元数据。隐藏新事、删除个人消息、删除个人聊天和解散群的语义不同；个人视图隐藏不承诺数据库、备份或另一端同时擦除。\n\n实时位置必须显式开始，客户端应允许停止并说明系统位置权限。位置表没有自动到期字段，界面要呈现最后更新时间；用户停止本机定位采集与服务器删除共享记录是两个需要协调的动作。IP 城市只返回粗略结果，可信代理只接受 allowlist 中上游覆盖的单个 X-Real-IP，不能把任意 X-Forwarded-For 当可信地址。\n\n## 输入限制、日志和公开资料\n\nJSON 默认最大 16 KiB，不同媒体与加密接口有专用上限；普通上传、贴纸数量、账号空间、新事媒体、查找和识别分别受限。文字长度、规范 base64、文件名和 MIME 校验能减少异常输入，但文件头检查不等于恶意文件扫描，当前文档不承诺存在未实现的病毒扫描或内容安全服务。\n\n公开文档仅发布接口契约与合成示例，不发布真实 token、客户端 secret、APNs/LiveKit 凭据、用户资料、数据库、附件、服务器地址或私有部署位置。机器人可索引文档不代表业务 API 免鉴权，也不授权访问个人数据。排错时使用错误码、请求阶段和已脱敏时间线，避免记录 verifier、票据、访问令牌、授权码和推送 token。\n\n## 当前验收范围\n\n可以确认的是源码中的路由、状态、输入限制、权限与加密实现。当前主聊天尚未全量接入 MLS，Android 未见同等 MLS 发送链路；真实跨网络音视频、后台系统来电和生产 APNs 仍需独立验收。当前权威 Account 主源码没有记忆转出 redeem 路由；App 工作树中的历史副本包含实现，也不能据此认定当前服务支持。安全承诺应以这些边界为准，测试依据见测试，运营配置见运维。"},{"product":"星线 App","title":"源码模块与修改入口地图","url":"/app/module-map/","text":"# 源码模块与修改入口地图\n\n本页将目录、关键文件、跨模块依赖和验证入口对应起来，便于定位变更边界。路径均相对其所属仓库，不代表公开源码下载授权。App 仓库含 iOS、Android、聊天和加密核心；Account 另有当前维护的主仓库，App 工作树附带的 identity/ 是历史快照。二者不能以目录同名认定版本相同，更不能把旧副本整体覆盖到共享服务。\n\n## 从需求定位负责层\n\n| 需求或故障 | 首要入口 | 继续追踪的依赖 |\n| --- | --- | --- |\n| 启动页、导航、默认页面 | iOS App／Home，Android MainActivity／StarlineDesign | 本机偏好、页面状态与生命周期 |\n| 登录、取消、回跳与重开 | ChatNativeAuth／ChatAuthenticator，Android NativeAuth | ChatAPI／ServiceApi、聊天授权路由、当前 Account OIDC |\n| 好友、群、消息、已读与删除 | ChatStore／AppState、聊天 server／database | 关系权限、个人历史边界、事件刷新 |\n| 新事、评论、可见性与视频 | MomentsStore／MomentsScreens、moments-repository | 聊天身份、独立媒体与成员关系 |\n| 本机任务、收藏与最近访问 | LocalWorkspaceStore／WorkScreens、LocalData | 本机文件／偏好，非企业服务 |\n| 记忆、模板与附件 | MemoryStore／MemoryTemplates、MemoryScreens | 设备密钥、格式兼容、本机识别与搜索 |\n| 定位、城市与实时共享 | 城市 resolver／ChatLocation／LiveLocationService | 系统权限、目录、IP 城市及显式共享状态 |\n| 通话与来电 | ChatCallCoordinator／CallScreen、calls 仓储 | LiveKit、APNs、系统能力、有效 session |\n| MLS 独立协议 | ChatEncryptedTransport／chat-crypto／e2ee-delivery | 密钥包、设备身份固定、checkpoint、成员状态机 |\n\n优先沿现有领域入口修改，不直接在视图里另建网络客户端、会话或持久化。页面隐藏只影响展示，服务端授权仍须在资源入口检查；双端使用同一业务服务也不代表本机实现和数据格式一致。\n\n## iOS 文件职责\n\n| 相对路径 | 职责与修改注意事项 |\n| --- | --- |\n| ios-app/project.yml、StarlineWorkbench.xcodeproj | XcodeGen 配置与实际工程；新增文件、资源和能力后核对二者 |\n| Sources/App/StarlineWorkbenchApp.swift | Store 创建、系统来电桥、前后台与 URL 回调分发 |\n| Sources/Features/Home/HomeView.swift | 根页面组织、本机工作台入口与偏好；不要混入服务端企业权限 |\n| Sources/Features/Chat/ChatAPI.swift、ChatModels.swift | HTTP、解码、错误与 Debug base URL；服务字段改动需同步模型 |\n| ChatNativeAuth.swift、ChatAuthenticator.swift、ChatKeychain.swift | PKCE／pending、系统授权会话与安全存储；检查取消、进程恢复和提交顺序 |\n| ChatStore.swift | 账号、关系、消息、pending、已读、事件与位置业务协调；异步操作核对当前账号和范围 |\n| ChatComposer.swift、ChatThreadView.swift、GroupThreadView.swift | 输入、附件、消息展示及目标会话；文件上传与消息引用是两步 |\n| ChatCallCoordinator.swift、ChatCallView.swift、CallSystemBridge.swift | 媒体与系统通话桥接；信令 active 不等于轨道可用 |\n| CityStickerSelection.swift、CityStickerDeviceLocator.swift | 设备城市、IP／手选优先级、缓存与迟到响应保护 |\n| ChatCryptoDevice.swift、ChatCryptoTrustStore.swift、ChatEncryptedTransport.swift | 独立 MLS 核心包装、设备公钥固定和加密投递；不等于主消息全部接入 |\n| Sources/Features/Moments/ | 新事模型、Store、发布、详情和媒体；与普通聊天媒体契约分开 |\n| Sources/Data/MemoryStore.swift、MemoryVaultCrypto.swift | 加密索引、附件、迁移、写入顺序与损坏保护 |\n| MemoryLocalRecognizer.swift、MemoryLocalSearch.swift | 本机推荐与搜索；系统模型可用性与本机回退 |\n| Sources/Domain/MemoryTemplate.swift、MemoryGeneratedSchema.swift | 固定模板、字段、长度与生成结构兼容 |\n| Sources/Features/Profile/MemoryExportDocument.swift | 额外授权后的本机明文输出与临时文件清理 |\n| Sources/Data/LocalWorkspaceStore.swift | 本机任务、收藏、最近访问的原子快照 |\n| Sources/Features/Scanner/、Browser/ | 相机／相册识别、网页、账号确认浏览会话 |\n\n除完整路径的表项外，Chat 文件均位于 ios-app/Sources/Features/Chat/，Data 文件位于 ios-app/Sources/Data/。界面改动检查真实视口、键盘、大字号与辅助技术语义；资源名称存在不代表已放进当前 target。当前主设备族是 iPhone，没有 watchOS target，iPad 审核仍需独立验收。\n\n## Android 文件职责\n\n下表文件位于 android-app/app/src/main/java/cn/thestarline/workbench/。\n\n| 文件 | 职责与联动范围 |\n| --- | --- |\n| MainActivity.kt、StarlineDesign.kt | Activity、Intent、根 Compose 布局、导航与系统 inset |\n| AppState.kt | 当前账号、页面、事件循环、业务请求、通话与位置协调；注意协程取消和迟到结果 |\n| AppData.kt | ServiceApi、错误、SecureFile、LocalData；任务偏好与加密会话／记忆分类不同 |\n| NativeAuth.kt | verifier、state、目标 URL、回调与 pending；没有 Debug 本地 Account 例外 |\n| MessageSendIdCoordinator.kt | 未确认发送的 clientId 复用；确认、换账号和换范围要保持隔离 |\n| ChatScreens.kt、ChatManagementScreens.kt、ChatMediaBubble.kt | 消息、群、关系菜单、附件读取；引用权限来自服务端 |\n| MomentsScreens.kt | 新事流、评论、通知、图片／视频发布与播放 |\n| CallScreen.kt、CallMediaControls.kt | 运行时权限、LiveKit 控件与输出轨道；绑定当前通话 generation |\n| LiveLocationService.kt、LiveLocationPanel.kt、ChatLocation.kt | 显式共享、前台服务、地图与更新时间；停止采集和删除共享要协调 |\n| CitySticker.kt、CityStickerLocation.kt、CityStickerSheet.kt | 城市匹配、短时定位、IP 回退、手选、缓存与发送目标检查 |\n| MemoryTemplates.kt、MemoryScreens.kt | 固定主题、关键词、编辑、搜索和旧字段保留 |\n| MemoryExport.kt、MemoryExportDocument.kt | 一次性授权、生成输出、系统保存位置与取消清理 |\n| WorkScreens.kt、ProfileScreens.kt、AccountExtras.kt | 工作台、本机任务／收藏、设置与账号展示 |\n| ScannerScreen.kt、ScanCameraPreview.kt | CameraX、ML Kit、图片与手动输入 |\n\n配置还分布在 app/build.gradle.kts、主／Debug AndroidManifest.xml 与资源目录。主清单关闭自动备份并禁止明文网络，Debug 另允许本地 API；这一网络例外不改变原生授权 URL 信任。当前 Kotlin、AGP、Gradle 和 JDK 组合应实际验证，不能把工具链声明直接当作可构建保证。\n\n## 聊天与加密服务\n\n| 相对路径 | 职责 |\n| --- | --- |\n| chat-service/src/main.mjs | 环境变量、生产启动条件、监听和信号退出 |\n| src/server.mjs | HTTP 解析、授权、资源路由、长轮询、模型桥接与定时协调 |\n| src/database.mjs | SQLite 表、启动迁移、事务、关系、消息、媒体、事件与设备会话 |\n| src/moments-repository.mjs | 新事可见范围、互动、通知、媒体与孤立资源清理 |\n| src/call-repository.mjs、call-media.mjs | 通话状态与参与者、媒体 token、房间和成员清理 |\n| src/apns-voip.mjs、voip-devices.mjs | APNs 签名发送、设备与 session 关联；提供商接受不等于设备到达 |\n| src/city-location.mjs、city-stickers.mjs | 可信来源与离线城市、白名单素材和媒体去重 |\n| src/memory-recognition.mjs | 配置模型的结构生成与固定补充；请求文字会离开设备 |\n| src/e2ee-verifier.mjs、e2ee-delivery.mjs | WASM 结构校验与设备密钥包、范围、密文、ack 状态 |\n| src/enterprise-*、src/enterprise/ | 共享后台的独立星企领域；不构成星线 App 个人前台功能 |\n| chat-crypto/src/、include/、scripts/ | Rust OpenMLS、C ABI、平台与 WASM 构建；状态快照与设备私钥由端侧保护 |\n\n表中 src/ 均相对 chat-service/。媒体 BLOB 与消息元数据存于同一 SQLite 数据集；数据库构造会迁移，不能为了检查 schema 随意用新服务打开真实旧库。恢复测试使用副本和配套密钥，见故障手册。\n\n## Account 权威源与历史副本\n\nAccount 的当前 server/config.ts、store.ts、notify.ts、captcha.ts、oidc.ts、admin-policy.ts 与 main.ts 分别定义配置、MySQL／PII、投递、人机验证、协议、管理策略与启动。现行代码还包含资料、扫码／手机授权、小程序、实名与开放接口等模块；这些不能凭 App 历史副本的文件清单推断缺失。\n\n目前可确认的差异有：主迁移为 001–007 并有 PII 加密审批；生产邮件为腾讯 SES、短信为阿里云；管理员通行密钥开关由环境精确值读取；开发注册也有 captcha 门禁。App 副本中的本地聊天客户端登记脚本在当前 Account 主仓库不存在，旧注册 fixture 也没有新的 captcha 请求字段。应先修订隔离夹具并验证，不能称为现成一键联调。记忆转出 redeem 契约在当前主源码缺失，不能用旧副本证明跨服务可用。\n\n修改共享身份时，应在当前 Account 仓库执行其类型与测试检查；修改 App 侧协议时同时核对目标部署的实际契约。两个仓库的版本和授权是独立边界，文档发布不等于身份服务升级，文件拷贝也不是协议迁移方案。\n\n## 修改入口与验证命令\n\n| 修改类型 | 最少关联入口 | 进一步验证 |\n| --- | --- | --- |\n| 原生授权 | iOS ChatNativeAuthTests、Android NativeAuthTest、服务端 native-auth | 系统浏览器、取消、冷启动、401／断网与真实目标平台 |\n| 发送与关系 | chat、group、management、media 测试；双端发送状态 | 丢响应重试、三账号拒绝、删除历史和双端已读 |\n| 新事 | moments 测试与两端发布／媒体界面 | 可见范围、屏蔽、视频 Range、取消与孤立资源 |\n| 本机记忆 | MemoryLocal／MemoryStore、MemoryTemplates／MedicalMemory | 损坏库、丢密钥、旧字段、多行、附件与重开 |\n| 城市／位置 | city-location、city-stickers、live-location 及原生 resolver | 权限拒绝、缓存、换账号／会话、停止、前后台 |\n| 通话 | calls、call-media、apns-voip、voip-devices | 两台真机音画、跨网、后台、锁屏与多设备 |\n\n真实命令入口示例，本次未执行：\n\n``sh\nnode --test chat-service/tests/native-auth.test.mjs\nnode --test chat-service/tests/group.test.mjs chat-service/tests/management.test.mjs\nnode --test chat-service/tests/memory.test.mjs chat-service/tests/moments.test.mjs\nzsh ios-app/scripts/test-native-auth.sh\n`\n\n`sh\ncd android-app\n./gradlew :app:testDebugUnitTest --max-workers=1\n`\n\n`sh\ncd identity\nnpm run typecheck\nnpm test\nnpm --prefix client run typecheck\n`\n\n最后一组必须在当前 Account 源码上下文执行。文档统一构建入口位于 technical-docs/build.mjs、technical-docs/check.mjs；App 的内容与导航继续从 app-download/docs/` 读取，作为全产品文档站的一个产品目录。构建只处理公开静态资料，不运行产品后台。它们的通过只能说明文档产物，不能成为业务验收。完整模块×故障矩阵见测试矩阵，配置与发行门禁见配置参考和发行清单。"},{"product":"星线 App","title":"本地开发与配置","url":"/app/development/","text":"# 本地开发与配置\n\n本文说明星线 App 的源码入口、隔离开发环境和配置方法。App 与聊天命令默认从 App 仓库根目录执行；Account 命令必须进入其当前维护仓库，不能使用 App 工作树中同名的历史 identity/ 副本。示例中的账号、口令和 example.test 域名均为合成测试值。配置依据 2026-10-23 当前源码整理，本次文档整理没有启动服务、模拟器或访问生产配置。\n\n## 先理解组成\n\n| 仓库路径 | 职责 | 开发入口 |\n| --- | --- | --- |\n| ios-app/ | iPhone 原生界面、本机数据、系统浏览器登录 | Xcode 工程 StarlineWorkbench.xcodeproj |\n| android-app/ | Android Compose 界面、Keystore 和本机数据 | Gradle Wrapper |\n| Account 当前仓库的 identity/ | 账号、验证码、通行密钥、OIDC、管理权限、PII 保护 | Node.js / TypeScript / Fastify / MySQL；App 副本不是当前权威源 |\n| Account 当前仓库的 identity/client/ | Account 与管理站前端 | React / Vite |\n| chat-service/ | 聊天、新事、媒体、设备会话、通话信令 | Node.js / SQLite |\n| chat-crypto/ | OpenMLS 加密核心及跨端绑定 | Rust 与平台构建脚本 |\n| app-download/dist/ | 公开下载介绍页 | 静态 HTTP 服务 |\n\n开发前先查看当前工作区差异。需要可丢弃环境时使用新的独立工作区，不把现有 App 私有目录、账号数据库、Keychain 或 Keystore 当作测试夹具。Account、聊天和 App 必须指向同一环境；本地签发的 ticket 不能提交给正式聊天服务。\n\n## 工具与依赖\n\n服务端的 package.json 要求 Node.js 24 或更新版本；身份服务使用 MySQL 8.4。Android 当前使用 Java 17、Android SDK 35，最低支持 Android 8.0（API 26）；构建应使用仓库自带 gradlew。iOS 工程使用 Swift 6、最低 iOS 17，需安装可用的 Xcode 与 XcodeGen。具体 Xcode、SDK 和签名兼容性以所选工具链实际构建结果为准，不由一次历史构建推断。\n\n``sh\nnode --version\njava -version\nxcodebuild -version\n`\n\n未安装的目标平台可跳过对应工具检查。依赖使用锁文件安装；前三条在当前 Account 仓库执行，最后一条在 App 仓库执行：\n\n`sh\nnpm --prefix identity ci\nnpm --prefix identity/client ci\nnpm --prefix identity/client run build\nnpm --prefix chat-service ci\n`\n\n## 隔离的账号与聊天联调\n\n身份测试与本地 fixture 固定使用回环地址 127.0.0.1:3310 上的新建 identity_dev 数据库。可以在专用开发环境创建容器；下面的口令公开且只用于可丢弃的本地数据库，禁止用于公网：\n\n`sh\ndocker run --name starline-identity-dev \\\n  -p 127.0.0.1:3310:3306 \\\n  -e MYSQL_DATABASE=identity_dev \\\n  -e MYSQL_USER=identity \\\n  -e MYSQL_PASSWORD=local-test-only \\\n  -e MYSQL_ROOT_PASSWORD=local-root-fixture-only \\\n  -d mysql:8.4\n`\n\n等待数据库就绪后，在当前 Account 的新建隔离工作区运行：\n\n`sh\ncd identity\nnode scripts/start-local-preview.mjs --local-fixtures\n`\n\n预览启动器先检查依赖、数据库和端口，已有健康服务会被复用；端口被其他异常服务占用时会退出。它持续运行账号后端与前端，不按展示时长关闭。此命令只证明启动流程，不提供当前注册、客户端登记和原生回跳的完整夹具。\n\n当前注册即使在 development 也需要 captcha。保留的 preview-fixture.mjs 没有提交该参数，不能照旧当作可用的一键注册；它需要先按现行协议修订并验证。App 历史副本的 create-chat-local-client.ts 在当前 Account 主源码中不存在，也不能复制回共享服务冒充当前工具。隔离测试应使用 env=test 专用构造和合成依赖；需要浏览器联调时另行完成受控人机验证与客户端登记，精确登记本地 HTTPS／开发回调、scopes 和 confidential 类型，不使用生产客户端秘密。\n\n聊天本地配置需由上述受控登记产生并保存在被忽略的受保护文件中；它不是本页启动命令自动生成的产物。取得匹配配置后才能继续：\n\n另开终端，从仓库根目录执行：\n\n`sh\ncd chat-service\nCHAT_DATABASE_PATH=./data/qa-chat.sqlite node --env-file=.env.local src/main.mjs\n`\n\n这里显式使用独立 SQLite 文件，避免接触已有预览数据。此服务前台持续运行，正常结束应发送终端中断，让服务关闭连接。账号页为 http://localhost:9100，前端开发入口为 http://localhost:9101，聊天健康检查为 http://localhost:4210/health。WebAuthn 本地 RP 使用 localhost，不要把 IP 页面和域名页面混为同一 RP。\n\n## 连接原生 App\n\niOS 在 Xcode 的 Debug Run 环境中设置 STARLINE_CHAT_BASE_URL=http://localhost:4210。该覆盖仅在 Debug 生效，Release 忽略它；HTTP 仅允许本机回环主机。默认地址已经是正式聊天服务，旧资料中“模拟器默认连接 localhost”的表述不再适用。\n\n`sh\ncd ios-app\nxcodegen generate\nopen StarlineWorkbench.xcodeproj\n`\n\n若缺少 chat-crypto/dist/StarlineChatCrypto.xcframework，先按工具链准备 Rust iOS 目标，再从仓库根目录执行 bash chat-crypto/scripts/build-apple.sh。该脚本构建原生库，并不证明聊天界面已启用端到端加密。\n\nAndroid 模拟器对本机聊天 API 做隔离联调时可显式构建：\n\n`sh\ncd android-app\n./gradlew :app:assembleDebug \\\n  -PstarlineChatBaseUrl=http://10.0.2.2:4210 \\\n  --max-workers=1\n`\n\n10.0.2.2 是 Android 模拟器的宿主机别名，不能直接用于实体手机。上述覆盖和端口反向转发只解决普通 API 的网络可达性，不能直接完成隔离 Account 的原生登录。当前 Android NativeAuth.kt 仅信任正式 https://account.thestarline.cn 授权目的地，没有 Debug localhost 例外；本地聊天服务返回本地 Account 授权 URL 时，客户端会拒绝打开。完整隔离原生登录需要另行设计并实现受控测试信任配置，再单独验收，目前没有现成的完整流程。不要放宽正式构建的信任范围，或把模拟器地址写入公开发行包。安装到已有预览设备前先确认签名兼容，不卸载、不清空数据。\n\n## 配置变量目录\n\n配置值应由本地受保护文件或部署系统注入，公开文档只说明名字与用途。生产模式包含固定批准域名、HTTPS 和密钥强度约束，https://account.example.test/oidc 这样的合成值仅用于测试构造，不能直接作为生产配置运行。\n\n| 分组 | 变量名 | 用途与边界 |\n| --- | --- | --- |\n| 身份基础 | NODE_ENV、IDENTITY_HOST、IDENTITY_PORT、IDENTITY_DATABASE_URL、IDENTITY_DB_PREFIX | 模式、监听和数据库命名空间 |\n| 身份安全 | IDENTITY_SECRET、IDENTITY_PII_KEY、IDENTITY_PII_MIGRATION_APPROVED、IDENTITY_ORIGINS、IDENTITY_TRUST_PROXY、IDENTITY_OIDC_JWKS_FILE | 稳定加密材料、PII 迁移审批、精确来源与可信代理 |\n| 身份站点 | IDENTITY_ACCOUNT_ORIGIN、IDENTITY_OPEN_ORIGIN、IDENTITY_WEBAUTHN_RP_ID | 页面来源与通行密钥 RP |\n| 邮件投递 | IDENTITY_DELIVERY、TENCENT_SES_SECRET_ID、TENCENT_SES_SECRET_KEY、TENCENT_SES_TEMPLATE_ID | 当前生产仅使用腾讯 SES；生产禁用开发投递 |\n| 非生产邮件备用 | SMTP_HOST、SMTP_PORT、SMTP_SECURE、SMTP_USER、SMTP_PASSWORD、SMTP_FROM、SMTP_LOCAL_SINK | 当前 SMTP 不作为生产回退，本地 sink 仅允许非生产回环 |\n| 短信 | ALIBABA_CLOUD_ACCESS_KEY_ID、ALIBABA_CLOUD_ACCESS_KEY_SECRET、ALIYUN_SMS_SIGN_NAME、ALIYUN_SMS_TEMPLATE_CODE | 当前阿里云短信配置；旧腾讯短信变量不再适用 |\n| 人机验证 | TENCENT_CAPTCHA_APP_ID、TENCENT_CAPTCHA_APP_SECRET、TENCENT_CAPTCHA_SECRET_ID、TENCENT_CAPTCHA_SECRET_KEY | 开发注册也受门禁，缺配置不模拟通过 |\n| 聊天 | CHAT_HOST、CHAT_PORT、CHAT_DATABASE_PATH、CHAT_ISSUER_URL、CHAT_PUBLIC_URL、CHAT_CLIENT_ID、CHAT_CLIENT_SECRET | 存储与 confidential OIDC 客户端 |\n| 可选服务 | CHAT_PROFILE_ENCRYPTION_KEY、CHAT_CRYPTO_NODE_BUNDLE、CHAT_TRUST_PROXY_ALLOWLIST、CHAT_IP_CITY_DATABASE_DIR、CHAT_MEMORY_MODEL_URL、CHAT_MEMORY_MODEL | 资料加密、MLS 校验、城市与自建模型 |\n| 媒体与推送 | LIVEKIT_URL、LIVEKIT_API_KEY、LIVEKIT_API_SECRET、APNS_TEAM_ID、APNS_KEY_ID、APNS_KEY_PATH、APNS_VOIP_TOPIC、APNS_ENVIRONMENT | 通话媒体与 APNs；密钥留在服务端 |\n\n第三方身份变量还有 WECHAT_APP_ID、WECHAT_APP_SECRET、QQ_APP_ID、QQ_APP_SECRET、ALIPAY_APP_ID、ALIPAY_PRIVATE_KEY、ALIPAY_PUBLIC_KEY。配置存在只说明适配器具备材料，不能代替平台开通与真实回调验收。当前管理通行密钥策略由 IDENTITY_ADMIN_PASSKEY_REQUIRED` 精确值控制，不再是旧副本的生产硬编码；小程序、实名与 PII 的附加条件见完整配置参考。验证方法见测试与验收，生产处理见运行维护。"},{"product":"星线 App","title":"环境、配置与兼容性","url":"/app/configuration/","text":"# 环境、配置与兼容性\n\n星线 App、聊天服务与 Account 必须使用匹配环境。本页解释配置契约与变更影响；变量明细及本地命令见开发配置，生产变更步骤见运维。公开资料不包含任何实际密钥或私有配置文件。\n\n## 环境一致性\n\n| 环节 | 需要匹配的内容 | 配错的表现 |\n| --- | --- | --- |\n| App → 聊天服务 | API 基地址、HTTPS、回跳 scheme、客户端 state | 登录回到错误 App、换票失败、会话不能恢复 |\n| 聊天服务 → Account | issuer、client ID、机密凭据、回调地址和授权范围 | 授权端拒绝、token 交换失败或用户信息不可读 |\n| Account 浏览器 | 来源、Cookie、RP ID、可信代理 | 会话丢失、通行密钥失败、Host 或来源被拒绝 |\n| 通话 | LiveKit 地址、签发配置与设备网络 | 信令已接收但房间或媒体不可用 |\n| 城市、识别、推送 | 城市数据库、模型、APNs 与对应平台配置 | 相关功能受限，而非整个服务必然失败 |\n\n开发环境的 localhost、Android 模拟器宿主别名与真机网络地址各有用途。Release 包应核对真正的服务器地址和回调配置，不能把调试覆盖或本地 fixture 配置带入分发产物。\n\n## 原生端配置\n\niOS 的 STARLINE_CHAT_BASE_URL 仅用于 Debug 覆盖；HTTP 仅允许回环。默认聊天地址为 https://chat.thestarline.cn。最低系统为 iOS 17，Swift 语言版本为 6；当前工程以 iPhone 为设备家族，iPad 与 watchOS 不能由此自动推断已交付。\n\nAndroid 的 starlineChatBaseUrl 是构建属性，默认同样指向正式聊天服务。原生授权地址校验只信任正式 Account HTTPS 来源，没有 Debug localhost 例外；仅覆盖聊天基地址不能完成隔离 Account 原生登录。最低 API 26，目标/编译 API 35，Java 17。当前仓库声明 AGP 8.7.3 和 Gradle Wrapper 9.2.1；这一组合需要在所选环境实际验证兼容性，本技术文档没有执行新的原生构建。构建成功还需要继续检查签名、manifest、实际 API 地址和真机行为。\n\n## 服务启动和可选能力\n\n聊天服务要求 Node.js 24 或以上，使用内置 SQLite；Account 使用独立 MySQL。聊天数据库、身份数据库、静态文档内容和设备本地数据不是同一数据集。服务的健康 HTTP 响应不自动覆盖外部身份、模型、媒体或推送依赖。\n\nCHAT_CRYPTO_NODE_BUNDLE 指向 MLS 验证器产物；配置该产物不会自动切换所有客户端的主消息路径。CHAT_MEMORY_MODEL_URL 与模型名称配置影响服务器识别适配器，而不是启用记忆云同步。APNs 和 LiveKit 的可用性既受配置影响，也受证书、网络、设备和平台状态影响。\n\nCHAT_PROFILE_ENCRYPTION_KEY 等持久密钥与既有资料有关。轮换前需分析解密、索引、会话和回滚影响，不能把随意重建配置当作故障修复。客户端只保存必要会话材料，服务端机密凭据不得放入 App 包、下载脚本或公开文档。\n\n## 配置变更的检查顺序\n\n1. 识别变更归属：设备、聊天、Account、媒体、推送或静态站。\n2. 核对当前运行基线与目标版本，保留可恢复的配置和数据，不回显实际值。\n3. 在隔离环境验证格式与依赖；测试只使用合成账号和合成数据。\n4. 对原生包重新构建并核对产物；对服务执行对应配置、迁移和健康检查。\n5. 运行真实路径：授权回跳、换票、会话恢复及受到影响的业务步骤。\n6. 记录已验证范围、未覆盖设备和待办，不把一个检查结果推广为全部能力通过。\n\n## 文档站的配置\n\n技术文档由 Markdown 生成静态文件，没有服务端账号、数据库或访问令牌。站点基本地址固定为 https://app.thestarline.cn，构建时同步生成 canonical、站点地图和索引文件。部署只需要静态产物以及正确的 Markdown MIME；不需开放 Account 或聊天权限。构建说明保存在文档源目录的维护指南，公开抓取规则见文档访问与引用。"},{"product":"星线 App","title":"配置变量与启动校验参考","url":"/app/configuration-reference/","text":"# 配置变量与启动校验参考\n\n本页从配置加载器、服务入口、构建文件和适配器提取变量名、默认行为与失败条件，不使用环境文件中的实际值。Account 以其当前维护的主源码为准；App 独立工作树中的 identity/ 是历史副本，不能作为现行生产配置模板。以下描述是源码契约，不是运行配置或业务可用性的回读结果。环境选择见开发配置，维护流程见故障处理手册。\n\n## 配置的读取顺序与分类\n\nAccount 的 npm start、dev、migrate 会通过 Node 的环境文件参数加载配置；生产构建入口直接读取进程环境，Compose 还会增加容器环境和变量插值。聊天 npm start 本身不会自动加载本地配置文件，需由启动器或 Node 参数注入。对同名值应核对最终进程环境的来源，不能只看到某份文件就认定容器已使用它。检查时报告变量是否存在、格式是否合法、来源与版本，避免回显实际值。\n\n| 分类 | 例子 | 处理方式 |\n| --- | --- | --- |\n| 服务端秘密 | 身份主密钥、PII 密钥、客户端 secret、云服务 secret、签名私钥 | 受保护系统注入；不进入仓库、App 包、日志或文档 |\n| 稳定加密依赖 | IDENTITY_SECRET、IDENTITY_PII_KEY、CHAT_PROFILE_ENCRYPTION_KEY | 与数据版本一起备份；轮换前设计迁移，不能随意重建 |\n| 标识与策略 | issuer、origin、client ID、topic、模板 ID、开关 | 不一定是秘密，仍需核对环境与变更影响 |\n| 本机开发配置 | API 端口、Debug 地址、测试命名空间 | 仅用于隔离环境，不能冒充生产回执 |\n\n本页用“未设置”描述没有默认值；不是建议将变量设置为空字符串。Account 多数变量用 || 取默认值，聊天多数使用 ??，因此聊天的空字符串通常不会回退到默认值。所有布尔开关只有源码指定的精确字符串才启用，1、TRUE 与 true 不能随意互换。\n\n## Account 基础与持久化\n\n| 变量 | 默认或格式 | 必填条件与影响 |\n| --- | --- | --- |\n| NODE_ENV | 仅精确 production 进入生产分支，其他为 development | 必须显式核对模式，不能靠目录名称判断 |\n| IDENTITY_HOST / IDENTITY_PORT | 本机 IPv4 回环 / 9100 | 容器通常由运行配置覆盖；端口转为 Number |\n| IDENTITY_DATABASE_URL | 未设置 | 所有正常服务入口必需；指向 MySQL |\n| IDENTITY_DB_PREFIX | 空前缀 | 仅字母、数字、下划线；改变它会切换表命名空间 |\n| IDENTITY_SECRET | 空值，启动校验要求至少 32 字符 | 生产拒绝公开示例标记；保护协议材料与 HMAC |\n| IDENTITY_PII_KEY | 64 位十六进制，解码后 32 字节 | 生产必需；已加密数据库在任何模式下均需匹配原密钥 |\n| IDENTITY_PII_MIGRATION_APPROVED | 仅精确 1 表示已批准 | 有旧联系方式、flow 或 challenge 数据时，迁移前需验证备份并明确批准 |\n| IDENTITY_AUDIT_RETENTION_DAYS | 180 | 供清理策略读取；修改前核对保留要求与实际清理任务 |\n| IDENTITY_RATE_LIMIT | 20，正安全整数 | 与固定 600 秒窗口配合；不是各业务独立限额的统一替代 |\n| IDENTITY_BOOTSTRAP_EMAIL | 未设置 | 仅首次管理员初始化脚本读取；不是持续服务的登录凭据 |\n| IDENTITY_DB_PASSWORD / IDENTITY_DB_ROOT_PASSWORD | 当前 Compose 必需的插值变量 | 分别用于应用数据库用户和数据库 root 初始化；属于秘密，不能以示例口令部署 |\n| IDENTITY_BIND_PORT | 当前 Compose 默认 9100 | 只改宿主映射，不改变容器内服务端口；不是 IDENTITY_PORT 的同义项 |\n\nPiiProtector.fromHex 拒绝错误长度或非十六进制密钥。数据库保存密钥检查标记，缺密钥、错误密钥或迁移后残留不一致都会拒绝继续。当前迁移顺序为 001.sql 至 007.sql，随后执行 PII 与补列处理。迁移审批开关只表示维护流程已满足前置条件，不会替维护者自动备份，也不是修复错误密钥的方法。\n\n## 来源、管理与协议\n\n| 变量 | 默认行为 | 启动或运行约束 |\n| --- | --- | --- |\n| IDENTITY_ORIGINS | 开发允许本机 9100、9101 的两种回环来源；生产无默认列表 | 至少一个精确 origin；不得带路径；生产只允许批准的 Account 与管理站 HTTPS 主机 |\n| IDENTITY_ACCOUNT_ORIGIN / IDENTITY_OPEN_ORIGIN | 开发为 localhost 的 9100 / 9101；生产为各自正式 HTTPS 来源 | 决定页面、协议及来源判断，需与代理和客户端一致 |\n| IDENTITY_WEBAUTHN_RP_ID | 开发 localhost，生产为正式 Account 主机 | RP 与 origin 是不同字段，已有通行密钥不能任意迁移到其他 RP |\n| IDENTITY_TRUST_PROXY | false | 逗号分隔的明确可信代理；拒绝 *，不能复制其他网络的配置 |\n| IDENTITY_OIDC_JWKS_FILE | 开发可生成并加密持久化签名材料 | 生产必需稳定私有 JWKS；RSA、RS256、use=sig、kid，私钥至少 2048 位 |\n| IDENTITY_ADMIN_PASSKEY_REQUIRED | 仅精确 true 启用 | 当前主源码不再硬编码生产必为 true；管理授权仍需按当前策略与服务端角色检查 |\n| IDENTITY_REAL_NAME_ENABLED | 仅精确 true 启用 | 启用时要求可用 PII 保护器；开关不代表外部实名能力已验收 |\n| IDENTITY_DEV_API_PORT | 9100 | 仅 Vite 开发代理使用，不改变生产服务监听端口 |\n| IDENTITY_PREVIEW_URL | fixture 脚本默认本机 9100 HTTP | 仅该旧预览脚本读取；限制本机、非生产，不解决当前 captcha 契约差异 |\n\nAccount 会话 28800 秒、OTP 300 秒、验证事务 1800 秒、OTP 尝试 5 次、重发间隔 60 秒是当前加载器中的固定配置值，不能凭这些字段名推导存在对应环境变量。公开资料中的角色与通行密钥建议属于运维要求，实际可接受的认证方法需依据现行 admin-policy.ts 和目标会话验证。\n\n## 验证码、人机验证与第三方身份\n\n| 变量 | 默认及启用条件 | 缺失时的行为 |\n| --- | --- | --- |\n| IDENTITY_DELIVERY | 精确 development 选择开发投递，其余选择真实适配器 | 生产禁止开发适配器；真实配置缺失不模拟投递成功 |\n| TENCENT_SES_SECRET_ID / TENCENT_SES_SECRET_KEY / TENCENT_SES_TEMPLATE_ID | 三项齐全，模板 ID 为正安全整数 | 当前生产邮件只接受此 SES 路径；SMTP 不作为生产回退 |\n| ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET / ALIYUN_SMS_SIGN_NAME / ALIYUN_SMS_TEMPLATE_CODE | 四项齐全启用阿里云短信 | 无可用适配器时返回投递不可用；当前发送校验中国大陆手机号与六位验证码 |\n| SMTP_HOST / SMTP_FROM / SMTP_USER / SMTP_PASSWORD | 常规 SMTP 要求四项；仅供非生产备用 | 即使四项齐全，生产邮件也不会使用 SMTP |\n| SMTP_PORT / SMTP_SECURE | 普通端口默认 465；非精确 false 时 secure=true | 保持 TLS 验证；选择其他端口时要同时核对 TLS 模式 |\n| SMTP_LOCAL_SINK | 精确 true；端口默认 9125，无认证和 TLS | 仅非生产回环；生产或外部主机直接拒绝 |\n| TENCENT_CAPTCHA_APP_ID | 正整数形式、5–16 位十进制且为安全整数 | 公开配置返回 null 或验证不可用 |\n| TENCENT_CAPTCHA_APP_SECRET / TENCENT_CAPTCHA_SECRET_ID / TENCENT_CAPTCHA_SECRET_KEY | 与有效 App ID 四项齐全 | 注册等受保护入口返回 CAPTCHA_UNAVAILABLE；不能靠开发投递绕过 |\n| WECHAT_APP_ID / WECHAT_APP_SECRET、QQ_APP_ID / QQ_APP_SECRET | 各提供商成对配置 | configured 与后台启用、平台开通、真实回调是不同状态 |\n| ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY | 应用 ID、RSA2 私钥与平台公钥 | 不完整时不能据此启用支付宝身份验证 |\n\n当前 SES 模板参数为带三位分组显示的验证码，发件地址由适配器代码固定，不由 SMTP_FROM 控制。旧副本中的 TENCENT_SMS_* 不是现行主源码的短信配置。当前开发注册同样有人机验证门禁；仅 env=test 的测试构造允许专用注入或隔离跳过，正常服务没有通用免验证开关。\n\n微信小程序还有 WECHAT_MINI_APP_ID、WECHAT_MINI_APP_SECRET，以及 IDENTITY_WECHAT_MINI_ENABLED、IDENTITY_WECHAT_MINI_PUBLISHED、IDENTITY_WECHAT_MINI_LAUNCH_ENABLED、IDENTITY_WECHAT_MINI_VERIFICATION_ENABLED 四个精确 true 开关。配置完成、预览验证、正式 URL Link 与公开入口分别检查；小程序 ID 还不能与网页微信提供商 ID 混用。配置中的 published 不等于官方发布回执，当前暂停要求不因改配置而自动解除。\n\n## 聊天、媒体与可选依赖\n\n| 变量 | 默认与校验 | 所属能力 |\n| --- | --- | --- |\n| CHAT_HOST / CHAT_PORT / CHAT_DATABASE_PATH | 本机回环 / 4210 / ./data/chat.sqlite | 路径相对工作目录；确认持久卷映射，避免误建空库 |\n| CHAT_ISSUER_URL / CHAT_PUBLIC_URL | 未设置；生产要求批准的正式 issuer 与 HTTPS public URL | OIDC discovery、授权回调与站点身份 |\n| CHAT_CLIENT_ID / CHAT_CLIENT_SECRET | 未设置；生产要求存在，secret 至少 24 字符 | confidential 客户端；不放入原生 App |\n| CHAT_PROFILE_ENCRYPTION_KEY | 未设置则使用客户端 secret 作为派生输入 | 资料联系电话加密；轮换可能影响旧数据解密 |\n| CHAT_CRYPTO_NODE_BUNDLE | 未设置则校验器为 null | 设置时要求导出两个 MLS 校验函数，错误文件会导致启动异常 |\n| CHAT_TRUST_PROXY_ALLOWLIST | 无可信代理；逗号分隔并去空白 | 仅合法地址或 CIDR；IP 城市不信任任意转发头 |\n| CHAT_IP_CITY_DATABASE_DIR | 仓库固定城市库目录 | 需匹配版本与摘要；缺库、坏库返回空城市，不制造位置 |\n| CHAT_MEMORY_MODEL_URL / CHAT_MEMORY_MODEL | 非生产默认本机 11434 HTTP 与 qwen2.5vl:7b；生产未设置 | 识别接口；请求文字发送给配置模型，不形成云同步 |\n| LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET | 未设置 | 通话媒体必需；健康接口不验证这三项是否真实可用 |\n| APNS_TEAM_ID / APNS_KEY_ID / APNS_KEY_PATH / APNS_VOIP_TOPIC | 未设置；四项齐全才可发送 | 配置路径读取失败或私钥格式错误可在启动时报错 |\n| APNS_ENVIRONMENT | 精确 sandbox 才走测试 APNs，其余走生产 | 与 App 签名环境匹配，不能以默认值推断配置正确 |\n\ncityStickerDirectory、媒体房间清理间隔和 APNs 测试 origin 是测试构造参数，并没有同名环境变量入口。聊天设备 session 默认 24 小时也没有公开环境变量或 refresh 路由。完整约束见限制与错误。\n\n## 原生构建差异与核对方法\n\niOS STARLINE_CHAT_BASE_URL 仅 Debug 读取，接受 HTTPS 或 HTTP 回环，拒绝用户名密码、查询与 fragment；Release 忽略覆盖。iOS 工程的 APNS_ENVIRONMENT 在 Debug 为 development、Release 为 production，服务端测试环境字符串却是 sandbox，二者不得直接照抄。Android starlineChatBaseUrl 编译为 BuildConfig.CHAT_BASE_URL，Debug 允许本地明文 API，Release 清单禁止；NativeAuth.kt 两种构建均只信任正式 Account HTTPS，没有本地授权例外。ANDROID_HOME、JAVA_HOME、ANDROID_NDK_HOME、CARGO 是工具链定位配置，不能当作产品服务变量。文档生成器的 STARLINE_DOCS_NODE_MODULES 仅增加 Markdown 构建依赖的查找位置，不影响 App、Account 或聊天运行配置。\n\n只查代码中的声明可使用：\n\n``sh\nrg -n 'env\\.[A-Z_]+|process\\.env' identity/server identity/client/vite.config.ts\nrg -n 'environment\\.[A-Z_]+|production' chat-service/src/main.mjs\nrg -n 'STARLINE_CHAT_BASE_URL|#if DEBUG' ios-app/Sources/Features/Chat/ChatAPI.swift\nrg -n 'gradleProperty|versionName|versionCode' android-app/app/build.gradle.kts\n``\n\n第一条应在 Account 当前源码仓库执行，其余在 App 仓库执行。不要把搜索范围扩展到环境文件、运行目录或秘密材料。变更结果按测试矩阵验证，再按发行清单记录实际交付阶段。"},{"product":"星线 App","title":"测试、隔离与验收边界","url":"/app/testing/","text":"# 测试、隔离与验收边界\n\n本文给出仓库实际存在的测试入口，说明各层测试能够证明什么，以及如何保护本机数据。命令供维护者在具备条件的隔离环境执行；本次文档编写没有重跑产品测试。文中历史数量仅指相应日期的保存记录，不能作为当前版本、线上部署或所有设备均可用的保证。\n\n## 选择最小有效范围\n\n修改账号协议时优先运行协议与原生登录测试；修改记忆模板时运行本机解析、加密存储和相应界面流程；修改下载站时核对真实浏览器视口、键盘操作和下载配置。不要仅因某个聚焦测试通过就扩大结论，也不要为只读文档更新启动整套模拟器。版本和交付边界见版本与分发状态。\n\n| 层级 | 主要覆盖 | 不包含的结论 |\n| --- | --- | --- |\n| 静态检查与编译 | 类型、语法、依赖、资源打包 | 真实登录、投递和可用界面 |\n| 单元与本地集成 | PKCE、状态机、权限、持久化、错误恢复 | 公网平台配置与跨网络表现 |\n| 模拟器 UI | 元素位置、流程、键盘、无障碍树 | 实体相机、蜂窝网络、正式推送 |\n| 授权真机验收 | 目标设备上的完整真实流程 | 未测系统版本、其他平台和永久稳定性 |\n| 商店与生产回执 | 上传、提审、审核、发布各自状态 | 未在回执中确认的后续步骤 |\n\n## 服务端测试\n\n身份服务测试必须在 Account 当前维护仓库执行，App 工作树的 identity/ 副本存在历史漂移。MySQL 集成测试使用固定的独立开发库：回环端口 3310、库名 identity_dev，随机生成 t 加 12 位十六进制与下划线的表前缀，结束时只删除该命名空间；不要把它改指生产库或现有业务库。非生产 SMTP 适配器测试另需 Mailpit 端口 9125、API 9135。这只验证备用适配器，不代表当前生产 SES 邮件或阿里云短信已经送达。\n\n``sh\ncd identity\nnpm test\nnpm run typecheck\nnpm run build\nnpm --prefix client test\nnpm --prefix client run typecheck\nnpm --prefix client run build\n`\n\nnpm test 实际调用单并发的 tests/*.test.ts。当前主源码还应覆盖 PII 加密／旧数据审批／错误密钥拒绝、captcha、手机号与邮件投递、管理策略、资料和手机授权等新增模块，不能只运行旧副本的文件集合。tests/pii.test.ts 用合成数据验证迁移；tests/captcha.test.ts 验证专用依赖注入，正常开发服务不提供同等绕过开关。旧注册 fixture 缺少 captcha，主仓库也没有 App 副本的本地聊天登记脚本，不能作为现成浏览器端到端前置条件。\n\n协议测试包含软件认证器产生的真实密码学证明，但软件认证器是隔离 fixture，不是物理通行密钥验收。前端测试使用 DOM 测试环境，仍需浏览器核对表单、焦点、系统确认框和实际回调。依赖安装与构建可能访问包源，应在受控开发环境执行。详细场景、断网与双端期望见测试矩阵。\n\n聊天服务测试以 Node 测试运行器执行，测试自行构造合成用户、临时 SQLite 和本机 HTTP 监听。原生登录测试用合成身份响应覆盖进程重建、state、PKCE、单次票据和失败边界，不会完成真实 Account 登录。\n\n`sh\ncd chat-service\nnode --test tests/native-auth.test.mjs\nnode --test tests/city-location.test.mjs tests/city-stickers.test.mjs\nnode --test tests/enterprise-onboarding.test.mjs tests/enterprise.test.mjs tests/enterprise-auth.test.mjs\nnpm test\n`\n\n最后一条是完整聊天回归；只在变更影响范围与可用资源需要时执行。服务端状态、权限、APNs 请求格式测试通过，仍不能证明真实通知到达或两端实际接通。\n\n## iOS 原生测试\n\n无需启动 iOS 模拟器的登录聚焦检查入口是：\n\n`sh\nzsh ios-app/scripts/test-native-auth.sh\n`\n\n该脚本将实际授权、API、Keychain 和模型源文件复制到临时 Swift 包，单并发运行 ChatNativeAuthTests，结束删除临时包。测试覆盖随机 verifier、RFC 7636 向量、可信目的地、持久化失败、独立 Keychain 恢复、冷回调、取消、过期、重放、并发、迟到响应和网络重试。它不是完整 iOS 应用构建。\n\n需要界面验证时，先创建专用 QA 模拟器并把 QA_SIMULATOR_UDID 设为它的标识，再串行运行指定用例：\n\n`sh\nxcodebuild -project ios-app/StarlineWorkbench.xcodeproj \\\n  -scheme StarlineWorkbench \\\n  -destination \"platform=iOS Simulator,id=$QA_SIMULATOR_UDID\" \\\n  -parallel-testing-enabled NO \\\n  -only-testing:StarlineWorkbenchUITests/NavigationUITests/testSignedOutProfileOpensAccountWebsiteDirectly \\\n  test\n`\n\n该用例核对系统“继续”提示和正式 Account 登录表单。不得把出现表单写成“真实账号已登录”。完整验收还需用户在真实表单完成身份验证、授权后回到 App、重开恢复、取消保留原会话，并核对 iPhone 与审核所涉 iPad。安装前不卸载已有 App，不清除 Keychain 或记忆。\n\n## Android 测试\n\n以下任务不要求启动设备，适合先验证 JVM 状态机和 APK 打包。资源受限环境可使用单 worker、限制堆与 Kotlin 进程内编译：\n\n`sh\ncd android-app\n./gradlew :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest \\\n  --max-workers=1 \\\n  -Dorg.gradle.jvmargs=-Xmx1g \\\n  -Pkotlin.compiler.execution.strategy=in-process\n`\n\nNativeAuthTest 覆盖 PKCE、state、过期、取消、事务替换与退出竞态。必须核对构建使用的 starlineChatBaseUrl，因为默认地址是正式聊天服务。需要隔离网络时采用本地开发中的显式覆盖。\n\n设备测试只在已选定且允许使用的 QA 设备上运行，避免同时连接多台导致误选：\n\n`sh\n./gradlew :app:connectedDebugAndroidTest \\\n  -Pandroid.testInstrumentationRunnerArguments.class=cn.thestarline.workbench.NativeAuthSessionTest \\\n  --max-workers=1 \\\n  -Dorg.gradle.jvmargs=-Xmx1g \\\n  -Pkotlin.compiler.execution.strategy=in-process\n`\n\n该测试类使用独立文件、缓存、设置命名空间和设备回环 HTTP fixture，覆盖加密 pending、会话恢复、401 清理、503 保留与退出后的迟到结果。设备测试重建 AppState 的证明范围与系统真正杀进程后的回跳不同，后者要保留独立的端到端记录。\n\n## 医疗健康、本机数据与视觉验收\n\n保存记录 docs/qa-memory-medical-health-2026-10-21.md` 描述了双端医疗模板的历史验收：八项可选字段、两段各 2000 字多行内容、搜索、编辑、附件、重开读取和删除合成记录。iOS 当时记录 37 项本机测试以及原生 UI 流程；Android 当时记录 69 项单元测试与两项设备流程。这些数量属于特定快照，本次没有复跑，也未覆盖全部 OS、实体设备、全部字号或跨设备同步。\n\n新测试只创建自己的合成记录，不读取真实用户记忆正文、token 或验证码。对数据保护的比较可以核对密文哈希与文件数量，不把密文或 Keychain 内容复制到公开报告。视觉验收应检查窄屏、长字段、键盘、大字号、保存按钮、底栏与弹窗位置；图标采用统一 SVG 尺寸、线宽和颜色，不用装饰 Emoji 替代。预览持续展示，不设置自动关闭时限。\n\n## 如何记录结果\n\n每次写清提交或工作区基线、命令、目标设备与系统、环境、退出码、失败数、产物位置和未测范围。出现失败保留首个有效错误与日志，修复后重跑相关用例；不要删掉失败记录，只保留成功截图。历史 Android 生产记录已经证明一次真实账号授权自动返回与 force-stop 后冷启动恢复，设备会话为 24 小时；它不代表连续运行 24 小时或永久登录。iOS 完整真实账户闭环、正式分发、推送与跨网通话仍分别待验收。故障分层方法见故障定位。"},{"product":"星线 App","title":"分层测试与双端验收矩阵","url":"/app/qa-matrix/","text":"# 分层测试与双端验收矩阵\n\n本页将源码检查、自动测试、界面验证、真实设备与生产验收分开。矩阵中的“期望”是测试断言或必须满足的产品边界，不表示本轮已经运行。历史记录按其日期和目标保留；所有新增执行都应记录当前基线、设备、环境、命令、结果与剩余范围。基础命令见测试，模块入口见模块地图。\n\n## 环境与证据约定\n\n每个场景至少记录 App 平台、构建、OS、网络、Account／聊天环境、合成账号代号和前置关系。正常、拒绝、断网与重启不能混为同一用例。一次失败若发生在环境准备阶段，应记为“前置条件未满足”，不能删掉或改成测试跳过后再宣称功能通过。\n\n| 证据层 | 能确认 | 不能替代 |\n| --- | --- | --- |\n| 源码与配置 | 路由、类型、校验、状态机、默认值 | 实际运行配置与业务结果 |\n| 单元／本地集成 | 可重复的输入、状态和权限断言 | 真实平台回调与公网服务 |\n| 系统 UI 测试 | 指定设备的元素、焦点和操作路径 | 其他屏幕、OS、硬件和商店环境 |\n| 授权真机闭环 | 指定账号、设备与网络的实际结果 | 永久登录、所有地区或所有用户 |\n| 正式回执 | 上传、提审、审核与发布的各自阶段 | 未记录的后续动作 |\n\nAccount 自动测试使用专用 identity_dev MySQL 与随机表前缀；聊天测试使用内存库或临时 SQLite；原生设备测试使用自己的文件和设置命名空间。禁止把生产 token、真实记忆、私人聊天或现有设备数据作为通用夹具。测试前后可核对密文数量和摘要，不读取正文证明“没有修改”。\n\n## 登录与设备会话\n\n| 场景 | 正常与拒绝 | 断网／重启及双端期望 | 验证方法 |\n| --- | --- | --- | --- |\n| 发起原生授权 | 随机 verifier、S256、保存 pending 后才开浏览器；错 URL 或存储失败拒绝打开 | 连点只创建当前事务；退出后的迟到响应不重新登录 | iOS ChatNativeAuthTests、Android NativeAuthTest、服务端 native-auth 测试 |\n| state 与 ticket | 正确 proof 单次换票；缺失、错误、重复、取消、过期均不替换原有效账号 | 回调不能从另一环境或设备拼接；重放不产生第二个 session | 服务端验证与两端隔离状态机断言 |\n| 网页到 App | 系统浏览器显示正确 Account、用户完成授权、自动回 App | 真正替换进程后回跳；需保存完整阶段与设备时间线 | 授权真机人工闭环；iOS 系统“继续”提示单独观察 |\n| 会话恢复 | /me 成功恢复资料；401 清理当前凭据 | 503、超时和临时离线保留凭据；恢复网络后重试 | Android NativeAuthSessionTest；iOS 授权与 Keychain 测试，加真机恢复 |\n| 退出竞态 | 本机退出并停止轮询、通话和待处理授权 | 服务端退出失败不能显示为已撤销全部会话；旧请求不能回写新账号 | 测试迟到响应、同机账号切换及离线退出 |\n\niOS 会保存待换票 ticket 与 API 来源，Android pending 没有相同字段，不能把 iOS 的“换票时中断可重试”结论直接套用 Android。Android 当前没有 Debug 本地 Account 信任例外，隔离普通 API 可测，隔离浏览器原生登录仍需受控测试配置。聊天会话 24 小时，无 refresh-token 路由；不得用修改系统时钟或共享真实 token 代替可控时钟测试。\n\n## 联系人、消息与在线状态\n\n| 模块与场景 | 正常期望 | 拒绝／断网／重启／双端期望 | 验证入口 |\n| --- | --- | --- | --- |\n| 好友申请 | 唯一好友 ID 或当前临时码发申请，对方同意后可私聊 | 自己、未知、过期码、限流与屏蔽拒绝；重开后关系仍一致 | chat.test.mjs、management.test.mjs，两合成账号与第三观察账号 |\n| 群成员 | 群主邀请、改名、移除；成员按权限读取 | 非成员不可读；被移除后不可发送或拿新媒体凭证；新成员历史边界正确 | group.test.mjs、通话成员变化测试 |\n| 文本去重 | 成功写入一条并产生当前用户事件 | 丢响应后使用原 clientId 重试只保留一条；换内容／换范围复用 ID 拒绝 | chat.test.mjs、MessageSendIdCoordinatorTest、iOS 聊天测试 |\n| 已读与删除 | 已读位置单向前进；本人删除只改变自己的视图 | 旧读位置不倒退；恢复聊天不恢复删除前历史；双方结果分别核对 | 服务端权限断言、双端可见列表与历史分页 |\n| 事件同步 | 消费普通事件 ID 并重新读取资源 | 断线保留已处理游标；重建快照不漏消息；不把消息 ID 或 MLS sequence 当普通事件游标 | chat.test.mjs 与真实网络中断后列表核对 |\n| 媒体 | 上传者可读，发送引用后授权接收者可读 | 未发送、外人、删除可见历史后不能越权；重开不丢引用；大文件拒绝 | media.test.mjs、profile.test.mjs 与下载字节摘要 |\n| 新事 | 好友、指定人、已登录公开、仅本人各自可见 | 解除关系、屏蔽、隐藏、删除立即影响读取；视频 Range 边界正确 | moments.test.mjs，至少三合成账号逐项比对 |\n| 城市贴纸 | 内置 ID 与三端素材一致，设备城市优先 | 权限拒绝用 IP 或手选；离开会话／换账号丢弃迟到结果；坏素材不伪造成功 | city-location、city-stickers 服务端测试和原生城市测试 |\n\n消息行应同时观察发送者和接收者，不能只凭本机气泡出现判断服务端成功，也不能凭 HTTP 200 判断对方已读。断网测试分别覆盖发送前、服务端提交后丢响应、媒体已上传但消息未发送三个时点；记录相同 clientId 的行为，避免测试本身重复点击产生另一条合法新消息。\n\n## 位置、通话与系统能力\n\n| 场景 | 期望 | 需要独立验证 |\n| --- | --- | --- |\n| 实时位置 | 用户明确开始；PUT 只更新已开启共享；停止、离群或权限变化处理采集与服务端记录 | 两端前后台、粗略权限、最后更新时间、断网后旧坐标与手动停止 |\n| 通话状态 | 成员才能发起／接听；重试不新建通话；响铃超时、忙、拒绝、挂断与群离开终态正确 | calls 与 call-media 测试；至少两设备逐角色检查 |\n| 媒体连接 | 只有 active 当前参与者可获得短时房间 token | 麦克风、扬声器、蓝牙、摄像头切换、双向画面与声音、网络切换 |\n| 系统来电 | 正确签名环境、topic、session 与设备注册 | APNs 真正到达、锁屏展示、后台、取消来电、多设备竞争接听 |\n| 相机与文档 | 只读取用户选择的内容，拒绝后仍有可用替代入口 | 实体相机、无相机、永久拒绝、系统撤权、文件授权失效 |\n| MLS 独立协议 | 密钥包、Welcome、Commit、ack、激活、密文重试与发件人校验 | iOS 独立传输与 Rust 测试；不能写成主聊天及全部附件 E2EE 通过 |\n\nLiveKit 本机入房与 APNs 请求格式仅属于部分证据。媒体连接成功还应在双方分别断开与恢复、对方退群、挂断后迟到 token 等场景检查，不能让旧异步操作恢复已结束通话。普通聊天、新事、位置与推送元数据的数据可读性，应与安全边界一致。\n\n位置退出必须单独验收：当前 iOS 退出只停止本机采集，不逐范围 DELETE 远端共享；聊天 session 注销或到期也不会自动删除 live_locations，其他仍有资格成员可能继续读取最后位置。Android 远端清理采用 bestEffort，失败时同样可能残留。用合成共享分别测试退出、到期、DELETE 成功与失败，记录本机是否采集、远端是否仍有记录、其他成员能否读取，不能把一项成功当作三项全部完成。\n\n## 本机工作台、记忆与转出\n\n| 场景 | 正常与拒绝期望 | 重启／离线／双端区别与验证 |\n| --- | --- | --- |\n| 任务与收藏 | 新建、编辑、筛选、去重与最近访问按本机规则保存 | 离线可读写；同设备重开保持；另一设备不自动同步；LocalWorkspaceStoreTests、任务 UI 与 Android 工作台流程 |\n| 记忆与附件 | 合法内容加密保存；空模板不落盘；字段按上限处理 | 重开、编辑、搜索与附件字节一致；iOS/Android 文件格式分别检查，不直接互拷 |\n| 损坏或丢密钥 | 显示无法读取并阻止覆盖 | 不把损坏库当空库；不新建密钥覆盖旧密文；MemoryStoreTests 与 Android 医疗／转出测试 |\n| 医疗与宠物 | 八项医疗字段、两段 2000 字与换行保留；宠物优先规则正确 | 英文缩写与动物词边界、未知旧字段、附件、超长内容分别回归；不用真实医疗记录 |\n| 系统推荐 | iOS 系统模型可用时生成待确认结构，不可用则本机回退 | Android 固定关键词路线独立；服务器识别 API 不等于本机识别，也不等于云同步 |\n| 授权转出 | 当前用户取得额外许可后才生成明文 JSON；取消不生成完整输出 | 错账号、过期、断网、文件写失败清理部分文件；当前 Account 跨服务契约未闭环，需保持待验收 |\n\n视觉流程另检查 320 级窄屏布局、大字号、键盘、长文件名、长文本、列表空态、底栏、弹窗与保存按钮。无障碍标识只用于测试定位，仍需实际核对朗读标签、焦点与点击区域；关闭动画后页面必须可操作。预览不按展示时长自动关闭。\n\n## 命令与执行记录\n\n以下命令展示真实测试入口，本次未执行。服务端命令在 App 仓库；Account 测试应进入其当前维护仓库另行运行：\n\n``sh\nnode --test chat-service/tests/native-auth.test.mjs chat-service/tests/chat.test.mjs\nnode --test chat-service/tests/media.test.mjs chat-service/tests/moments.test.mjs\nnode --test chat-service/tests/calls.test.mjs chat-service/tests/call-media.test.mjs chat-service/tests/live-location.test.mjs\nzsh ios-app/scripts/test-native-auth.sh\n`\n\n`sh\ncd android-app\n./gradlew :app:testDebugUnitTest --max-workers=1 \\\n  -Dorg.gradle.jvmargs=-Xmx1g -Pkotlin.compiler.execution.strategy=in-process\n`\n\n`sh\ncd identity\nnpm run typecheck\nnpm test\nnpm --prefix client test\n``\n\n设备用例必须先指定允许使用的 QA 目标，串行执行，不卸载现有 App，不 wipe 模拟器或清 Keychain。历史医疗 QA 记录了 iOS 37 项本机测试、Android 69 项单测及专项 UI；原生授权记录包含 iOS 16 项测试与 Android 85 项 JVM、5 项隔离设备测试。Android 另有一次真实授权与 force-stop 冷启动记录；iOS 完整真实账号回跳、审核 iPad、生产推送、跨网音视频、正式包和手表独立版仍需新的证据。\n\n每条验收结果填写：场景 ID、当前基线、期望、实得、退出码、日志摘要、目标设备、是否改变夹具、复验结果与未覆盖项。失败记录保留并关联修复后的结果；统计数量只能帮助追溯，不能替代具体断言或成为生产功能保证。发行门禁见发行清单。"},{"product":"星线 App","title":"服务运行、迁移与数据保护","url":"/app/operations/","text":"# 服务运行、迁移与数据保护\n\n星线 App 的运行涉及静态下载站、Account 身份服务、聊天服务，以及可选的模型、LiveKit 和 APNs。本文介绍源码中可确认的运行机制与维护原则，不提供生产凭据、服务器地址或私有部署目录。本次仅核对代码与历史文档，没有连接生产、读取数据库、执行迁移或演练恢复。\n\n## 运行拓扑与边界\n\n身份服务使用 Node.js 24、MySQL 8.4 和独立 Account／管理站来源；其代码、命令与迁移以当前维护仓库为准，App 工作树的 identity/ 不能当作现行部署模板。聊天服务使用 Node.js 24 与 SQLite，保存聊天、媒体、新事、设备会话和相关权限。下载介绍与公开技术文档属于静态内容，不需要数据库。App 内的记忆、个人任务和收藏存于设备，不因后端数据库备份而自动备份，也不因账号退出而删除。\n\n| 组件 | 实际入口 | 持久数据或材料 |\n| --- | --- | --- |\n| 身份服务 | 当前 Account 的 identity/server/main.ts；构建后 dist/server/main.js | MySQL、稳定身份密钥、PII 密钥、私有 OIDC JWKS |\n| 聊天服务 | chat-service/src/main.mjs | SQLite、WAL、资料加密密钥、OIDC 客户端配置 |\n| 下载与文档 | app-download/dist/ 的发布产物 | 经过核对的静态文件与下载配置 |\n| 通话与推送 | LiveKit、APNs 服务端适配器 | 各自受保护配置与运行状态 |\n\n配置变量用途见配置参考。生产身份服务会校验批准的 HTTPS 来源、身份主密钥、PII 密钥和 OIDC 签名材料。当前管理员通行密钥要求由环境开关控制，不能沿用旧副本“生产始终强制 true”的断言；应另核对 admin-policy.ts 与实际角色及认证方法。聊天生产启动要求固定批准的身份 issuer、HTTPS public URL 与 confidential 客户端。合成域名只适合测试构造，不能通过替换示例值绕过这些检查。\n\n## 构建与持续运行\n\n在当前 Account 仓库的隔离构建环境准备依赖与身份服务产物：\n\n``sh\nnpm --prefix identity ci\nnpm --prefix identity/client ci\nnpm --prefix identity/client run build\nnpm --prefix identity run typecheck\nnpm --prefix identity run build\n`\n\n身份服务的源码入口 npm start 适合本地运行；生产宿主部署由进程管理器运行构建产物，并注入受保护配置。仓库还提供 identity/Dockerfile、identity/compose.yml、chat-service/Dockerfile 与 chat-service/compose.yml。聊天镜像从同一 Rust 源码构建 Node WASM 校验器，复制固定城市库与贴纸素材，并用非 root 用户运行。Compose 将数据库映射到持久卷，应用端口默认仅绑定宿主回环；容器代理要按实际网络选择可达地址。\n\n构建命令可以在新环境准备镜像。第一条在当前 Account 仓库执行，第二条在 App 仓库执行，不能因相对路径相同而混用历史副本：\n\n`sh\ndocker compose -f identity/compose.yml build\ndocker compose -f chat-service/compose.yml build\n`\n\n这一步只构建，不等于启动、发布或健康。镜像准备后先核对现有部署基线、配置链、持久卷、回滚产物和目标服务，再按维护窗口切换。共享 Account 有独立维护来源，不能用 App 工作区的旧副本覆盖其最新代码、Compose 或代理配置。进程应持续运行，不设置用于展示的时间上限；退出时优先使用 SIGTERM 或 SIGINT，源码会关闭定时任务和连接。\n\n## 健康检查与业务探测\n\n本地检查命令是：\n\n`sh\ncurl --fail --silent --show-error http://127.0.0.1:9100/api/health\ncurl --fail --silent --show-error http://127.0.0.1:4210/health\n`\n\n身份健康接口执行 SELECT 1，因此检查了数据库连接。聊天 /health 只返回进程的 status: ok，没有逐项探测 OIDC、LiveKit、APNs、模型或数据库后续写入。公开入口还要分别验证 DNS、TLS、反向代理、Host、重定向及正确响应。生产身份接口有可信代理和 Host 约束，不应直接照搬开发 curl 判断生产不可用，也不应为了探测关闭约束。\n\n业务探测使用专用合成账号，依次确认授权、换票、/me 恢复、双方收发、媒体权限与重开读取。通话还需两台真实设备跨网络验证，APNs 还需证明通知实际到达。只记录状态和关联请求信息，不保存 token、code、完整回跳 URL、验证码或用户内容。\n\n## 迁移与版本切换\n\n身份服务启动前自动调用迁移，也提供独立入口：\n\n`sh\ncd identity\nnpm run migrate\n`\n\n当前 Account MySqlStore.migrate() 顺序处理 001.sql 至 007.sql，随后执行 PII 与补列逻辑，表前缀取自配置；部分幂等迁移会先检查现有结构。生产要求 64 位十六进制 IDENTITY_PII_KEY。已有明文联系方式、flow 或 challenge 转换前需要验证备份，并明确设置 IDENTITY_PII_MIGRATION_APPROVED=1；数据库密钥标记不匹配时拒绝启动，不能通过审批开关绕过。修改迁移前应审查 SQL、在隔离副本验证，再记录执行结果；不能由命令返回 0 推断所有业务兼容。\n\n聊天没有独立 npm run migrate，ChatDatabase 与各仓储在构造时创建表、补充列或重建特定表，企业、城市贴纸、原生授权等结构也在启动阶段处理。因此新旧进程不能同时对同一份数据库随意切换。\n\n上线顺序应为：冻结当前基线与构建产物，取得一致性备份，在隔离环境验证迁移与读写，确认客户端兼容，停止旧实例后切换，再做健康与业务验收。仓库未提供通用自动降级脚本；数据库结构已变化时，单独退回旧镜像可能不兼容，回滚需要与已演练的数据快照一起设计。\n\n## 备份与恢复原则\n\n聊天 SQLite 启用 WAL、外键和 5 秒 busy timeout。备份应使用一致性快照机制，或在有序停止写入后保存数据库及其 WAL 所需状态；不能在服务写入时只随手复制主数据库。媒体 BLOB、新事及权限也属于数据集，不能只备份消息表。不要删除 WAL、手工改表或清空数据作为通用修复步骤。\n\n身份 MySQL 与稳定 IDENTITY_SECRET、IDENTITY_PII_KEY、私有 OIDC 签名材料需要按受控流程配套保护。PII 密钥同时影响联系方式解密与查找摘要，缺失或换错会拒绝启动或无法使用旧数据。聊天的 CHAT_PROFILE_ENCRYPTION_KEY 未设置时从客户端密钥派生；更换这些值可能导致已存资料无法解密或手机号查找索引失效。密钥轮换必须先分析受影响数据与会话，实施迁移或撤销，不把“生成新密钥”当作无损恢复。具体隔离演练见故障处理与恢复。\n\n备份应加密、限制访问、设置保留期并定期检查可恢复性。恢复先在隔离环境验证结构、读取、权限和合成写入；正式切换前停止旧实例，避免双写。身份恢复后根据风险撤销旧会话与授权。管理员恢复只能由获授权维护者在受保护环境审计处理，不提供公开提权或绕过验证入口。本机记忆密钥绑定设备安全存储，服务端备份不能重建丢失的设备密钥；换机前应由用户按 App 的授权转出流程处理。\n\n## 日志、队列和保留\n\n身份日志保留方法、去除查询参数的路径、请求 ID 与响应状态，脱敏 Cookie、Authorization 和 CSRF 头。当前验证码 worker 每 250 毫秒调用 processPendingDeliveries() 处理持久队列，多实例依靠数据库锁分配任务；不能沿用旧方法名当作当前运维接口。生产邮件为腾讯 SES，短信为阿里云，SMTP 仅非生产备用；“请求已受理”“提供商接受”和“用户已收件”是三个不同结果。聊天源码将未处理异常写入标准错误，目前没有完整集中指标面板，应在进程及代理层补充错误率、延迟、重启、磁盘和资源观测，并先过滤敏感信息。\n\n身份维护入口为 npm run cleanup，按过期状态与 IDENTITY_AUDIT_RETENTION_DAYS` 清理，审计默认保留 180 天。新事未发布媒体会在启动及每小时检查中清理过期孤立项。不能把这些清理误认为完整备份或容量治理。生产状态、发布与商店进度见版本与分发状态，按症状排查见故障定位。"},{"product":"星线 App","title":"故障处理与恢复演练手册","url":"/app/incident-runbooks/","text":"# 故障处理与恢复演练手册\n\n本页按现象给出只读排查、安全修复、验证与恢复边界。命令中的 qa-fixture、qa-backup、测试数据库和连接别名均为隔离演练示例，不指向真实用户数据。本次编写没有运行这些命令或执行恢复。首先记录时间、平台、版本、网络、操作阶段与脱敏错误；不要收集密码、验证码、verifier、票据、访问 token、记忆正文或完整带参数回跳 URL。\n\n## 处理顺序与共同保护措施\n\n1. 确定影响范围：单设备、单账号、单资源、全部 API、身份上游、媒体或存储。\n2. 保留当前基线和原始错误，用只读检查确认实际运行版本、配置来源、资源与请求阶段。\n3. 对能重现的最小场景准备合成夹具；涉及写入、权限、密钥和迁移时先制定可验证的恢复点。\n4. 修复后重复原触发路径，再测拒绝、断网、重启与另一账号；记录实得，不只记录进程恢复。\n5. 关闭事件前确认未产生数据覆盖、重复业务对象或滞留资源，并注明未覆盖设备和依赖。\n\n本地健康探测只说明对应层：Account /api/health 执行数据库查询，聊天 /health 仅说明 HTTP 进程响应。公开入口还需分别验证 DNS、TLS、代理和业务。正常退出使用进程管理器的有序停止，让服务处理信号；不要把强杀、重装或删除数据库作为默认动作。\n\n## 授权网页成功但未回到 App\n\n| 阶段 | 只读检查 | 安全处理与验证 |\n| --- | --- | --- |\n| 开始 | App 目标聊天地址、授权 URL 主机、pending 保存结果 | 修复环境或存储错误后重新发起；不要手工拼接他人回调 |\n| Account | 用户是否完成身份验证与应用授权、系统浏览器是否仍显示确认 | 区分系统“继续”提示、登录表单和实际授权完成；不因表单出现而结案 |\n| HTTPS 回调 | 回调路由阶段、上游交换状态、是否过期或取消 | 对照客户端登记和当前 issuer 修复；一次性票据不反复消费 |\n| App scheme | scheme／host 注册、当前构建、返回时进程状态 | 用授权测试账号重新完成流程；实际杀进程后的回跳单独记录 |\n| 换票 | proof、state 的校验阶段、HTTP 状态、是否切换账号 | 只比较状态，不输出秘密；失败后保持原有效会话并重新授权 |\n\nAndroid NativeAuth.kt 只允许正式 Account HTTPS；覆盖聊天地址或端口转发不能建立隔离 Account 原生登录。iOS Debug 可接受受限本机环境，但 Release 不读取该覆盖。恢复验证应覆盖正常授权、取消、过期、错 state、重放、临时离线与主动退出后的迟到响应。Android 已有一次真实授权冷启动历史，iOS 完整真实账号及审核 iPad 仍需独立证据。\n\n## 会话突然 401 或临时无法连接\n\n先确认 401 来自聊天 /me 或业务接口，而不是网页、媒体服务或反向代理。聊天 session 默认 24 小时，当前没有 refresh-token 接口。核对设备时间、环境、最近退出和升级；不要复制原 token 到其他设备，也不要把 Account token 当聊天 token。\n\n真实 401 应清理对应失效会话并重新登录；网络超时、503 或临时离线应保留凭据。检查错误分类与当前账号 generation，避免旧请求清掉新账号。离线退出只证明本机已退出，远端退出失败时旧 session 可能持续到期限。修复后的验证包括重开恢复、断网恢复、账号切换与另一设备不受无关清理影响，见客户端生命周期。\n\n## 消息重复、发送结果不明或事件似乎丢失\n\n先区分两次独立点击和一次提交丢响应。记录合成用例的范围与 clientId，核对服务端是否返回已有消息；不能把相同文字当作同一业务动作。原请求重试沿用原 clientId、正文与 mediaId；换内容或范围后复用旧 ID 会返回 CLIENT_ID_REUSED。修复协调器或队列后模拟“提交已成功但响应丢失”，断言服务端与双方列表各只有一条。\n\n事件是刷新信号，不是完整消息正文。比较当前快照、普通事件游标、请求 after 与返回 cursor，确认没有使用消息 ID 或 MLS sequence 代替。长轮询等待者在单进程内，断线或多实例场景要检查后续查询是否取到已提交事件；不能把没有即时唤醒等同于数据已丢失。恢复时重新读取有权限的列表与历史，随后从正确游标继续；不要倒改数据库 ID、删除事件或清空消息“重同步”。\n\n## 媒体下载拒绝、位置不更新与城市为空\n\n媒体 401 先处理会话；403／404 核对所有者、可见引用消息、当前群成员与个人历史可见边界。普通媒体的单聊读取不额外检查好友或屏蔽状态，既有可见引用不会仅因屏蔽／删好友自动失效；不能把发送限制当读取限制。上传成功不自动向其他账号公开，删除自己的可见历史后也可能失去引用授权。新事媒体的权限与 Range 另有契约。恢复时修正错误引用或客户端状态，不扩大为匿名公开下载；以发送者、接收者和外人三账号验证。\n\n城市为空先区分拒绝定位、IP 兜底、目录未收录、系统解析失败和离线库损坏。只查目录版本、摘要与可信代理规则，不记录设备坐标，不信任任意转发头，也不使用附近城市伪造当前位置。坏库允许返回空值；素材恢复后重新载入并验证，检查离开会话时迟到结果被丢弃。\n\n实时位置要分别检查系统采集、服务端更新和界面刷新。记录最后更新时间，确认用户已经显式开始，PUT 不应替用户自动开始。当前服务端记录没有自动过期字段，停止本机采集与删除共享记录必须协调；断网时不能把最后一帧说成实时移动。iOS 退出没有逐范围 DELETE，共享不会随聊天 session 注销或到期自动删除；其他仍有资格成员可能读取最后位置。Android bestEffort 远端清理失败也可能残留。安全处理应在授权账号可用时使用明确停止入口，并复查远端记录与其他成员视图；不能仅以退出界面或通知消失结案。验证停止、离群、权限撤回、前后台和重启，详见位置共享。\n\n## 来电不响、接听后无声或通话未结束\n\n按创建、邀请、系统推送、接听、媒体 token、入房、轨道、输出设备、结束逐段检查。active 只表示信令状态；LiveKit 本机入房也不能证明蜂窝网络双向媒体。核对成员资格、媒体服务配置、token 期限与网络路径；若参与者已离开，不应为其签发新媒体凭证。\n\niOS 另核对签名环境、APNs sandbox／production、VoIP topic、设备注册是否绑定有效 session，以及提供商返回的脱敏状态。提供商接受不能替代锁屏系统来电到达。修复后测双方音画、静音、扬声器、蓝牙、切后台、网络切换、多设备竞争与挂断后的迟到任务。结束状态异常时先复查房间人数与宽限期，不能因一次查询失败就宣告空房。不要将恢复通话状态用于发出虚构 VoIP 来电。\n\n## 记忆损坏、密钥不可用或转出失败\n\niOS loadError 与 Android memoryLocked 表示应保护原库，读取失败不能转换成可写空库。先确认设备是否解锁、是否刚升级、文件与密钥是否仍匹配；用文件存在性、大小和密文摘要辅助诊断，不解密真实内容作为报告。禁止清 Keychain／Keystore、卸载、生成新密钥覆盖或删掉“坏文件”后重建。\n\n修复只针对确定的解码、权限或兼容问题，并先在合成副本重现。已有密钥丢失时，服务端聊天备份不能重建本机记忆；没有成功导出的资料不能承诺恢复。转出失败还应区分聊天许可桥接、当前 Account 契约与系统保存位置。现行 Account 主源码缺少对应 redeem 路由，旧副本和本地 fixture 不能证明生产可用。清理仅针对本次生成的部分临时输出，验证取消与失败不损坏原库，见记忆转出。\n\n## SQLite 锁、磁盘满与一致性故障\n\nSQLITE_BUSY 先检查是否存在重复写实例、长事务或备份／迁移任务；当前数据库使用 WAL、外键与 5 秒 busy timeout。磁盘满先检查所在卷空间、配额和权限，不能先删数据库或 WAL 腾空间。/health 200 不保证新事务能写入，必须用合成业务验证。出现损坏信号时停止继续写入，保留数据库与相关 WAL 状态，交由授权维护者处理。\n\n只对已经取得的隔离快照做检查：\n\n``sh\nset -eu\ntest -f qa-backup/chat.sqlite\nsqlite3 -readonly qa-backup/chat.sqlite 'PRAGMA quick_check;'\nsqlite3 -readonly qa-backup/chat.sqlite 'PRAGMA integrity_check; PRAGMA foreign_key_check;'\n`\n\nintegrity_check 返回 ok 不检查外键关系，需另运行 foreign_key_check；空输出表示没有报告外键违例。结构完整也不代表账号映射、加密密钥和权限语义正确。不要对故障原件运行通用 .recover 后直接上线，更不能手工禁用外键掩盖不一致。SQLite PRAGMA 说明给出了检查范围。\n\n## 仅用合成数据进行备份与恢复演练\n\n1. 在新 QA 环境建立两合成账号、好友、群、消息、媒体与私有新事，记录预期关系和少量摘要。\n2. 明确镜像／源码、数据库结构、当前稳定密钥的版本标识，暂停该演练环境的结构变更。在线备份使用 SQLite Backup API，不在写入时随手复制主文件。CLI .backup 与快照机制见官方命令行说明和备份说明。\n3. 源文件必须存在，目的文件必须是新的演练文件；示例只读取合成源并写入新备份：\n\n`sh\nset -eu\ntest -f qa-fixture/chat.sqlite\nmkdir -p qa-backup\ntest ! -e qa-backup/chat.sqlite\nsqlite3 -readonly qa-fixture/chat.sqlite \".backup 'qa-backup/chat.sqlite'\"\nshasum -a 256 qa-backup/chat.sqlite\n`\n\n4. 运行前述结构与外键检查，记录工具版本和退出码。复制备份到新的隔离恢复目录，拒绝覆盖已有目的库；在恢复环境使用配套密钥与匹配的 Account 测试映射，不能指向原服务或真实云投递。\n5. 启动恢复环境后验证两账号资料、历史、未读、外人拒绝、媒体摘要、群成员边界及合成新写入。API 验证由获授权的测试操作完成，不把源库与恢复库同时当作生产写入口。\n6. 对照恢复点记录可接受的数据损失范围和恢复用时。任何结构、权限或解密差异未解决时，不进行正式切换。\n\nAccount MySQL 要另行演练，一致性数据备份与 IDENTITY_SECRET、PII 密钥、签名材料配套保护。示例连接别名需预先指向独立 QA 实例，凭据通过受保护客户端配置提供：\n\n`sh\nset -eu\ntest -d qa-backup\ntest ! -e qa-backup/identity.sql\nmysqldump --login-path=qa-identity --single-transaction \\\n  --routines --triggers --events identity_qa > qa-backup/identity.sql\nmysql --login-path=qa-identity-restore identity_qa_restored < qa-backup/identity.sql\n``\n\n导入对象必须是事先批准的新隔离数据库，不是现有业务库；备份命令失败时应立即停止，不能拿空文件继续导入。备份文件本身是敏感数据，不进入公开站点。PII 旧数据迁移需先验证备份，再明确审批，错误密钥不能用审批开关绕过。正式恢复由维护窗口停旧实例后切换，必要时撤销旧资格；回退数据库可能丢弃恢复点之后的写入，应单独决策并记录。更多门禁见发行清单。"},{"product":"星线 App","title":"版本、构建与分发状态","url":"/app/releases/","text":"# 版本、构建与分发状态\n\n本文区分源码、预览构建、真实验收、商店上传、审核与公开发布。状态依据截至 2026-10-22 的仓库与保存记录整理；本次文档工作没有重新登录商店后台、复跑产品测试或重新核验生产。后续状态必须由新的可核对回执更新，不能用构建数量或聊天中的完成表述代替。\n\n## 当前可以确认的范围\n\n| 项目 | 源码或历史证据 | 尚需完成或重新核对 |\n| --- | --- | --- |\n| Android App | 当前 versionName=1.0.6-preview、versionCode=7；保存记录包含一次真实账号授权、自动返回与 force-stop 后冷启动恢复 | 正式 Release 签名、公开 APK、备案签名一致性、商店与跨网络验收 |\n| iOS App | 原生系统授权、PKCE、Keychain 与冷回调实现；保存记录包含 16 项授权测试与正式登录表单 UI 验证 | 真实账号完整登录／授权回跳／冷启动，以及 iPhone、iPad 真机闭环 |\n| 医疗健康记忆 | 双端历史 QA 覆盖八个可选字段、多行文本、本机加密、附件、搜索和重开 | 其他设备、全部系统版本和字号不在原记录范围 |\n| 下载介绍站 | 历史部署记录已确认静态站、HTTPS 和备案页脚发布 | 公开 App Store 地址和可分发正式 APK 仍未提供 |\n| 音视频与推送 | 本机媒体入房、状态与 APNs 请求构造测试 | 真实 APNs 到达、双向媒体、跨网络、锁屏与后台真机验收 |\n| watchOS | 手表独立登录联网版的产品方向已确定 | 尚无独立版实现交付证据；登录、网络、输入、消息和来电提醒待实施 |\n\nAndroid 真实生产授权记录仅保存布尔结果与必要范围：真实账号授权成功、一次性票据消费、自动回前台、替换进程后恢复，以及原记忆与会话密文未改变。设备会话期限为 24 小时，没有 refresh token 或永久登录承诺，也没有连续运行 24 小时的验收。Android 结论不能代替 iOS 或 Apple 审核设备的结果。\n\n## Apple 与小米渠道\n\nApple 渠道仍需完成 iPad 账号授权可访问性及商业模式说明等审核事项的闭环；后续签名 Archive、构建上传及重新提审，需要新的后台记录核对。仅修复源码、打开登录页、通过模拟器用例或完成 Android 登录都不足以宣称审核问题已解决。\n\n小米渠道仍是草稿、未提审。版权证明与聊天安全评估等渠道要求材料仍需按实际情况核对补齐；不能把待备材料写成已提交。正式 Android Release 签名、备案相关签名一致性和联网验收仍待完成，Debug APK 仅用于开发预览。\n\nwatchOS 的规划方向是“独立登录联网版”。范围包括独立登录、文字／听写、语音消息、图片、普通与城市贴纸、回应、未读已读、历史和来电提醒。现有通话 SDK 不支持 watchOS，手表直接接听需另行设计与验证；尚不能宣称手表版可下载或接听。\n\n## 星线智联暂停状态\n\n星线智联小程序开发版本 0.1.0 有上传成功的历史回执，但回执明确尚未提审、尚未发布。后续发布流程已暂停；文档公开、App 开发和资料整理不会自动恢复小程序验收或提交审核。\n\n真实微信登录曾显示“暂时无法核对”，已有证据不足以确定是 wx.login、网络还是后端 inspect 环节失败，不能直接断言密钥或合法域名错误。恢复后先核对当前 Account 运行版本及请求链，再做真实手机闭环。正式 URL Link 需要线上发布版本；开发预览或填写版本号不等于正式往返已通过，公开入口状态也应以恢复后的实际回读为准。\n\n## 可复现构建与正式产物\n\nAndroid 构建入口存在，但当前配置没有提供可公开使用的正式签名凭据。下面命令生成 Release 构建候选，不能仅凭文件存在就称为正式已签名发行包：\n\n``sh\ncd android-app\n./gradlew :app:assembleRelease --max-workers=1\n`\n\n正式分发前应核对 application ID、版本号／版本码、目标服务、签名证书、权限、最低系统、安装升级兼容与备案材料。用 Android SDK 工具验证签名，并为最终文件计算摘要；以下路径假定维护者已将真正完成签名的候选命名为该文件：\n\n`sh\napksigner verify --verbose candidate-release.apk\nshasum -a 256 candidate-release.apk\n`\n\n同一待发布 APK 的签名、大小、摘要、下载内容和验收记录必须对应。不能把测试 APK 或另一轮构建的摘要写到下载站。\n\niOS 使用工程的 StarlineWorkbench scheme；生产 Archive 需正确的 Apple 开发团队、证书、描述文件、Bundle ID 与推送能力。示例将归档保存到本工作区的构建目录，实际签名参数通过受保护的 Xcode 配置提供：\n\n`sh\nxcodebuild -project ios-app/StarlineWorkbench.xcodeproj \\\n  -scheme StarlineWorkbench -configuration Release \\\n  -destination 'generic/platform=iOS' \\\n  -archivePath build/StarlineWorkbench.xcarchive archive\n`\n\nArchive 成功只产生候选。之后仍需签名检查、目标设备安装、隐私与权限复核、上传回执、审核提交、审核结果与公开版本链接。开发推送和发布推送环境不同，不能混用测试签名和正式 APNs 结论。\n\n## 下载站与文档发布\n\n下载配置在 app-download/dist/downloads.config.js。空地址显示“准备中”；ios.url 应为确认存在的公开 App Store HTTPS 地址，android.url 应指向真实可分发 APK。只有正式稳定版验收完成后才把 android.channel 切为 stable`，同时更新真实版本、大小、最低系统与 64 位 SHA-256。浏览器的 URL 结构校验不能证明服务器可下载或安装成功。\n\n静态页面与文档发布也要单独记录：本地预览、上传、公网访问与抓取规则分别核对。发布产物只包含明确允许公开的页面、样式、脚本、图片和文档，不包含仓库 README、部署回执、环境文件、源代码快照、日志或数据库。下载新增后验证公网 GET、字节摘要和安装；文档新增后验证导航、Markdown、站点地图与抓取规则。旧部署脚本带有一次性基线假设，不应直接重复运行。\n\n建议每次发行记录版本与提交基线、构建工具链、最终产物摘要、签名标识、测试环境、真实验收范围、渠道回执及已知限制。状态词使用“本地构建”“已上传”“已提审”“审核通过”“公开发布”等准确阶段；测试数量只作为证据索引。当前 Account 已有 001–007 与 PII 迁移、SES／阿里云投递等变化，不能从 App 历史副本冻结服务版本。具体门禁与签名命令见发行核对清单，测试见测试与验收，服务切换和恢复见运行维护。"},{"product":"星线 App","title":"构建、签名与发行核对清单","url":"/app/release-checklist/","text":"# 构建、签名与发行核对清单\n\n发行应绑定一个明确基线、一组不可混用的最终产物和分阶段回执。源码版本、测试数量、Archive、APK 文件、上传、提审和公开下载各有独立证据。以下命令供在授权构建环境核对，本次没有运行原生构建、签名、服务测试或渠道操作；当前状态见版本发布。\n\n## 冻结范围与版本资料\n\n先记录仓库提交及未提交差异、目标平台、工具链、依赖锁文件、API 环境和计划版本。未提交改动也属于构建输入，不能只记一个 commit 后忽略工作区。共享 Account 必须以当前维护仓库和实际运行版本为准，不从 App 历史副本整目录部署。任何发布都不自动恢复暂停的小程序或启动手表任务。\n\n``sh\ngit status --short\ngit rev-parse HEAD\nnode --version\nxcodebuild -version\njava -version\n`\n\n仅执行目标平台所需检查。保存输出前去除个人路径、账号、凭据和内部地址；公开发行说明只保留对评估兼容性有用的版本、产物标识及限制。版本表还应记录法律／隐私页版本、数据迁移、已知问题和回滚条件。\n\n| 对象 | 必核对项 | 不能替代的证据 |\n| --- | --- | --- |\n| iOS | Bundle ID、营销版本、构建号、最低系统、设备族、权限、推送环境 | 源 plist 不等于最终 Archive，iPhone 测试不替代审核 iPad |\n| Android | applicationId、versionName、versionCode、min/target SDK、权限、签名 | Debug 版本不等于 Release；文件名不证明签名或升级兼容 |\n| 服务端 | 源码／镜像基线、迁移、配置来源、持久卷和稳定密钥版本 | 构建成功不等于已运行或业务可用 |\n| 静态站 | 最终清单、正文、下载配置、公共文件范围 | 上传成功不等于公网字节、MIME、TLS 和链接正确 |\n\n## Android 构建候选与签名\n\n当前源码声明 1.0.6-preview、versionCode 7、API 26 起、编译与目标 API 35，实际候选必须重新读取。采用仓库 Gradle Wrapper，先核对 JDK 与插件组合，不因历史构建通过跳过当前兼容检查：\n\n`sh\ncd android-app\n./gradlew --version\n./gradlew :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest \\\n  --max-workers=1 -Dorg.gradle.jvmargs=-Xmx1g \\\n  -Pkotlin.compiler.execution.strategy=in-process\n./gradlew :app:assembleRelease --max-workers=1\n`\n\n最后一步只产生构建候选。当前 Gradle 未配置公开可复用的正式 Release 签名流程，不能把 unsigned 包、Debug key 或任意临时签名当作正式升级渠道。正式密钥由受保护签名系统提供，不在命令历史或文档中填写口令。\n\n以下命令假定维护者已将正确签名的最终 APK 放为工作区内 candidate-release.apk，并准备好 Android SDK 命令：\n\n`sh\napksigner verify --verbose --print-certs candidate-release.apk\napkanalyzer manifest application-id candidate-release.apk\napkanalyzer manifest version-name candidate-release.apk\napkanalyzer manifest version-code candidate-release.apk\napkanalyzer manifest min-sdk candidate-release.apk\napkanalyzer manifest target-sdk candidate-release.apk\nshasum -a 256 candidate-release.apk\n`\n\n比对签名证书摘要、备案材料与上一可升级安装包，确认 application ID 不变、versionCode 递增。正式包不得包含模拟器地址、fixture 登录或本地 HTTP 依赖。Release 清单禁止明文流量，编译属性接受 URL 不代表网络策略允许。安装验收至少包括干净安装与从上一版原地升级；现有用户预览设备不得为测试方便而卸载或清数据。\n\n升级后确认记忆、附件、任务、收藏和已登录恢复策略；对旧字段、加密格式和失败保护做专项回归。包签名变化可能阻止升级，应在独立 QA 设备发现，不能先要求用户删除原 App。验证完成后计算最终 SHA-256 与大小，后续重新签名、重打包或修改资源都要求重算并重验，不能沿用上一份摘要。\n\n## iOS Archive 与发布签名\n\n从 project.yml 与已提交 Xcode 工程核对源配置；当前最低 iOS 17、设备族 iPhone，Info.plist 仍含历史 1.0／构建 1。不得据此声称商店已有新构建。生成工程或修改版本后，检查实际构建设置：\n\n`sh\nxcodebuild -project ios-app/StarlineWorkbench.xcodeproj \\\n  -scheme StarlineWorkbench -configuration Release -showBuildSettings \\\n  | rg 'PRODUCT_BUNDLE_IDENTIFIER|MARKETING_VERSION|CURRENT_PROJECT_VERSION|IPHONEOS_DEPLOYMENT_TARGET|TARGETED_DEVICE_FAMILY'\n`\n\n没有出现某字段时继续核对 Info.plist 与最终产物，不能把空输出解释为默认正确。签名团队、证书、描述文件和 Push Notifications 能力需匹配；Debug 使用 development entitlement，Release 使用 production，并与服务端 APNs 环境及 VoIP topic 对齐。\n\n`sh\nxcodebuild -project ios-app/StarlineWorkbench.xcodeproj \\\n  -scheme StarlineWorkbench -configuration Release \\\n  -destination 'generic/platform=iOS' \\\n  -archivePath build/StarlineWorkbench.xcarchive archive\n`\n\n生成后在实际 App 目录检查签名和打包后的版本。这里的路径是仓库内候选产物，不是用户安装目录：\n\n`sh\ncodesign --verify --deep --strict build/StarlineWorkbench.xcarchive/Products/Applications/StarlineWorkbench.app\ncodesign -dv --verbose=4 build/StarlineWorkbench.xcarchive/Products/Applications/StarlineWorkbench.app\nplutil -p build/StarlineWorkbench.xcarchive/Products/Applications/StarlineWorkbench.app/Info.plist\n`\n\nXcode Archive、导出、上传回执、处理完成、选择构建、审核提交、审核通过与正式可下载应逐项记录。iOS 的真实账号授权、自动返回、冷启动、取消与记忆保护必须在目标设备完成；历史系统表单测试不能代替完整授权。Apple iPad 登录驳回与商业模式说明仍需形成新的后台闭环，不能引用 Android 成功作为修复证明。\n\n## 服务端、数据库与兼容门禁\n\n| 检查阶段 | 执行内容 | 通过标准 |\n| --- | --- | --- |\n| 配置 | 按配置参考核对变量来源、格式和依赖 | 无 fixture 混入；稳定密钥可匹配当前数据；外部能力明确启用条件 |\n| 迁移 | 在隔离副本运行对应版本，验证 schema、读写与权限 | Account 001–007 与 PII 迁移有配套备份；聊天构造迁移不破坏旧记录 |\n| 切换 | 停旧写入口、按维护窗口启用新实例 | 无双写、无指错持久卷、无新建空库误报健康 |\n| 验证 | HTTP、身份、消息、媒体、事件、通话等分层探测 | 合成业务断言与拒绝路径通过，真实依赖另有证据 |\n| 观测 | 错误、延迟、重启、磁盘、队列和外部接受状态 | 发现异常能关联阶段，日志不含秘密与用户内容 |\n\n数据库升级没有通用降级脚本。发布前应验证旧客户端对新增字段的容忍、旧服务是否能读取升级后结构、数据保护密钥是否变化。退镜像与退数据是两种动作：退镜像可能遇到 schema 不兼容，退数据库可能丢失恢复点后写入。不能承诺一键无损回滚；按恢复演练预先验证并记录边界。\n\n## 下载、渠道与正式回执\n\n下载地址只指向已核验的最终产物。iOS 使用真实公开 App Store 链接；Android 填写最终版本、最低系统、大小、SHA-256 与渠道状态。只有稳定版验收完成后才切 stable，预览包需保留其可用范围说明；空地址继续显示准备中。\n\n可以在发布后的授权核验阶段下载公开候选并比较，以下域名与文件名为合成示例：\n\n`sh\nset -eu\ncurl --fail --location --output qa-download.apk https://downloads.example.test/starline-release.apk\nshasum -a 256 qa-download.apk\napksigner verify --verbose qa-download.apk\n``\n\n同时核对 HTTPS 信任链、重定向、响应文件类型、真实字节和安装升级，不只发 HEAD 请求或检查 URL 格式。备案标识、包名、签名证书和商店材料应属于同一产品；网站号与 App 号分开，星线与星企不混用。小米仍缺版权证明与聊天安全评估材料，草稿保存不能当作提审；正式 APK 与渠道回执仍需取得。\n\n星线智联 0.1.0 只有开发上传历史，未提审、未发布，且保持暂停。上传版本号、开发者工具预览或设置 published 环境开关均不是官方发布回执。watchOS 独立联网版没有成品交付，手机包不能替它填报已上线。\n\n渠道操作前把版本记录与材料逐项对应：创建草稿只记录草稿；上传成功记录包处理状态；选择构建后确认实际提交；审核结果以后台决定为准；公开发布后核对目标地区、可下载版本与安装结果。被拒后保留具体原因和对应设备，再关联修复提交、复验和新回执。截图、客服说明、审核回复和补件上传分别保存，不能用一项替代整套流程。渠道要求变化时核对当前后台字段，不从旧材料复制过期状态。\n\n## 关闭发行记录\n\n发行记录至少包含：最终源码／工作区基线、工具链、版本与签名标识、产物摘要、数据兼容结论、所选设备与网络、通过和失败用例、外部依赖、上传／审核／发布回执、公开下载核验、可回退版本和已知限制。给每项写准确阶段，不用“已经弄好”概括整条链路。后续紧急修复仍须关联相同清单，避免源码、文档、安装包和商店说明分别指向不同版本。"},{"product":"星线 App","title":"故障定位与恢复边界","url":"/app/troubleshooting/","text":"# 故障定位与恢复边界\n\n排查星线 App 时，先确定故障发生在本机界面、网络、聊天服务、Account、第三方授权还是商店。本文根据源码中的实际错误与运行机制整理处理顺序。示例仅使用本机地址和合成数据；本次没有访问生产、读取用户内容或实施恢复操作。服务机制见运行维护，验收命令见测试与验收。\n\n## 先收集最少必要信息\n\n记录平台、App 版本、系统、网络、时间、入口、可重复步骤、HTTP 状态或错误代码，以及是否刚更新版本。把“按钮没反应”“网页报错”“网页成功但未回 App”“回 App 后账号为空”分开；它们位于不同边界。日志只截取必要部分，移除 Authorization、Cookie、验证码、state、ticket、完整回跳 URL、手机号和记忆正文。\n\n优先做只读检查，保留原进程和数据。不要一开始卸载 App、清空 Keychain／Keystore、删除 SQLite、清空记忆或重置模拟器。构建成功不能解释真实路径失败，截图出现某一页面也不能证明用户已完成授权。\n\n## 页面打不开或连接被拒绝\n\n本地 ERR_CONNECTION_REFUSED 通常先确认目标端口确有服务，而不是反复刷新页面：\n\n``sh\ncurl --fail --silent --show-error http://127.0.0.1:9100/api/health\ncurl --fail --silent --show-error http://127.0.0.1:4210/health\n`\n\n身份健康检查会访问数据库；聊天健康仅证明 HTTP 进程响应。若前端开发页能打开而 API 不可用，分别检查 identity/client 的 Vite 与身份后端，不把一个端口的 200 当成整套服务可用。静态下载页可独立预览：\n\n`sh\npython3 -m http.server 8868 --bind 127.0.0.1 --directory app-download/dist\n`\n\n该预览持续运行，不自动按时间退出。端口冲突时先识别已有服务，不随意结束用户进程。公开入口还需分层核对 DNS、证书、HTTPS、代理和上游，502 与 DNS 解析失败属于不同阶段；不通过关闭 TLS 验证或修改系统网络来掩盖原因。\n\n## 登录、回跳与冷启动\n\n当前原生流程是 POST /auth/native/start 获取授权地址，系统浏览器进入 Account，网页返回聊天服务 /auth/callback，再以 starlineworkbench://chat-auth 回到 App，最后 POST /auth/exchange 换取设备会话。客户端保存 verifier 与 state，服务端票据为单次且有效一分钟；访问 token 不放在回跳 URL。\n\n| 现象 | 优先定位 | 处理边界 |\n| --- | --- | --- |\n| LOGIN_UNAVAILABLE 或启动登录 503 | 聊天 OIDC 配置、discovery、上游可达性 | 不在客户端补写 secret 或伪造成功 |\n| 打开了错误环境 | App base URL、issuer、public URL、构建参数 | Account 与聊天必须来自同一联调环境 |\n| 网页完成但未回 App | scheme 注册、目标 app、系统授权会话、回调阶段 | 先确认回调到达位置，不重复消费 ticket |\n| state 错误、过期或 ticket 401 | 是否原设备发起、是否重放、是否过期、是否取消 | 发起新的完整授权；保留原有有效会话 |\n| 冷启动临时离线 | /me 网络错误或服务 503 | 客户端应保留凭据并允许恢复 |\n| /me 返回 401 | 会话到期、退出或无效 | 重新登录，不把 token 复制到其他设备 |\n\n设备会话有效期为 24 小时，没有永久登录或 refresh token。iOS 的 Debug 仅通过 STARLINE_CHAT_BASE_URL 显式覆盖，Release 使用正式地址；Android 通过 -PstarlineChatBaseUrl 编译，默认也是正式服务。本地 ticket 不能提交正式服务换票。iOS 系统“继续”提示是授权会话的一部分，应检查提示与实际表单，不能把它当作 App 登录失败后直接删掉。\n\nAndroid 保存过真实授权和 force-stop 冷启动成功的历史证据；iOS 真实账号完整闭环及审核 iPad 仍待验收。定位问题时应针对失败平台重现，不能用另一平台的成功结果代替。对已有账号执行恢复测试须保留记忆与原会话数据，不把重新安装作为默认步骤。\n\n## 验证码未到或第三方暂不可用\n\n匿名邮箱码接口返回 request_accepted 表示受理，发送在持久验证码队列中执行；提供商接受与实际收件仍需分别确认。DELIVERY_UNAVAILABLE 先检查当前主源码的投递适配器：生产邮件为腾讯 SES，短信为阿里云；SMTP 只用于非生产备用，配置 SMTP 不能恢复生产邮件。旧副本的腾讯短信变量也不能用于当前服务。TLS 验证保持严格，等待重发冷却，避免重复请求导致难以判断当前有效验证码。\n\n若注册先返回 CAPTCHA_REQUIRED／CAPTCHA_UNAVAILABLE，优先检查当前验证码门禁及四项 captcha 配置，不把它归为邮件失败。开发注册同样需要证明；旧 fixture 没有提交 captcha，不能据此认定后端注册损坏。若启动报 PII 密钥缺失、不匹配或旧数据迁移未批准，保留库与稳定密钥，先验证隔离备份和迁移方案，不通过删除标记、重置密钥或盲开审批修复。当前 Account 迁移已为 001–007，排查必须使用其当前维护仓库，见完整配置参考与故障处理手册。\n\n微信、QQ、支付宝配置存在不等于平台已启用或真实回调通过。星线智联 0.1.0 只有开发上传历史，尚未提审、发布，并按用户要求暂停。手机“暂时无法核对”可能包含 wx.login、网络或 inspect 错误，需恢复后检查分段日志；不能仅凭统一提示断言 AppSecret 或合法域名错误，也不能自动恢复暂停任务。\n\n## 消息、贴纸与模型\n\n401 先处理登录，403／404 核对成员和资源权限；已解除好友、屏蔽、离群或删除记录会改变可见范围。失败消息应通过原重试入口发送，避免新建另一条造成重复。只在已加载聊天摘要中搜索的结果不能代表完整历史不存在。\n\n城市贴纸未知 cityId 或额外字段返回 400，缺少／损坏素材返回 503。城市目录来自内置 catalog，客户端不能提交文件路径。IP 城市为空可能是私网地址、代理未在精确 allowlist、离线库缺失或无法识别；先核对输入边界、素材版本与哈希，不扩大为信任所有代理。设备城市优先、IP 只是粗略兜底，空值时保留选择城市，不伪造位置。\n\n实时位置的本机停采集与远端停止必须分别核对。当前 iOS 退出不会逐范围删除共享，session 注销或到期也不清理服务端最后位置；Android bestEffort 删除失败同样可能残留。不能以退出、通知消失或坐标不再变化证明其他成员已不可读；使用明确停止入口并确认远端状态，详见故障处置。\n\n未配置 CHAT_CRYPTO_NODE_BUNDLE 时加密接口返回 503；加密核心测试或 WASM 构建成功不能证明聊天 UI 已全量采用 MLS，也不能声明旧消息、附件或新事均为端到端加密。记忆模型未就绪可返回 503，但本机自由记录和固定模板仍可使用；不要把模型不可用修成上传用户记忆到未知外部服务。\n\n## 通话与通知\n\nCALL_MEDIA_UNAVAILABLE` 先核对 LiveKit 地址、密钥、媒体房间与网络连通。HTTP 业务接口响应正常不代表 WebRTC／TURN 端口可达；同房间连接测试也不代表双向声音和画面正常。排查时分别确认发起、振铃、接听、媒体、挂断与多设备竞争，保留双方时间线。\n\nAPNs 请求构造成功或提供商接受不等于设备收到来电。核对 Bundle／VoIP topic、开发或生产环境、签名能力、会话期限、锁屏与后台状态；不要记录推送 token 或私钥。watchOS 尚无独立版交付，现有通话 SDK 不支持手表，不能用手机接通结果声称手表可接听。\n\n## 本机记忆、升级与资源异常\n\n记忆索引和附件分别加密，密钥在设备安全存储。升级或迁移失败应保留原文件，不用空数据覆盖；卸载会丢失 App 私有数据，服务端备份不能恢复设备密钥。用户换机或卸载前，应按现有账号多因素授权流程主动转出；公开报告只描述结果，不收集导出正文。医疗健康模板只记录信息，没有自动复诊提醒、系统健康接入或跨设备同步。\n\n模拟器内存异常先观察进程、物理内存、swap、日志和是否存在重复实例，不擅自关闭程序或修改系统配置。Android 预览启动器复用既有 AVD，保护阈值是资源异常条件，不是展示时限。界面截图停在旧帧时先区分绘制与业务状态，不清空数据强行“修复”。问题修复后只重跑相关验证并记录剩余边界；发布与审核状态依据新的正式回执更新，见版本与分发状态。"},{"product":"星线 App","title":"标识符、时间与术语参考","url":"/app/glossary/","text":"# 标识符、时间与术语参考\n\n本页统一文档中的名称、数据单位和状态含义。相似名称不一定代表同一对象；集成时应同时核对所属服务、用户、会话范围和有效期。具体字段以 API 参考、本机数据格式和配置参考为准。\n\n## 产品与组件\n\n| 名称 | 指代 | 容易混淆的内容 |\n| --- | --- | --- |\n| 星线 App | 当前 iOS 与 Android 原生产品 | 不把共享后台中的全部能力视为已交付前台 |\n| 星线 Account | 账户登录、验证与 OIDC 身份授权 | Account 登录态不是聊天 Bearer 会话 |\n| 聊天服务 | 用户映射、消息、新事、媒体、位置、事件和通话状态 | 公网基地址可知不等于接口匿名开放 |\n| 星企 | 使用共享后台的独立企业产品 | /enterprise/* 不等于个人群聊和本机工作台 |\n| 工作台 | 星线 App 的个人日常功能入口 | 个人待办与企业待办的存储和权限不同 |\n| 新事 | 文字、图片/视频、评论、点赞及可见范围 | 不是所有人可匿名读取的公开网页 |\n| 记忆 | 设备上的文字、模板字段与附件记录 | 没有由此推导的云端记忆 CRUD 或自动同步 |\n| 城市贴纸 | 固定城市目录与随包分发的城市建筑素材 | 不是从精确实时位置生成任意城市图片 |\n\n## 身份与凭据\n\n| 字段/术语 | 用途 | 保存与传递边界 |\n| --- | --- | --- |\n| issuer | OIDC 身份签发方 | 按配置精确匹配，不用任意外部发现文档替换 |\n| Account sub | Account 对用户的身份标识 | 聊天服务用它映射本地用户；不等同于显示名或手机号 |\n| chat user.id | 聊天服务用户标识 | 业务对象引用的用户 UUID 字符串 |\n| client ID | 服务端登记的 OIDC 应用标识 | 是应用配置，不是普通消息的 clientId |\n| client secret | 机密 OIDC 客户端的认证材料 | 只在受控服务端，不进入 App、静态页面或公开示例 |\n| authorization code | Account 授权码 | 聊天服务在 HTTPS 回调后兑换；一次性且有期限 |\n| state | 绑定当前授权流程的随机状态 | App 返回状态须与本次 pending 完全匹配，不能跨流程拼接 |\n| codeVerifier | 本次 PKCE/原生 proof 的随机证明 | App 与聊天服务→Account 使用不同的 verifier |\n| codeChallenge | verifier 的 S256 挑战 | 挑战公开不意味着可以省略最终 verifier 验证 |\n| ticket | 聊天服务交给 App 的一次性换票凭据 | 不是可长期调用业务接口的聊天 token |\n| chat accessToken | 调用聊天业务的 Bearer 凭据 | 独立 session，不能用 Account 或 LiveKit token 替换 |\n| expiresAt | 当前对象的到期时刻 | 先确认属于 flow、ticket、session 还是其他对象 |\n| pending | 客户端未完成授权状态 | 双端字段、清理时机与中断恢复不同 |\n\n登录名、显示名、好友号、邮箱和已验证手机号分别有不同职责。修改展示资料不一定修改 Account 安全联系方式；Account 改密也不自动撤销聊天服务已签发的独立 session。详见登录与会话。\n\n## ID 与游标\n\n| 名称 | 类型/作用 | 使用规则 |\n| --- | --- | --- |\n| conversationId | 单聊范围的 UUID 字符串 | 不是对方 userId；删除好友的路径也使用该会话 ID |\n| groupId | 个人群范围的 UUID 字符串 | 不等同于企业频道 ID |\n| mediaId | 上传媒体的 UUID 字符串 | 媒体所有权与当前可见引用决定读/发权限 |\n| clientId | 客户端一次发送意图的 UUID 字符串 | 新消息换 ID；相同内容的网络重试保留原 ID |\n| message.id | 服务端普通消息整数 ID | 与发送时 clientId 建立映射；不假定全局连续 |\n| event.id | 当前普通事件流使用的整数 ID | 是变更通知游标，不是消息正文编号 |\n| eventCursor | 普通事件的已知进度 | 用于 /events?after=...，不能填 Unix 时间戳 |\n| MLS sequence | 特定加密范围中的事件顺序 | 按 MLS 协议处理，不能复用普通 eventCursor |\n| before | 读取严格早于边界的数据 | 消息历史常取当前页最小 ID；各资源默认值不同 |\n| after | 读取严格晚于边界的数据 | 单聊支持的增量规则不自动适用于群历史 |\n| nextBefore | 某些列表响应给出的下一页边界 | 新事、评论、通知各用自己的资源游标 |\n\n服务端可能使用 UUID 生成标识，但部分路径校验采用固定长度的十六进制/短横线模式。验证格式不能代替查成员与权限；客户端也不能因为一个字符串“像 UUID”就信任它来自当前账号。\n\n## 时间、大小与字符\n\nHTTP API 的 createdAt、updatedAt、startedAt、expiresAt 等时间通常为 Unix 毫秒数；wait、缓存 max-age 等参数使用秒。原生模型里的 Date、本机字符串及导出容器并不全部沿用同一时间编码，转换前应查看本机格式。\n\n| 表达 | 含义 | 检查要点 |\n| --- | --- | --- |\n| 1 秒 | 1,000 毫秒 | 不把服务端毫秒直接当 Unix 秒传入原生时间构造 |\n| 1 KiB | 1,024 字节 | 不是1,000字节；文件上限以代码的实际计算为准 |\n| 1 MiB | 1,048,576 字节 | 10 MiB 与上传协议里的十进制10MB不同 |\n| Unicode 码点数 | 如服务端 [...text].length 的计算 | 与UTF-8字节数、UTF-16长度不等价 |\n| UTF-16代码单元 | JavaScript字符串length等实现使用的单位 | 非BMP字符可能占两个单元 |\n| 原生字符串截取 | Swift/Kotlin各自的限制实现 | 不因中文提示相同就假设两端对所有字符完全同计数 |\n| base64 | 二进制的文本编码 | 导出附件base64不是加密，解码即可得到原文件 |\n| base64url | 使用URL安全字符的编码变体 | 原生proof与MLS标准base64不能互换 |\n\n所有上限还受请求整体字节限制影响。例如文字未超过字段字符数，不代表包含其他字段后的 JSON 一定未超过请求体上限。某些二进制接口有独立最大值；图片宽高和文件头校验也是额外条件。\n\n## 加密与信任范围\n\n| 术语 | 当前语境 | 不能据此推出 |\n| --- | --- | --- |\n| TLS/HTTPS | 客户端与服务之间的传输保护 | 服务器无法读取普通消息正文 |\n| 本机AES-GCM | 记忆索引与附件等设备文件加密 | 已解锁设备或导出的明文文件也天然受同一保护 |\n| Keychain/Keystore | 设备安全存储机制 | 换机后所有密钥与文件一定自动恢复 |\n| AAD | GCM认证附加数据，绑定密文上下文 | AAD是另一个密码或可以被任意换成别的purpose |\n| MLS | 设备群组的密钥与加密事件协议 | 当前双端所有普通聊天与附件都已经接入 |\n| KeyPackage | MLS设备可被加入群的公开密钥材料 | 公开材料里可以包含私钥，或可以无限次重复领取 |\n| Welcome | MLS新成员获得群状态的协议消息 | 代表所有成员均已确认并允许发应用密文 |\n| Commit | MLS群状态变化消息 | 提交HTTP成功即可忽略终端认证与确认 |\n| ciphertext | 密文载荷 | 只要JSON字段名叫payload就必然是端到端加密 |\n| 签名公钥固定 | 记住已见设备的签名身份 | 已提供用户可线下核对的完整安全号码系统 |\n\n当前主聊天与附件边界见安全与隐私，MLS各阶段见MLS协议。本机记忆加密、资料字段加密和加密事件协议分别属于不同数据集，不能用一个“已加密”标签覆盖全部情况。\n\n## 操作名称与状态\n\n| 表达 | 应理解为 | 常见误读 |\n| --- | --- | --- |\n| 隐藏/删除本人消息 | 修改本人可见结果 | 对方也撤回同一消息 |\n| 恢复会话 | 取消当前隐藏标记 | 自动恢复被历史边界排除的旧内容 |\n| 屏蔽 | 影响当前联系与可见范围的关系状态 | 完成账号删除或物理擦除所有历史数据 |\n| 退出群 | 结束当前成员资格，可能转移群主 | 群在所有成员处被解散 |\n| 解散群 | 群主执行的群级清理路径 | 本人删除聊天列表项 |\n| 本机退出 | 客户端清理当前账号状态 | 远端logout一定成功、Account全站和其他设备均退出 |\n| 转出 | 本机生成可携带的明文JSON与附件编码 | 当前已经提供导入、无损换机恢复或跨端自动互换 |\n| 恢复 | 针对某个明确数据与密钥组合验证回读 | 有一份任意文件就保证恢复所有业务 |\n| 上传成功 | 文件/构建到达目标服务 | 相关消息发送、审核提交或公开发布已完成 |\n| 正式发布 | 有可核对公开版本与分发入口 | 页面写了版本号或存在Debug包 |\n\n技术状态应说明对象、阶段和证据。例如“HTTP健康”只指该探针检查的依赖，“客户端源码已实现”只指代码存在，“历史真机授权通过”只指保存记录覆盖的那一次环境。完整交付条件见发行检查，故障阶段划分见故障处置。\n\n## 源码与查阅顺序\n\n普通服务对象参考 chat-service/src/server.mjs 和 chat-service/src/database.mjs；新事、加密、通话由各自仓储与适配器定义。原生术语需同时查看平台模型、序列化与安全存储，不能仅对照翻译后的界面文字。源码入口和变更验证映射见模块地图，首次集成可按协议示例逐步核对。"},{"product":"星线 App","title":"文档访问与引用","url":"/app/ai-access/","text":"# 文档访问与引用\n\n技术文档提供 HTML、Markdown 和索引文件，无需登录或 API key。允许自动化工具访问、抓取、索引、摘要及引用 https://document.thestarline.cn/app/ 的公开内容。引用时请保留文章标题、原始 URL、资料日期和相关限制。\n\n## 机器可读入口\n\n| 地址 | 内容与使用方式 |\n| --- | --- |\n| /llms.txt | 文档入口、阅读顺序和能力边界 |\n| /llms-full.txt | 按章节汇总的完整公开 Markdown |\n| /docs/catalog.json | 文档标题、摘要、HTML URL、Markdown URL、内容摘要值 |\n| /docs/search-index.json | 本站搜索使用的公开正文索引 |\n| /docs/api-index.json | 从当前服务路由整理的方法、路径、鉴权和范围元数据 |\n| /sitemap.xml | 公开页面的 canonical 地址 |\n| /robots.txt | 本域名自动抓取规则 |\n\n每篇文章页面提供 Markdown 原文链接。例如首页原文是 /docs/overview.md，API 原文是 /docs/api-reference.md。HTML 正文在服务器构建时已经生成，关闭 JavaScript 仍可阅读，不需要执行搜索脚本才能取得文章。\n\nllms.txt 是帮助 AI 发现和理解网站内容的提案，不是所有模型都实现的统一索引协议，也不保证任何平台会主动抓取或收录。格式背景见 llms.txt 提案。\n\n## 抓取策略\n\n本域名的 robots.txt 对普通爬虫开放，并显式允许 OAI-SearchBot、GPTBot 与 ChatGPT-User。根据 OpenAI 爬虫说明，前两者分别用于搜索和可能用于模型训练的内容抓取，控制项独立；ChatGPT-User 是用户触发访问，robots 规则可能不适用。网站表达允许访问，不代表控制 AI 平台的后续行为或保证内容进入训练、搜索或回答。\n\n抓取者应合理控制并发，复用已取得的静态文件，并在遇到 HTTP 429 或临时服务错误时退避。可通过 catalog.json 的 SHA-256 判断内容变化，不必重复下载所有文章。没有对特定账号、国家、设备或 User-Agent 提供不同的文档正文。\n\nrobots.txt 的语义依据 RFC 9309。它表达自动抓取规则，不能代替身份验证和访问控制；私有内容应继续受真正的鉴权保护，参见 Google 关于 robots.txt 的说明。\n\n## 公开许可的边界\n\n公开技术文档可被自动获取、摘要和引用。此许可不授予 App 源码、商标、软件包或第三方素材的开源许可，不授予读取用户聊天、媒体、记忆、账号资料或受保护 API 的权限。API 描述中的 Bearer token、成员关系、票据和授权检查继续生效；请勿尝试把抓取许可作为绕过访问控制的依据。\n\n完整文本和索引只包含公开文章。原生客户端中的本机记忆、医疗记录、日志、服务配置和数据库均不属于文档内容。法律文件以相应隐私说明与法律站为准。\n\n## 如何可靠地引用\n\n引用具体文章的 canonical 地址，而不是本地预览、搜索结果或登录回跳 URL。例如讨论加密时引用安全和隐私，并保留“当前默认主消息与附件尚未形成全量 E2EE 闭环”的范围说明；讨论功能时区分实现、历史验收和待办。\n\n资料截至 2026-10-23。机器目录含内容摘要值和资料日期；它们用于辨识文档版本，不是签名验证或产品服务等级承诺。更新文档时会重新生成 HTML、原文、搜索、完整文本和站点地图，避免不同入口互相矛盾。\n\n站点地图帮助发现 URL，但提交或提供 sitemap 本身不保证索引，参见 Google sitemap 文档。本次公开发布验证的是公网 HTTP 可读、文件完整与抓取规则；是否被外部平台实际收录仍需其自身结果确认。"}]
