# fix(agency): 旅行社资质附件上传必须先调通用 OSS 接口拿 url, 再以 application/json 提交元数据

**日期**: 2026-05-06 18:30
**通知对象**: @mmg (前端) — **必须改前端「公司资质 - 新增/编辑」请求方式**
**关联 PR**: 无 (后端无需改动, 接口设计与项目内其它「URL 字段」类接口一致)
**测试服 round-trip**: ✅ JSON 路径创建成功 / multipart 路径复现 500

---

## ⚠️ 一句话摘要

**`POST /admin/travel-agency/{agencyId}/qualification` 后端接的是 `application/json`, 前端目前直接发 `multipart/form-data`(把文件流当成 body) → Spring MVC 抛 `HttpMediaTypeNotSupportedException` 返回 500。前端必须改成两步上传: 先调通用 OSS 上传接口拿 url, 再 POST 资质接口带 fileUrl 等元数据 JSON。**

---

## 一、报错信息

```
服务器内部错误[HttpMediaTypeNotSupportedException]:
Content type 'multipart/form-data;boundary=----WebKitFormBoundaryW7TegwvnmSqm3boT;charset=UTF-8' not supported
```

触发路径: `POST /admin/travel-agency/2051922156798779394/qualification`

---

## 二、后端契约 (不变)

```
POST /admin/travel-agency/{agencyId}/qualification
Content-Type: application/json
Authorization: Bearer <token>
```

请求 body (`AgencyQualificationSaveReqVO`):

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | String(64) | ✅ | 资质名称, 例: `旅行社业务经营许可证` / `营业执照` |
| `qualificationType` | String(32) | ✅ | 字典 `agency_qualification_type`: `BUSINESS_LICENSE` / `TRAVEL_AGENCY_LICENSE` / `VALUE_ADDED_TELECOM_LICENSE` |
| `fileUrl` | String | ✅ | **OSS 永久 URL (从通用上传接口拿)**, **不是文件流** |
| `fileType` | String | 否 | 字典 `agency_qualification_file_type`: `PDF` / `IMAGE` / `OTHER` |
| `issueDate` | LocalDate | 否 | 颁发日期, `2024-01-01` |
| `expireDate` | LocalDate | 否 | 过期日期, `null=长期有效` |
| `sortOrder` | Integer | 否 | 排序值 |
| `remark` | String | 否 | 备注 |

> 编辑接口 `PUT /admin/travel-agency/{agencyId}/qualification/{qualificationId}` 同样 JSON, body 同上。

---

## 三、前端正确调用方式 (两步)

### 步骤 1 - 上传文件到 OSS, 拿到永久 URL

```
POST /admin/sys/oss/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
form-data: file=<File 对象>
```

返回:

```json
{
  "code": 200,
  "data": {
    "id": 123,
    "url": "https://hulalv-private.oss-cn-shanghai.aliyuncs.com/xxx/yyy.pdf",
    "name": "营业执照.pdf",
    "size": 102400
  }
}
```

> 这是项目内通用 OSS 上传接口, 富文本/素材库已经在用, 复用即可 — `hl-ui/src/api/material.js#uploadFile` 应该就是这个。

### 步骤 2 - 提交资质元数据 (带上一步拿到的 url)

```
POST /admin/travel-agency/{agencyId}/qualification
Content-Type: application/json
Authorization: Bearer <token>
```

```json
{
  "name": "营业执照",
  "qualificationType": "BUSINESS_LICENSE",
  "fileUrl": "https://hulalv-private.oss-cn-shanghai.aliyuncs.com/xxx/yyy.pdf",
  "fileType": "PDF",
  "issueDate": "2024-01-01",
  "expireDate": "2029-12-31",
  "sortOrder": 100,
  "remark": "营业期限至 2029-12-31"
}
```

返回:

```json
{ "code": 200, "data": 2051971194931859458 }
```

`data` 是新建的 `qualificationId`, 编辑/删除接口需要带它。

---

## 四、为什么不让后端直接接 multipart

1. **设计一致性**: 项目内「URL 字段」类接口(产品图、合同附件元数据、酒店封面等)统一约定 `fileUrl: String`, 文件上传统一走 `/admin/sys/oss/upload`, 业务接口只接 JSON 元数据。
2. **OSS 直传降本**: 走通用上传接口可以走未来的 OSS STS 直传链路, 业务接口不需要重复封装文件流。
3. **后续 P1-11 任务**: `fileUrl` 在 RespVO 中要转 30 min 临时签名 URL (私有 bucket), 这套也是基于「永久 url 落库」的两步设计。

> 项目里另一个接口 `POST /admin/contract/scheme/{schemeId}/attachments` 直接接 multipart 是历史模式, 新接口都按上面两步走。

---

## 五、Round-trip 验证记录

| 测试 | Content-Type | 期望 | 实际 |
|------|------|------|------|
| 1 | `application/json` | 创建成功 | ✅ HTTP 200 + `code:200` + `data:2051971194931859458` (已清理) |
| 2 | `multipart/form-data` (前端目前的发法) | 复现报错 | ✅ HTTP 200 + `code:500` (前端看到的报错) |

测试服: `https://api.test.1814.love:9443`
agencyId: `2051922156798779394`
admin token: 真实登录 (admin / Admin@123456)

---

## 六、改动范围 (前端)

| 模块 | 改动 |
|------|------|
| `hl-ui/src/api/agency.js` (或同名文件) 资质 CRUD 函数 | 新增/编辑请求体改成 JSON, 不再 FormData |
| 「公司详情 - 资质附件」组件 | 上传组件先调 OSS 拿 url, 再调资质接口提交元数据 |
| 删除/列表/排序接口 | 不受影响, 已 round-trip 通过 |

---

**有不清楚的字段或字典值, 直接 @ wx 加补。**
