{"record":{"id":"30c7cb65e3b93f2b","repo":"yikart/AiToEarn","slug":"relay-api-error-body-code-body-message","errorCode":null,"errorMessage":"Relay API error [${body.code}]: ${body.message}","messagePattern":"Relay API error \\[(.+?)\\]: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"project/aitoearn-backend/apps/aitoearn-ai/src/core/ai/relay-media/relay-media-resolver.service.ts","lineNumber":128,"sourceCode":"    const cdnPrefix = this.assetsConfig?.cdnEndpoint?.replace(/\\/+$/, '')\n    const endpointPrefix = this.assetsConfig?.endpoint?.replace(/\\/+$/, '')\n    if (cdnPrefix && endpointPrefix && localUrl.startsWith(cdnPrefix)) {\n      const bucket = this.assetsConfig && 'bucketName' in this.assetsConfig\n        ? String(this.assetsConfig.bucketName || '')\n        : ''\n      const internalBase = `${endpointPrefix}${bucket ? `/${bucket}` : ''}`\n      return localUrl.replace(cdnPrefix, internalBase)\n    }\n    return localUrl\n  }\n\n  private async post<T>(url: string, data: unknown): Promise<T> {\n    const response = await this.httpClient!.post<RelayCommonResponse<T> | T>(url, data)\n    const body = response.data\n\n    if (this.isCommonResponse(body)) {\n      if (body.code != null && body.code !== 0) {\n        throw new Error(`Relay API error [${body.code}]: ${body.message}`)\n      }\n      return body.data\n    }\n\n    return body\n  }\n\n  private isCommonResponse<T>(body: RelayCommonResponse<T> | T): body is RelayCommonResponse<T> {\n    return typeof body === 'object' && body !== null && 'data' in body\n  }\n\n  private async uploadFileFromLocalUrl(localUrl: string): Promise<string> {\n    const filename = basename(new URL(localUrl).pathname)\n\n    const fileResponse = await axios.get(localUrl, { responseType: 'arraybuffer' })\n    const contentType = fileResponse.headers['content-type'] || 'application/octet-stream'\n    const size = (fileResponse.data as ArrayBuffer).byteLength\n","sourceCodeStart":110,"sourceCodeEnd":146,"githubUrl":"https://github.com/yikart/AiToEarn/blob/d3aa8bea5b146a8675607cf0144d891aad3e9683/project/aitoearn-backend/apps/aitoearn-ai/src/core/ai/relay-media/relay-media-resolver.service.ts#L110-L146","documentation":"RelayMediaResolverService.post 是 Relay 媒体服务（uploadSign、confirm 等）的 HTTP 封装：当响应体是 RelayCommonResponse 且 code 非 0（非 null）时，以普通 Error 抛出 `Relay API error [code]: message`。表示 Relay 后端拒绝了请求（业务错误码）。","triggerScenarios":"调用 post 走 /api/assets/uploadSign 或 /api/assets/{id}/confirm 时，Relay 返回业务错误：文件大小超限、签名参数非法、资产 ID 不存在或已确认、鉴权/RELAY_API_KEY 无效导致业务拒绝。","commonSituations":"Relay 服务器地址或 Key 与环境不匹配（aitoearn.cn vs aitoearn.ai）；上传文件超过临时资产大小限制；重复 confirm 同一资产；Relay 服务端版本与客户端约定的错误码结构不一致。","solutions":["读取错误消息中的 [code]，对照 Relay API 文档确定具体业务错误含义。","核对 RELAY_SERVER_URL 与 RELAY_API_KEY 是否来自同一环境（cn 域名 Key 不能配 ai 域名 URL）。","检查 uploadSign 请求的 filename/type/size 是否合法，size 是否与实际文件一致。","确认资产只 confirm 一次，且 uploadUrl 的 PUT 已成功。"],"exampleFix":"// before\nconst sign = await resolver.signResult({ filename, type, size }) // 内部 code!=0 直接抛 Error\n// after\ntry {\n  const sign = await resolver.signResult({ filename, type, size })\n} catch (e) {\n  const m = /^Relay API error \\[(\\d+)\\]/.exec((e as Error).message)\n  if (m) {\n    logger.error({ relayCode: m[1] }, 'Relay rejected request; check RELAY_SERVER_URL/RELAY_API_KEY env match')\n  }\n  throw e\n}","handlingStrategy":"try-catch","validationCode":"if (!process.env.RELAY_SERVER_URL || !process.env.RELAY_API_KEY) {\n  throw new Error('RELAY_SERVER_URL and RELAY_API_KEY are required')\n}\n// env must match key origin: *.aitoearn.cn keys only with cn URLs, *.aitoearn.ai keys only with ai URLs\nconst isCnKey = process.env.RELAY_API_KEY_PREFIX === 'cn'\nconst urlIsCn = process.env.RELAY_SERVER_URL.includes('aitoearn.cn')\nif (isCnKey !== urlIsCn) throw new Error('Relay key/env mismatch')","typeGuard":"function isRelayCommonResponse<T>(b: unknown): b is { code: number, message: string, data: T } {\n  return b != null && typeof b === 'object' && 'data' in b && typeof (b as any).code === 'number'\n}","tryCatchPattern":"try {\n  return await post(url, data)\n} catch (e) {\n  const m = /^Relay API error \\[(\\d+)\\]: (.*)$/.exec((e as Error).message)\n  if (m) {\n    const [, code, message] = m\n    if (code === '401' || code === '403') throw new Error('Check RELAY_API_KEY matches RELAY_SERVER_URL environment')\n    if (RETRYABLE_RELAY_CODES.includes(code)) return retryWithBackoff(() => post(url, data))\n  }\n  throw e\n}","preventionTips":["确保 RELAY_SERVER_URL 与 RELAY_API_KEY 属于同一环境（cn vs ai 域名）","上传前核对文件大小不超过 Relay 临时资产限制","避免对同一资产重复调用 confirm","解析错误消息中的 [code] 并按码分类处理，不要笼统重试"],"tags":["relay","http-api","error-code","media-upload"],"backgroundTag":"relay-api-error-code","analyzedSha":"d3aa8bea5b146a8675607cf0144d891aad3e9683","analyzedAt":"2026-08-31T14:19:24.185Z","schemaVersion":2},"datasetVersion":"2026-08-31T19:17:28.585Z"}