# 外部 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": "<Apple 返回的原始 JWT>",
  "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已实现全部流程。
