hyapp-server/docs/flutter对接/Fami_房间封面上传Flutter接口对接.md
2026-07-21 11:31:21 +08:00

206 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fami 房间封面上传 Flutter 接口对接
## 接入结论
Fami 创建房间和修改房间封面前,必须先调用 `POST /api/v1/rooms/covers/upload`。上传成功后只把 `cover_upload_id` 提交给创建/更新接口;响应里的 `url` 只用于本地预览,不能继续作为 `room_avatar` 提交。
服务端根据文件魔数识别实际格式校验扩展名、multipart Content-Type、尺寸、字节数、帧数和动画时长。文件原始字节直接写 COS不裁剪、不转码。动态 GIF/WebP 由服务端调用 Wallet `CheckVipBenefit(animated_room_cover)`Fami 当前配置为 VIP4 解锁Flutter 不得用本地 `vipLevel >= 4` 代替服务端判定。
## 1. 上传接口
```http
POST /api/v1/rooms/covers/upload
Authorization: Bearer {access_token}
Content-Type: multipart/form-data
command_id={本次选择文件的上传幂等键}
file={原始文件字节}
```
约束:
| 项目 | 规则 |
| --- | --- |
| 格式 | JPEG、PNG、GIF、WebP不支持 APNG |
| 一致性 | 魔数、文件扩展名、multipart Content-Type 必须一致 |
| 大小 | 最大 10 MiB服务端读取字节数必须与 multipart 文件大小一致 |
| 尺寸 | 宽、高均不超过 4096总像素不超过 16,777,216 |
| 动画 | 仅多帧 GIF/WebP最多 120 帧,总时长 `(0, 15000]ms` |
| 静态 | `frame_count=1``animated=false``duration_ms=0` |
同一次上传因超时重试时复用原 `command_id` 和同一文件;用户重新选择文件时生成新的 `command_id`。不要统一发送 `application/octet-stream`
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"cover_upload_id": "cover_12ab34cd56ef789012ab34cd56ef7890",
"url": "https://cdn.example.com/room-media/fami/covers/10001/x.gif",
"sha256": "64位小写十六进制",
"frame_count": 18,
"animated": true,
"content_type": "image/gif",
"format": "gif",
"size_bytes": 1256789,
"width": 640,
"height": 640,
"duration_ms": 3600,
"server_time_ms": 1784592000000
}
}
```
`url`、后缀和 `content_type` 都是服务端按实际格式生成的结果。Flutter 可以立即用 `url` 预览,但持久化房间封面只能使用 `cover_upload_id`
## 2. 创建房间
```http
POST /api/v1/rooms/create
Content-Type: application/json
{
"command_id": "create_room_550e8400-e29b-41d4-a716-446655440000",
"seat_count": 10,
"mode": "voice",
"room_name": "My Room",
"cover_upload_id": "cover_12ab34cd56ef789012ab34cd56ef7890",
"room_description": ""
}
```
Fami 不再提交 `room_avatar`。服务端会校验上传记录属于当前 `app_code + user_id`、状态为可消费,并在创建命令事务内绑定房间和推进 `consumed`。一个 `cover_upload_id` 不能创建两个房间。
## 3. 修改房间封面
```http
POST /api/v1/rooms/profile/update
Content-Type: application/json
{
"room_id": "fami_room_xxx",
"command_id": "update_cover_550e8400-e29b-41d4-a716-446655440000",
"cover_upload_id": "cover_98ab76cd54ef321098ab76cd54ef3210"
}
```
`cover_upload_id` 与历史字段 `room_avatar` 互斥。资料修改成功后凭证立即消费HTTP 超时重试要同时复用原更新 `command_id` 和原 `cover_upload_id`
## 4. Dio 示例
```dart
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
class RoomCoverUploadResult {
const RoomCoverUploadResult({
required this.coverUploadId,
required this.url,
required this.sha256,
required this.frameCount,
required this.animated,
});
final String coverUploadId;
final String url;
final String sha256;
final int frameCount;
final bool animated;
factory RoomCoverUploadResult.fromJson(Map<String, dynamic> json) {
return RoomCoverUploadResult(
coverUploadId: json['cover_upload_id'] as String,
url: json['url'] as String,
sha256: json['sha256'] as String,
frameCount: json['frame_count'] as int,
animated: json['animated'] as bool,
);
}
}
MediaType roomCoverMediaType(String extension) {
switch (extension.toLowerCase()) {
case 'jpg':
case 'jpeg':
return MediaType('image', 'jpeg');
case 'png':
return MediaType('image', 'png');
case 'gif':
return MediaType('image', 'gif');
case 'webp':
return MediaType('image', 'webp');
default:
throw ArgumentError('unsupported room cover extension');
}
}
Future<RoomCoverUploadResult> uploadRoomCover({
required Dio dio,
required String filePath,
required String fileName,
required String extension,
required String uploadCommandId,
}) async {
final response = await dio.post<Map<String, dynamic>>(
'/api/v1/rooms/covers/upload',
data: FormData.fromMap({
'command_id': uploadCommandId,
'file': await MultipartFile.fromFile(
filePath,
filename: fileName,
contentType: roomCoverMediaType(extension),
),
}),
);
final envelope = response.data!;
if (envelope['code'] != 'OK') {
throw StateError(envelope['code'] as String? ?? 'UPLOAD_FAILED');
}
return RoomCoverUploadResult.fromJson(envelope['data'] as Map<String, dynamic>);
}
```
选择图片时必须保留 GIF/WebP 原始字节。静态图若客户端裁剪后输出 JPEG文件名应改成 `.jpg/.jpeg`multipart MIME 应为 `image/jpeg`;不能保留原 `.png/.gif` 名称。
创建/更新参数示例:
```dart
await dio.post('/api/v1/rooms/create', data: {
'command_id': createCommandId,
'seat_count': seatCount,
'mode': mode,
'room_name': roomName,
'cover_upload_id': uploaded.coverUploadId,
'room_description': description,
});
await dio.post('/api/v1/rooms/profile/update', data: {
'room_id': roomId,
'command_id': updateCommandId,
'cover_upload_id': uploaded.coverUploadId,
});
```
## 5. 错误处理
| HTTP / code | 含义 | Flutter 动作 |
| --- | --- | --- |
| `403 / VIP_BENEFIT_REQUIRED` | 动态封面缺少 `animated_room_cover` | 读取 `data.benefit_code``required_level`,展示升级入口;不要自行写死 VIP4 |
| `413 / FILE_TOO_LARGE` | 超过 10 MiB | 保留选择页并提示重新选择 |
| `415 / UNSUPPORTED_MEDIA_TYPE` | 魔数、扩展名、MIME 或文件结构不一致 | 不重试;重新选择或修正 multipart MIME |
| `400 / INVALID_MEDIA_DIMENSIONS` | 尺寸、总像素、帧数或时长越界 | 不重试;重新选择 |
| `409 / IDEMPOTENCY_CONFLICT` | 同一上传 `command_id` 换了文件 | 为新文件生成新 `command_id` |
| `404 / NOT_FOUND` | 创建/更新时凭证不存在 | 重新上传,再提交新凭证 |
| `403 / PERMISSION_DENIED` | 凭证不属于当前用户/租户,或不是房主 | 丢弃本地凭证并刷新房间权限 |
服务端错误是最终权限事实。本地 VIP 信息只能用于提前展示锁态,不能跳过上传请求或把动态 URL 直接交给创建/更新接口。
## 6. 发布与旧数据
发布顺序为后端接口和消费校验先上线,再发布 Flutter。客户端升级后所有 Fami 房间封面都使用新接口。
历史上响应头为 `image/gif`、实际字节为单帧 PNG 的伪 GIF 已经没有动画帧,客户端无法恢复。后端修复上线后必须让用户重新上传原始 GIF/WebP并按现有 CDN 管理流程刷新旧 URL 缓存;不要只改文件后缀或 Content-Type。