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

7.2 KiB
Raw Blame History

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. 上传接口

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=1animated=falseduration_ms=0

同一次上传因超时重试时复用原 command_id 和同一文件;用户重新选择文件时生成新的 command_id。不要统一发送 application/octet-stream

成功响应:

{
  "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. 创建房间

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. 修改房间封面

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 示例

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/.jpegmultipart MIME 应为 image/jpeg;不能保留原 .png/.gif 名称。

创建/更新参数示例:

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_coderequired_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。