# 外部 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)。
