206 lines
7.2 KiB
Markdown
206 lines
7.2 KiB
Markdown
# 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。
|