# Zing API Docs Canonical: https://doc.api.zingyang.com/apple-sign-in-ios/ API base: https://work.zingyang.com/api # 外部 iOS App 接入 Apple 登录 版本:1.1 · 更新:2026-10-23 本文依据 zing-boot 当前源码编写,供独立 iOS App 对接平台账号登录使用。不是 Apple 内购、订阅或 OAuth 授权服务器接入文档。测试与生产均使用用户指定地址 `https://work.zingyang.com`;Bundle ID 和返回数据仍为示例,不代表已为目标 App 开通。 ## 1. 接入前先确定 双方需确认以下信息: | 信息 | 提供方 | 说明 | |---|---|---| | 测试、生产 API 根地址 | 平台 | 统一为 `https://work.zingyang.com/api`,包含 `/api` 前缀 | | iOS Bundle ID | App | 如 `com.example.externalapp`,测试/生产若不同需分别登记 | | Apple Developer Team 与 App ID 分组 | App | 决定是否需要与现有 App 统一 Apple 身份;不能仅凭相同邮箱合并账号 | | 首次登录是否允许建号 | 平台 | 对应配置中心 `zing.apple-signin.auto-create-user` | | 用户归属 | 平台 | 是否只使用个人空间,还是首登额外加入指定组织 | | 可调用的业务接口与角色权限 | 双方 | 登录成功不等于拥有全部业务权限 | **必需前置条件:平台验签配置中的 audiences 必须包含目标 App 的真实 Bundle ID。** 客户端不能通过请求参数自行指定或覆盖 audience。 当前配置实现的区别: - 开关、自动建号、默认组织、失败频控通过 `RuntimeConfigProvider` 从配置中心读取。 - `audiences` 通过既有配置中心读取,键 `zing.apple-signin.audiences`,类型JSON、平台级,值为Bundle ID字符串数组。 - 随 `V3.9.4_172` 迁移初始化两个ZingTalk标识和 `com.zingyang.Cofree`;需部署本次代码并执行迁移才生效,已有自定义配置不被覆盖。 - 本文没有替目标 App 修改服务配置;未完成 audience 开通前,不能承诺直接联调成功。 ### 以后自行新增 App 本次初始化数据位于 `zing-boot/zing-module-system/zing-system-start/src/main/resources/flyway/sql/mysql/V3.9.4_172__apple_signin_audiences_database.sql`。迁移执行后,日常修改通过后台完成,不修改已执行的迁移文件: 1. 使用有配置管理权限的账号进入 **系统管理 → 配置中心**。 2. 搜索配置键 `zing.apple-signin.audiences`,找到 **Apple 登录允许的应用**。 3. 编辑JSON配置值,在现有数组末尾追加新的Bundle ID;保留原有全部值。例如: ```json [ "com.zingyang.zingtalk", "com.zingyang.zing-talk", "com.zingyang.Cofree", "com.example.nextapp" ] ``` 4. 填写“修改原因”,例如“新增 nextapp 的 Apple 登录”。此配置标记为高风险,按界面要求填写至少4个字符的“确认文本”,例如“确认新增应用”,然后保存。 5. 重新打开详情核对实际生效值;配置项支持运行时读取,核实目标实例和缓存已更新,再让新App重新发起Apple授权验证。 Bundle ID大小写敏感,不能使用通配符。不要删除既有App标识,也不要通过“新增配置项”再建一个同义键。如果查不到该项,先确认本次代码和172迁移已发布;若缺菜单或编辑权限,由管理员按既有权限流程处理,不绕过权限直接改库。 ## 2. Apple 开发者侧准备 1. 在 Apple Developer 为目标 App ID 启用 **Sign in with Apple**。 2. Xcode 的目标 App Target → **Signing & Capabilities** 添加 **Sign in with Apple**,检查签名与 provisioning profile 包含该能力。 3. 若需要相关 App 共享 Apple 身份,先在同一开发者账号下规划 Primary App ID 与分组;不要把 Xcode 的 App Groups 共享容器当作 Apple 登录分组。 4. 使用已登录 Apple Account 的设备进行真实授权联调。 本接口使用原生 `AuthenticationServices` 返回的 identity token,不要求 App 内置 Apple 私钥、client secret,也没有网页回调地址参数。若未来接入网页授权、授权码交换或撤销,需要单独核实服务端能力。 Apple 官方参考:[启用 Sign in with Apple](https://developer.apple.com/help/account/capabilities/about-sign-in-with-apple/)、[相关 App 分组](https://developer.apple.com/help/account/capabilities/group-apps-for-sign-in-with-apple/)。 ## 3. 登录时序 ```text iOS 生成 rawNonce(每次尝试独立生成) → request.nonce = SHA256(rawNonce) 的小写十六进制 → 系统 Apple 授权 → 获得 identityToken → POST 平台登录接口,提交 identityToken + 原始 rawNonce → 平台验证 Apple 签名、issuer、audience、有效期和 nonce → 查 Apple 绑定;必要时创建平台账号 → 平台返回自己的 token、userInfo、tenantList 等 → iOS 安全保存平台会话,使用平台 token 访问业务接口 ``` 两种令牌不可混淆:Apple identity token 仅用于这次登录交换;后续平台 API 使用返回的 `result.token`。不要把 identity token 当平台 token,也不要将 Apple `credential.user` 当平台用户 ID。 ## 4. 登录接口 **POST `{API_BASE}/sys/thirdLogin/apple/native`** 例如完整地址:`https://work.zingyang.com/api/sys/thirdLogin/apple/native`。 请求头: ```http Content-Type: application/json Accept: application/json ``` 该接口允许未登录访问,不需要旧平台 token、App Secret、App Store 收据或客户端签名参数。不必指定租户 ID,平台按账号成员关系决定登录租户。本文示例是人工使用原生 App 授权后的正常请求;AI 工具代调平台接口时仍须遵守项目的 AI 标识、确认和审计规则。 | JSON 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | identityToken | String | 是 | `credential.identityToken` 以 UTF-8 转成的 JWT 字符串,不要再次 Base64 编码;当前默认最大长度4096字符 | | nonce | String | 是 | 授权前生成的原始随机串;不是已经 SHA-256 的摘要 | | authorizationCode | String | 否 | Apple authorizationCode 的 UTF-8 字符串;当前预留,不交换、不持久化 | | fullName | String | 否 | 显示姓名候选;只用于补充空资料,不参与身份判断;当前最多保留50字符 | | email | String | 否 | 可选资料提示;当前不持久化、不用于查找或合并账号 | ```json { "identityToken": "", "nonce": "<本次授权对应的 rawNonce>", "fullName": "示例用户" } ``` 服务端使用验签后的 `sub` 定位绑定,绑定类型是 `apple`,绑定记录在默认租户0。该租户仅用于第三方身份登记,不表示业务请求可以任意使用租户0。 ## 5. 响应与错误 成功响应示意(仅展示客户端需要的部分字段): ```json { "success": true, "code": 0, "message": "登录成功", "result": { "token": "<平台 JWT>", "userInfo": { "id": "<平台用户ID>", "username": "apple_example", "realname": "示例用户", "loginTenantId": 1234 }, "tenantList": [] } } ``` `tenantList` 内容随真实成员关系返回,上例不是固定值;还可能包含 `departs`、`multi_depart` 等字段。解码时允许额外字段,非必需资料按可选处理。**成功条件是 HTTP 成功、`success=true`、`result.token` 非空及 `userInfo` 存在,不要只判断 HTTP200 或写死 `code=200`。** Apple 业务失败通常是 **HTTP200 + success=false**: ```json { "success": false, "code": 1001, "message": "Apple 登录校验失败,请重试", "result": null } ``` | 业务码 | 含义 | 客户端处理 | |---|---|---| | 1001 | token/签名/issuer/audience/有效期/nonce 不合法 | 结束本次登录;检查接入配置,用户重试时重新发起 Apple 授权 | | 1002 | 未绑定且禁止自动建号,或 Apple 登录关闭 | 展示服务端提示,联系平台确认开通策略;不要据此认为存在可调用的账号绑定接口 | | 1003 | Apple 公钥服务不可达或异常 | 提示稍后重试;不要自行放行登录 | | 1004 | 建号或并发绑定冲突 | 提示重试,避免并发连续提交 | | 1005 | IP 失败频控超限 | 停止自动重试,提示稍后再试;窗口由平台配置 | | 500或其他码 | 账号无效、配置、租户或其他公共业务异常 | 按 `success=false` 处理,保留脱敏诊断信息并联系平台 | 同时处理真实 HTTP401/403、5xx、网络错误及非JSON响应。Apple 登录的1001等业务错误不等同于已有平台会话过期,不要无条件清除其他有效会话。 ## 6. SwiftUI 最小示例 下面示例使用系统按钮、每次独立 nonce 和 URLSession。适用于支持 Swift Concurrency 的 iOS 项目;替换 API 地址后集成。`onSession` 必须接入 App 自己的 Keychain 和登录状态管理;示例不包含完整导航或 Keychain 封装。 ```swift import SwiftUI import AuthenticationServices import CryptoKit import Security struct PlatformAppleLoginRequest: Encodable { let identityToken: String let nonce: String let fullName: String? let email: String? } struct PlatformUser: Decodable { let id: String let username: String? let loginTenantId: Int? } struct PlatformSession: Decodable { let token: String let userInfo: PlatformUser } struct LoginEnvelope: Decodable { let success: Bool let code: Int? let message: String? let result: PlatformSession? } @MainActor struct PlatformAppleLoginButton: View { let apiBase: URL // 例如 https://work.zingyang.com/api let onSession: (PlatformSession) throws -> Void @State private var rawNonce: String? @State private var busy = false @State private var errorText: String? var body: some View { VStack { SignInWithAppleButton(.signIn) { request in guard let nonce = makeNonce() else { rawNonce = nil errorText = "无法生成登录随机数,请重试" return } busy = true errorText = nil rawNonce = nonce request.requestedScopes = [.fullName, .email] request.nonce = SHA256.hash(data: Data(nonce.utf8)) .map { String(format: "%02x", $0) }.joined() } onCompletion: { result in let nonce = rawNonce rawNonce = nil switch result { case .failure(let error): busy = false if (error as? ASAuthorizationError)?.code != .canceled { errorText = "Apple 授权失败,请重试" } case .success(let authorization): guard let nonce, let credential = authorization.credential as? ASAuthorizationAppleIDCredential, let data = credential.identityToken, let token = String(data: data, encoding: .utf8), !token.isEmpty else { busy = false errorText = "Apple 登录凭据缺失,请重试" return } let name = credential.fullName.map { PersonNameComponentsFormatter().string(from: $0) } Task { defer { busy = false } do { let session = try await exchange(.init( identityToken: token, nonce: nonce, fullName: name, email: credential.email )) try onSession(session) // 先写入 Keychain,再切换登录态 } catch { errorText = error.localizedDescription } } } } .frame(height: 50) .disabled(busy) if let errorText { Text(errorText) } } } private func makeNonce() -> String? { var bytes = [UInt8](repeating: 0, count: 32) let status = bytes.withUnsafeMutableBytes { buffer in SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) } guard status == errSecSuccess else { return nil } return bytes.map { String(format: "%02x", $0) }.joined() } private func exchange(_ body: PlatformAppleLoginRequest) async throws -> PlatformSession { let url = apiBase.appendingPathComponent("sys/thirdLogin/apple/native") var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw NSError(domain: "PlatformLogin", code: -1, userInfo: [NSLocalizedDescriptionKey: "平台登录请求失败,请稍后重试"]) } let envelope = try JSONDecoder().decode(LoginEnvelope.self, from: data) guard envelope.success, let session = envelope.result, !session.token.isEmpty else { throw NSError(domain: "PlatformLogin", code: envelope.code ?? -2, userInfo: [NSLocalizedDescriptionKey: envelope.message ?? "平台登录失败"]) } return session } } ``` 生产集成需由宿主页面处理授权期间的重复点击、页面退出和任务取消。姓名、邮箱可能为空;不要因二次授权缺少这些字段而拒绝登录。随机数生成失败时示例不会交换平台会话,客户端不得改用固定 nonce。 ## 7. 登录成功后的完整会话流程 本节是外部App的集成要求及状态设计;接口契约来自现有源码,**不表示Cofree当前已经实现全部步骤**。所有路径相对 `https://work.zingyang.com/api`。 ### 7.1 状态与执行顺序 ```text 未登录 → Apple授权中 → 平台交换中 → 安全保存会话 → 初始化中 → 已就绪 ↓失败 ↓失败 ↓失败 ↓网络异常 回登录页 回登录页 不进入业务首页 保留凭据,允许重试 App启动 → 读取Keychain → 验证当前平台会话 → 初始化 → 已就绪 ↓无凭据 ↓明确401 未登录 清除本会话 → 重新登录 已就绪 → 切换组织中 → 更新上下文与重新初始化 → 已就绪 已就绪 → 退出中 → 清除本地会话与受保护缓存 → 未登录/本机模式 ``` 登录成功后的顺序: 1. 验证登录响应:`success=true`、平台token非空、`userInfo.id`有效;HTTP200本身不是成功依据。 2. 建立会话记录:保存API环境标识、平台用户ID、token、平台返回的`loginTenantId`。可同时保存显示资料与组织名,但它们不是权限依据。 3. 安全写入Keychain;成功后再发布“初始化中”状态。写入失败时停留登录页,不切换到已登录主页,不降级保存token到普通文件。 4. 携带该会话调用`GET /sys/user/getUserInfo`;检查`result.userInfo.id`与登录返回ID一致,防止串账号。该步骤确认平台会话,不能只靠Apple凭据状态。 5. 查询组织成员列表,确认当前业务上下文属于本账号;有权限驱动的业务入口时加载当前权限。两类读取可以在用户验证通过后并行执行。 6. 组织和权限准备好后,按目标App实际功能加载首屏数据;不是登录后自动拉取全部后台数据,也不自动上传本机录音、扣费或开通权益。 7. 达到本App必需的初始化条件才发布“已就绪”。可选头像/组织名称加载失败可以降级为占位;需要组织、权限的数据页不能默认按管理员或租户0运行。 如果新账号确实由平台返回`loginTenantId=0`,这是平台当前无成员组织时的行为,不能改成其他组织ID。只进入该账号实际被允许的页面;若业务要求有效组织而账号没有组织,展示“暂无可用组织”,按产品已提供的加入/创建流程处理,不伪造组织。 ### 7.2 已有接口清单 | 阶段 | 方法与路径 | 成功时读取 | 说明 | |---|---|---|---| | Apple身份交换 | POST `/sys/thirdLogin/apple/native` | `result.token`、`result.userInfo`、`result.tenantList` | 未登录可访问;只在授权完成后提交 | | 验证会话、更新资料 | GET `/sys/user/getUserInfo` | `result.userInfo` | 不是直接把整个`result`解码为用户 | | 组织成员关系 | GET `/sys/tenant/getTenantListByUserId?userTenantStatus=1` | `result`数组 | 查询当前登录账号;参数1只过滤正常成员关系,不代表所有返回组织都处于启用状态 | | 当前权限 | GET `/sys/permission/getPermCode` | `result.codeList`、`result.auth` | 按角色、当前租户与订阅过滤;仅在App需要这些功能时接入 | | 记住登录组织 | PUT `/sys/user/changeLoginTenantId` | `success` | Body为`{"loginTenantId":1234}`;是业务写操作 | | 退出平台会话 | POST `/sys/logout` | `success`、`message` | 不要求响应有`result`;需要当前token | 后五类请求均携带平台token;涉及租户业务时携带已确认的当前租户头。实际权限仍由服务端判定。文档中的普通App请求不额外添加AI确认头;AI工具代执行写操作时必须遵守平台确认与审计规则。 ### 7.3 用户、组织和权限响应怎么读 **当前用户**的响应结构示意: ```json { "success": true, "code": 200, "result": { "userInfo": { "id": "platform-user-1", "username": "apple_example", "realname": "示例用户", "loginTenantId": 1234 } } } ``` **组织成员关系**的关键字段示意: ```json { "success": true, "code": 200, "result": [ { "id": "platform-user-1", "tenantUserId": "1234", "name": "示例组织", "status": "1", "userTenantStatus": "1" } ] } ``` 特别注意:这个接口的Mapper将`sys_tenant.id`命名为 **`tenantUserId`**,而`id`是用户ID,不能把`id`当组织ID。`tenantUserId`当前模型为字符串,转为有效整数后才用于租户头。`status`是组织状态,`userTenantStatus`是成员关系状态;不能仅因一条记录在列表中就允许切换。平台当前组织启用值为1、正常成员关系值为1,兼容数字/字符串的响应表示。登录响应的`tenantList`则是另一种模型,其中`id`才是组织ID,不能与此接口复用同一个含义不明的DTO。 **权限**响应中的用途: | 字段 | 用途 | 禁止误用 | |---|---|---| | `codeList` | 当前用户获授权的按钮/操作权限编码数组 | 空数组不表示拥有全部权限 | | `auth` | 当前用户权限项及其规则信息 | 不能代替后端接口校验 | | `allAuth` | 全量权限定义,用于管理台等展示 | **不是当前用户权限,绝不能据此显示全部能力** | | `sysSafeMode` | 平台数据源安全模式信息 | 不是登录成功或管理员标志 | 权限只决定客户端展示与交互,后端仍检查RBAC、租户、套餐、配额及资源归属。外部App不需要构建后台菜单路由;只为自身功能映射明确的权限码。没有对应业务协议时,不根据菜单名称猜测可调用接口或计费权益。 ### 7.4 后续请求的统一封装 ```http GET /api/sys/user/getUserInfo HTTP/1.1 Host: work.zingyang.com Accept: application/json X-Access-Token: <平台 result.token> X-Tenant-Id: <当前已确认的组织ID> ``` - 使用平台`X-Access-Token`,不能把Apple identity token放进去,也不能仅发送Bearer头并假定等价。 - JSON写请求同时设置`Content-Type: application/json`。 - 每个请求从同一会话快照读取用户、token和租户,避免切换组织时拼出“旧token/新缓存/旧租户”的组合。 - 登录交换请求本身不携带旧平台token,避免污染会话;不要自行设置`X-Forwarded-For`来绕过频控。 - 部分接口还有`X-Sign`、`X-TIMESTAMP`等签名要求。以目标接口契约和平台批准的客户端签名方案为准;不要把字典接口的签名规则直接套到所有接口;额外签名要求取决于目标部署的拦截配置。遇到签名拒绝不能全局关闭签名,也不应把服务端秘密随意放进App。 - 解析通用响应时以`success`为主要依据,兼容成功码0/200;普通查询需要`result`时必须检查其存在,而退出、组织记忆等消息型接口允许无`result`。不要用一个强制要求payload的解码器处理所有请求。 统一错误分类: | 响应/故障 | 应做什么 | 不应做什么 | |---|---|---| | 已认证请求明确HTTP401,或明确的业务401会话失效 | 使对应会话失效、停止受保护请求,回登录入口;保留本机用户数据 | 不无限重试旧token | | Apple交换1001~1005 | 按第5节提示;失败不建立新会话 | 不把它当成旧平台会话过期 | | HTTP403或业务510权限拒绝 | 展示权限不足,停止该操作;保留有效登录态 | 不清空全局会话、不换管理员重试 | | 套餐、配额、资源归属或其他业务拒绝 | 按实际接口code/message处理 | 不靠重新登录绕过,也不将普通业务500全部当掉线 | | 超时、断网、5xx、非JSON网关响应 | 显示连接失败和重试入口;保留Keychain,暂停云端业务 | 不判定账号注销或自动删除凭据 | | 请求取消 | 结束当前任务,不显示“密码错误”等误导提示 | 不在已退出/换账号后重新落盘会话 | 可对确认幂等的读取进行有界重试并尊重Retry-After;登录、切换组织、发送消息等写操作不自动无限重试。网络超时可能意味着服务器已受理,必须按具体业务的回读/幂等契约恢复,不能盲目重复写。 ### 7.5 App启动与恢复登录 1. 从本App的Keychain读取完整会话;没有会话则进入未登录或已提供的本机模式,不自动弹Apple授权。 2. 校验本地记录的环境与平台用户ID,设置“恢复中”,暂不展示上一次账号或组织的云端数据。 3. 用保存的平台token调用当前用户接口。成功且用户ID匹配时,再加载组织/权限及首屏。 4. 明确401时清除对应会话并显示重新登录入口;断网或超时只进入“暂时无法验证”状态,允许重试或本机功能,不能伪装成已通过云端认证。 5. 如果App保存了Apple用户标识,可像现有Cofree代码一样调用`getCredentialState`并监听授权撤销;但Apple仍authorized不代表平台token有效,两套状态必须分别处理。不要用Apple状态代替平台当前用户验证。 6. 资料姓名或邮箱为空时仍允许恢复,不每次重新索取首次授权资料。切换账号必须按平台用户ID隔离缓存,不能凭昵称或邮箱判断相同账号。 Keychain记录建议包含:API环境标识、平台用户ID、平台token、当前租户ID及最少显示资料。token不进入UserDefaults、日志、错误截图或埋点;Apple token、rawNonce、授权码在本次交换结束后释放。Keychain保存应更新优先,避免“先删除旧记录、再新增失败”导致原会话丢失。 ### 7.6 过期与续期:当前实现的边界 服务端`ShiroRealm#jwtTokenRefresh`有基于Redis缓存的内部续期逻辑,不是客户端调用的refresh端点,也不保证每次请求都延长会话。客户端继续发送已保存的平台token,不能自行签发新token。 因此: - 不新增 `/refresh`、`/refreshToken` 等猜测接口。 - 不只解码JWT的`exp`就在本地强制退出;平台还会结合服务器缓存与实际校验状态决定会话是否可用,以认证接口的实际响应为准。 - 一旦明确401,不能靠保存的Apple authorizationCode或旧identity token续期;重新走用户发起的Apple授权流程。 - 当前Apple入口按PC类型签发会话,有效期和同类型互踢受平台策略影响。App不写死“永久登录”“固定30天”或“移动端不会互踢”。 ### 7.7 多组织切换(需要此功能的App才接入) 当前已有的是“记住当前登录组织”接口,不是一套会自动返回新会话和全部新权限的交换接口。 1. 先查询组织成员关系,展示当前账号可用的正常组织;目标ID只能来自服务器返回的数据。 2. 用户选择后进入“切换中”,暂停新云端业务请求,取消或隔离旧组织的在途任务。保留旧会话直到收到切换结果。 3. 发送: ```http PUT /api/sys/user/changeLoginTenantId HTTP/1.1 Content-Type: application/json X-Access-Token: <当前平台token> X-Tenant-Id: <切换前的已确认组织ID> ``` ```json {"loginTenantId": 1234} ``` 4. 当前Controller以登录用户身份检查目标租户成员关系,然后更新该用户的`loginTenantId`;**不会签发新token,也不会在响应中返回新用户或新权限**。请求不需要提交其他用户ID。 5. 确认`success=true`后,以同一token和目标租户建立新的本地上下文,重新核对成员关系、加载目标租户权限与首屏。不要把只改请求头等同于完整切换。 6. 该接口本身没有完整的组织启用/订阅校验和用户缓存刷新编排;`getUserInfo.loginTenantId`也可能受缓存影响。最终以目标上下文的实际授权读取为准;无法确认时停在切换错误页,不以旧缓存冒充目标组织数据。业务接口仍必须独立拒绝无权限、禁用或不属于当前账号的组织。 7. 权限与业务缓存按`API环境 + 平台用户ID + 租户ID`分区;旧请求迟到的响应必须丢弃,不得覆盖新组织页面。失败不自动授予权限;请求结果未知时先核实上下文,不用重复写操作“碰运气”。 首次Apple自动建号是否额外加入组织由平台配置决定,客户端不能通过本接口加入一个自己不属于的组织。单组织App可不实现切换UI,但仍应正确携带平台确认的租户上下文。 ### 7.8 退出、切换账号与请求并发 1. 用户主动退出时进入“退出中”,阻止新业务请求,捕获当前会话快照。 2. 带该token执行`POST /sys/logout`,解析`success`;接口响应可没有result。 3. 无论网络是否成功,都允许清理本地Keychain会话、当前账号/组织的受保护缓存、内存用户和权限状态,再进入未登录或本机模式。若Keychain删除失败,应明确提示并禁止自动恢复,不能声称凭据已删除。 4. 服务端注销缓存是异步处理;断网或接口失败时不能保证服务器已立即撤销token。不要将“本机已退出”显示为“Apple授权已撤销”或“账号已删除”。 5. 退出不删除用户本机录音、文档或其他账号的数据,也不自动迁移、合并或上传访客资料。数据清除属于另一项明确操作。 6. 取消尚未完成的Apple平台交换任务;每次登录/退出/换账号/换组织递增会话代次。所有异步响应落盘或改变UI前检查代次仍匹配。旧账号迟到的401不能清除新账号的有效会话,退出后迟到的登录成功也不能重新登录。 ### 7.9 初始化失败时的产品行为 | 阶段 | 失败后的状态 | 恢复入口 | |---|---|---| | 平台交换失败 | 未建立新会话,显示准确业务提示 | 由用户重新授权;按需联系平台 | | Keychain写入失败 | 不进入业务首页,原有效记录不被先删掉 | 提示安全存储错误后重试 | | 当前用户明确401/ID不一致 | 阻止云端业务,失效当前会话 | 重新登录;ID不一致需记录脱敏问题 | | 当前用户网络失败 | 保留凭据,云端初始化未完成 | 重试/本机模式 | | 组织为空或当前组织不可用 | 保留有效身份,不运行要求组织的页面 | 选择可用组织或既有加入组织流程 | | 权限读取失败 | 不默认开放按钮;与明确“无权限”区分 | 重试或联系管理员 | | 可选显示资料失败 | 身份与必要上下文已验证则可继续 | 使用占位并稍后重试 | | 首屏业务失败 | 保留有效登录和组织上下文 | 页面级错误、重试,不重新自动建号 | ## 8. 跨 App 账号与能力边界 当前绑定键包含 Apple `sub`、类型 `apple` 和绑定租户0,没有额外的外部App命名空间。平台 audience 白名单是平台级配置,不是每个业务租户各自的App授权系统。 - 外部App如果验出与现有绑定相同的 `sub`,会进入同一平台账号;如果不同,可能建成独立账号。 - 不保证同一自然人在不同 Developer Team、App分组或迁移条件下得到相同 `sub`。需要共享账号时先验证 Apple 配置与真实用户映射,再决定是否需要受控的绑定/迁移能力。 - 该登录接口没有“将 Apple 绑定到当前已有密码账号”的请求参数,不按邮箱自动合并,不允许客户端提交 `sysUserId` 选定账号。 - 自动建号、额外加入默认组织是平台级策略,不能按本次请求任意指定组织或角色。 - 登录成功不自动获得 Talk 订阅、外部App付费权限或管理员权限;订阅、订单和权益另走业务接口。 - 当前流程不消费 Apple credential 撤销通知,也未实现本流程的授权码交换、Apple token撤销闭环。账号删除、授权撤销等产品流程应另行核对,不能把本登录接口当作已覆盖。 ## 9. 联调验收清单 以下是真实接入时需要执行的检查,不是本文已执行的测试: | 场景 | 预期 | |---|---| | 目标 Bundle ID 首登 | audience校验通过;按平台策略建号或给出1002,成功时返回平台会话 | | 相同Apple身份再次登录 | 返回同一平台用户ID,不重复建号 | | 非白名单Bundle ID | 拒绝,不创建用户或绑定 | | 缺少/不匹配nonce,篡改/过期token | 拒绝,不产生新用户或绑定 | | 用户取消授权 | 静默回到可登录状态,不请求平台登录 | | 二次授权无姓名/邮箱 | 仍能登录;已有资料不被空值覆盖 | | 平台账号禁用或删除 | 拒绝登录,不绕过账号状态 | | 自动建号开关关闭 | 未绑定账号拒绝,不悄悄新建 | | 查询当前用户 | 平台 token 有效,返回ID与登录响应相同 | | 租户与权限 | 仅访问账号所属且获授权的数据;拒绝不能被客户端升权绕过 | | 网络、1003、1005 | 提示可理解,不自动密集重试,不记录敏感凭据 | | 退出、会话过期、App重启 | 本地凭据和界面状态一致;过期凭据不继续访问业务 | | 登录后当前用户校验 | result.userInfo.id与平台登录返回ID一致;ID不一致阻止初始化 | | 组织字段映射 | 成员接口tenantUserId用于组织ID,id不被误用;禁用/非正常成员关系不可切换 | | 权限初始化 | 仅codeList/auth用于当前授权,allAuth不得作为获权集合 | | 断网恢复 | 保留Keychain,不自动当作401登出;需要联网的页面保持错误/待验证状态 | | 组织切换 | 成功后重载目标上下文;失败不显示目标数据,迟到的旧响应被隔离 | | 并发退出/重新登录 | 旧401不清新会话,已退出后的迟到登录结果不落盘 | 真实首登会创建平台账号、第三方绑定及相关个人空间资源。测试与生产使用同一地址,不代表测试数据自动隔离。真实首登前须确认测试账号、租户和数据清理范围;不要用真实业务账号随意试建号。缺少真实Apple设备授权或目标audience配置时,应记录未验证,不将模拟JWT单测算作端到端通过。 ## 10. 排查顺序 - Apple授权阶段失败:先检查App ID能力、签名profile、设备Apple Account和系统错误,不改后端鉴权。 - 1001:检查目标Bundle ID是否进入当前部署的audiences、token是否完整、rawNonce是否配对、服务端时钟和Apple公钥连通性。只记录脱敏原因,不贴完整token。 - 1002:区分总开关关闭与未绑定禁止建号,联系平台管理员;不能改走弱校验接口。 - 1003:由平台检查Apple JWKS访问、超时与证书环境。 - 1004:检查建号/绑定冲突;不要删除历史绑定“重试”。 - 登录成功但业务拒绝:检查平台返回的账号、租户、角色和套餐,登录与业务授权分别排查。 ## 11. 平台实现参考 以下路径仅供平台维护者核对;外部App不需要复制这些后端实现: - 入口:`zing-boot/zing-module-system/zing-system-biz/src/main/java/org/zing/modules/system/controller/LoginController.java#appleNativeLogin` - 账号编排:同模块 `service/apple/AppleSignInService.java#authenticate` - 验签与公钥:`service/apple/AppleIdentityTokenVerifier.java`、`HttpAppleJwksProvider.java` - 配置与错误码:`service/apple/AppleSignInProperties.java`、`AppleSignInErrorCode.java` - 现有客户端:`zing-talk/zing-talk/Features/Auth/LoginView.swift` - 客户端会话:`zing-talk/Packages/ZingTalkCore/Sources/ZingTalkCore/Auth/AuthService.swift#loginWithApple` - 用户与组织记忆:`zing-boot/zing-module-system/zing-system-biz/src/main/java/org/zing/modules/system/controller/SysUserController.java#changeLoginTenantId` - 组织列表字段:同模块 `controller/SysTenantController.java#getTenantListByUserId`、`mapper/xml/SysTenantUserMapper.xml#getTenantListByUserId` - 权限:同模块 `controller/SysPermissionController.java#getPermCode` - 服务端会话续期:`zing-boot/zing-boot-base-core/src/main/java/org/zing/config/shiro/ShiroRealm.java#jwtTokenRefresh` 核验范围:本轮静态核对现有客户端、Controller、验签、建号及会话代码,并查阅Apple官方能力配置说明;未修改运行配置、未调用真实Apple登录、未验证外部App的Bundle ID,也未对本文Swift示例执行目标工程构建。示例需在目标iOS工程编译与真实设备联调后验收。 地址核实记录(2026-10-22):用户明确测试、生产统一使用 `https://work.zingyang.com`;只读访问 `/api/sys/loginCaptchaEnabled` 返回HTTP200、JSON且success=true,`/sys/loginCaptchaEnabled` 返回HTML。因此本文API根地址使用 `https://work.zingyang.com/api`。未执行线上Apple登录或建号。 2026-10-23 Cofree接入变更:新增配置中心audiences消费与172迁移;没有改变签名、issuer、nonce或租户校验。部署和迁移未完成前,不代表线上已经允许Cofree。 发布兼容说明:升级时容忍旧外部audiences属性绑定,但该值会被忽略;实际白名单只从配置中心读取。配置管理仍按本文步骤操作。旧版实例若未切换成功,数据库迁移完成不代表实际登录已使用新白名单。 2026-10-23 v1.1:补齐登录交换后的Keychain落盘、身份/组织/权限初始化、统一认证请求、启动恢复、服务端内部续期边界、现有组织切换接口、退出及并发隔离。此次为源码核实后的文档更新,未执行线上组织切换、退出或真实Apple授权,也不代表Cofree已实现全部流程。 Canonical: https://doc.api.zingyang.com/ios-cos-upload/ # 外部 iOS App 文件上传与 COS 接入指南 版本:1.0;更新:2026-10-23。 适用:Cofree 等外部 iOS App,普通图片/附件上传,以及评估大文件 COS 直传。业务 API 统一使用 `https://work.zingyang.com/api`,测试与生产使用同一地址。文档站只提供文档,不接收文件或 token。 ## 1. 登录与上传的关系 Apple 授权用于换取平台登录身份,不会授予 COS 上传凭据。先按照 [Apple 登录指南](/apple-sign-in-ios/) 完成平台登录、当前组织选择及会话初始化。 后续上传请求使用平台 token,不使用 Apple identityToken 或 authorizationCode: ```http X-Access-Token: <平台token> X-Tenant-Id: <当前组织tenantId> ``` token 与 tenantId 应从同一当前会话读取;切换组织后重新读取,不复用原组织的上传上下文。组织需具备存储套餐、额度和适用权限。原生 App 的正常业务请求沿用用户权限,不需要为了本指南额外添加 AI 调用标识;AI 工具代用户调用时另须遵守平台 AI 确认与审计契约。 ## 2. 两种方式如何选择 | 方式 | 适用 | 当前接入边界 | |---|---|---| | 统一上传接口 | 普通图片、小型附件 | iOS 用 URLSession 发送文件;由服务端处理校验、额度、存储与登记;存储目标由服务端配置决定 | | COS SDK 直传 | 录音、大文件、分片等需求 | 先申请对应业务上传任务,再获取限定对象临时凭据,上传后完成业务确认;不能仅凭登录 token 任意创建业务场景 | 普通附件优先使用统一上传接口。`biz` 只是业务目录,不是存储类型、权限或租户选择参数;传 `biz=cos` 不会强制使用 COS。 ## 3. 普通文件上传接口 ```http POST https://work.zingyang.com/api/sys/common/upload X-Access-Token: <平台token> X-Tenant-Id: <当前组织tenantId> Content-Type: multipart/form-data; boundary=<本次请求boundary> ``` | 表单字段 | 类型 | 说明 | |---|---|---| | file | 二进制文件 | 必填;带 filename 和真实 MIME 类型 | | biz | 文本 | 可选;使用约定业务目录,例如 cofree/attachments;不是服务端已登记的业务权限或专用接口 | Content-Type 中的 boundary 必须与请求体一致。不要用 JSON/base64 替代这个接口要求的 multipart 文件。 成功响应的关键字段如下(其他统一响应字段省略): ```json { "success": true, "message": "https://example.invalid/path/photo.jpg", "result": null } ``` **文件地址在 `message`,不是 `result.url`。** 失败时也可能由统一响应返回 `success=false` 和 `message`,不能只看 HTTP 200。示例域名为占位符,不能用于实际访问。 该接口返回 URL,不承诺返回 fileId。业务必须使用文件 ID 时,需要核对对应业务既有上传接口,不能把 URL 当 ID,也不能凭空调用一个未提供的“查询文件 ID”接口。 ## 4. Swift URLSession 示例 以下为小文件前台上传示例,使用 Foundation 和 async/await。它将完整请求体放入内存,不适用于大文件或可靠后台传输。请接入 App 既有网络层与凭据存储,不另建会话管理体系。 ```swift import Foundation struct PlatformUploadResponse: Decodable { let success: Bool let message: String? } enum PlatformUploadError: Error { case emptyFile case invalidMetadata case httpStatus(Int) case business(String) case invalidURL } func uploadAttachment( data: Data, fileName: String, mimeType: String, token: String, tenantId: String ) async throws -> URL { guard !data.isEmpty else { throw PlatformUploadError.emptyFile } // 防止 multipart 头注入;文件名由调用方提供,例如 photo.jpg。 guard !fileName.isEmpty, !fileName.contains(where: { "\r\n\"\\/".contains($0) }), !mimeType.isEmpty, !mimeType.contains(where: { "\r\n".contains($0) }) else { throw PlatformUploadError.invalidMetadata } let boundary = "ZingUpload-\(UUID().uuidString)" var body = Data() func append(_ value: String) { body.append(Data(value.utf8)) } append("--\(boundary)\r\n") append("Content-Disposition: form-data; name=\"biz\"\r\n\r\n") append("cofree/attachments\r\n") append("--\(boundary)\r\n") append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n") append("Content-Type: \(mimeType)\r\n\r\n") body.append(data) append("\r\n--\(boundary)--\r\n") var request = URLRequest(url: URL(string: "https://work.zingyang.com/api/sys/common/upload")!) request.httpMethod = "POST" request.timeoutInterval = 120 request.setValue(token, forHTTPHeaderField: "X-Access-Token") request.setValue(tenantId, forHTTPHeaderField: "X-Tenant-Id") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let (responseData, response) = try await URLSession.shared.upload( for: request, from: body) guard let http = response as? HTTPURLResponse else { throw PlatformUploadError.business("上传响应无效") } guard (200..<300).contains(http.statusCode) else { throw PlatformUploadError.httpStatus(http.statusCode) } let value = try JSONDecoder().decode( PlatformUploadResponse.self, from: responseData) guard value.success else { throw PlatformUploadError.business(value.message ?? "上传失败") } guard let text = value.message, let url = URL(string: text), ["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else { throw PlatformUploadError.invalidURL } return url } ``` 调用示例(token/tenantId 来自当前会话): ```swift let uploadedURL = try await uploadAttachment( data: jpegData, fileName: "photo.jpg", mimeType: "image/jpeg", token: session.token, tenantId: String(session.tenantId) ) // 按目标业务既有保存接口契约关联 uploadedURL.absoluteString。 ``` 不要打印 token、完整请求头或用户文件内容。以上调用片段中的 jpegData/session 由接入工程提供,不是平台 SDK 的固定类型。 ## 5. 上传后的完整流程 1. 校验会话有效,取得当前组织;用户取消选择文件时结束,不发请求。 2. 读取文件,检查非空、类型与大小;具体上限以目标环境网关、应用和业务限制为准,本指南不承诺固定上限。 3. multipart 上传,显示进度或加载状态,避免重复点击。 4. 同时检查 HTTP 状态与 `success`,从 `message` 取得地址。 5. 调目标业务已有保存接口关联地址;本指南不新增 Cofree 附件业务接口。 6. 上传成功但业务保存失败时保留上传结果重试业务保存,不立即重复上传。 7. 请求超时可能发生在文件实际上传完成后,不能推断服务端一定未写入。通用上传不承诺请求级幂等,自动重试前评估重复对象与存储额度。 8. 退出登录或切换组织时,取消或隔离旧任务结果,不能把旧组织文件写入新组织业务。 通用接口尝试统一文件登记,但实现保留“对象上传成功、登记失败交台账重试”的兼容行为。因此获取 URL 不代表拿到了可供任意业务关联的文件 ID,也不表示登记台账已成功。 ## 6. 怎样确认真正上传到 COS 通用接口按服务端 `zing.uploadType` 选择 `cos` 或 `local`。上线前由管理员核对当前运行配置:上传类型为 COS、COS 已启用、桶/地域/凭据正确,且当前组织具备存储权益与剩余额度。配置维护沿用平台配置中心,不能把云密钥写入 iOS。 本指南根据当前仓库实现编写,**未用 Cofree 真实账号向生产上传文件,也未确认线上当前配置值和部署版本**。应在获授权的测试组织完成小文件联调,核对最终对象落桶、访问策略、组织归属与业务关联。私有桶不会因为返回 URL 就自动允许匿名读取;需要时使用该业务既有的授权读取方案,不能把私有文件设置为公开来绕过鉴权。 ## 7. 大文件 COS 直传契约 已有凭据入口: ```http GET https://work.zingyang.com/api/sys/common/cos/sts?scene=<已支持场景>&uploadId=<业务任务ID> X-Access-Token: <平台token> X-Tenant-Id: <当前组织tenantId> ``` 指定场景时,后端通过场景解析器验证任务并确定对象 key,再签发限定该对象的临时凭据。关键字段位于统一响应 `result`: | 字段 | 含义 | |---|---| | bucket、region | 服务端指定桶和地域 | | objectKey | 本次任务允许写入的对象路径,不自行替换 | | tmpSecretId、tmpSecretKey、sessionToken | 同一组临时凭据;不持久化、不打印 | | startTime、expiredTime | Unix 秒级时间,过期重新按业务流程申请 | | domain、allowPrefix | 服务端提供的域名/范围信息,不替代任务授权 | 流程为:业务创建上传任务 → 获取限定临时凭据 → 腾讯云 iOS COS SDK 上传 → 业务完成确认 → 服务端核验并关联。COS 上传成功仅代表对象传输成功,不能跳过业务完成确认。 仓库已有 `talk-recording`、`talk-avatar` 调用,属于 ZingTalk 业务。Cofree 不应直接复用这些 scene 或虚构 uploadId;尚需核对 Cofree 目标业务的任务申请、场景与完成接口。本指南不将其描述为已可直接接入的通用 Cofree 大文件接口。 不要使用未指定 scene 的旧通用凭据路径作为外部 App 的默认方案:当前实现存在未配置角色时返回非临时密钥的兼容分支。App 不应接收或内置长期云密钥;发现无 sessionToken 的凭据响应应停止该直传方案,由服务端核查。 ## 8. 错误与排查 | 现象 | 排查动作 | |---|---| | 未登录、token 过期 | 按登录指南处理平台会话;不使用 Apple token 替代 | | 无租户、未开通套餐、额度不足 | 核对当前 tenantId、组织存储权益和容量 | | HTTP 413 | 文件超过网关/服务端上限;核实上限后决定压缩或使用已支持的直传业务 | | HTTP 200 但 success=false | 显示业务 message;不要当作上传成功 | | 解码失败或返回 HTML | 核对 /api 前缀、网关响应和实际 Content-Type | | 文件类型被拒绝 | 使用允许的文件和真实 MIME;不靠改扩展名绕过校验 | | COS 未启用/配置不完整 | 管理员核查服务端配置,不向客户端下发长期密钥 | | Unsupported COS upload scene | 该 scene 没有已支持的业务解析器,先核对契约 | | 上传成功但 URL 无法打开 | 核对对象存在、域名和桶访问策略;区分私有读取与上传失败 | | 超时/断网/取消 | 保留本地文件供用户重试,避免无条件重复上传 | 联调至少覆盖成功、过期会话、缺组织、额度不足、空文件、类型拒绝、断网重试、业务保存失败与组织切换。只读文档发布不代表上述业务用例已执行。 ## 9. 实现依据与维护 源码核对日期:2026-10-23。 - CommonController#upload:POST /sys/common/upload,读取 file/biz,URL 写入 message。 - FileUploadServiceImpl#upload / uploadFile:按存储类型上传,文件安全校验、容量校验、统一登记。 - CommonController#cosSts 与 ScopedCosCredentialsService#issue:场景限定临时凭据。 - 现有 iOS RecordingUploadCoordinator#start、ProfileStore#cosCredentials:业务任务凭据和 COS SDK 上传调用。 平台登录与会话管理见 [Apple 登录指南](/apple-sign-in-ios/)。本文原始 Markdown:[ios-cos-upload.md](/ios-cos-upload.md)。