文档 / 接入指南

FILE UPLOAD · iOS

iOS 文件上传
与 COS 接入

从平台会话到文件上传,覆盖 Swift 请求示例、响应处理与业务关联。

原始 Markdown ↗AI 全文 ↗

版本:1.0;更新:2026-10-23。

适用:Cofree 等外部 iOS App,普通图片/附件上传,以及评估大文件 COS 直传。业务 API 统一使用 https://work.zingyang.com/api,测试与生产使用同一地址。文档站只提供文档,不接收文件或 token。

1. 登录与上传的关系

Apple 授权用于换取平台登录身份,不会授予 COS 上传凭据。先按照 Apple 登录指南 完成平台登录、当前组织选择及会话初始化。

后续上传请求使用平台 token,不使用 Apple identityToken 或 authorizationCode:

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. 普通文件上传接口

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 文件。

成功响应的关键字段如下(其他统一响应字段省略):

{
  "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 既有网络层与凭据存储,不另建会话管理体系。

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 来自当前会话):

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 直传契约

已有凭据入口:

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 登录指南。本文原始 Markdown:ios-cos-upload.md。