hyapp-server/docs/flutter对接/Fami_动态头像上传Flutter接口对接.md

6.5 KiB
Raw Blame History

Fami 动态头像上传 Flutter 接口对接

接入结论

Fami 修改非空头像必须先调用 POST /api/v1/users/me/avatar/upload,再把返回的 avatar_upload_id 提交到 POST /api/v1/users/me/profile/update。上传响应里的 url 只用于本地预览,不能继续作为 avatar 提交。

服务端从原始字节识别静态或动态图片。动态 GIF/WebP 会调用 Wallet CheckVipBenefit(animated_avatar)Fami 当前配置为 VIP4 解锁,但 Flutter 不得写 vipLevel >= 4 作为最终准入。静态头像也走同一上传接口,只是不触发动态权益校验。

1. 上传头像

POST /api/v1/users/me/avatar/upload
Authorization: Bearer {access_token}
Content-Type: multipart/form-data

command_id={本次选择文件的上传幂等键}
file={原始文件字节}
项目 规则
格式 JPEG、PNG、GIF、WebP不支持 APNG
一致性 魔数、文件扩展名、multipart Content-Type 必须一致
大小 最大 5 MiB读取字节数必须与 multipart 文件大小一致
尺寸 宽、高均不超过 4096总像素不超过 16,777,216
动画 仅多帧 GIF/WebP最多 120 帧,总时长 (0, 15000]ms
静态 frame_count=1animated=falseduration_ms=0

同一文件因超时重试时复用原 command_id;重新选择文件时生成新值。不要发送 application/octet-stream

成功响应:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "avatar_upload_id": "avatar_12ab34cd56ef789012ab34cd56ef7890",
    "url": "https://cdn.example.com/user-media/fami/avatars/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
  }
}

2. 保存头像

POST /api/v1/users/me/profile/update
Content-Type: application/json

{
  "avatar_upload_id": "avatar_12ab34cd56ef789012ab34cd56ef7890"
}

服务端在同一数据库事务内校验凭证属于当前 app_code + user_id、状态为 active,然后更新 users.avatar 并把凭证推进为 consumedavatar_upload_idavatar 互斥。清空头像仍提交:

{"avatar": ""}

3. 当前 Flutter 代码需要调整

当前 AppUserService._avatarUploadPath 仍指向 /api/v1/files/avatar/uploaduploadProfileAvatar 只返回 URLupdateMyProfile 也只接受 avatar。Fami 接入时应改为:

  1. 新增 /api/v1/users/me/avatar/upload 路径和 AppAvatarUploadResult,至少保存 avatarUploadIdurlanimatedframeCountsha256
  2. multipart 增加 command_id;同一次网络重试复用,用户重新选图后重新生成。
  3. updateMyProfile 增加 avatarUploadId,保存非空头像时只发送 avatar_upload_id
  4. pickVipGatedProfileImage 改用 preserveGifOrWebp: true,否则动态 WebP 会进入裁剪并被压成 JPEG。
  5. 客户端大小上限同步为 5 MiB现有 maxAvatarGifBytes/10 MiB 提示不能继续用于本接口。

静态图片可以继续裁剪为 JPEG但裁剪后必须使用 .jpg/.jpeg 文件名和 image/jpeg。GIF/WebP 动画必须保留选择器返回的原始字节,不裁剪、不压缩、不改扩展名。

4. GetConnect 风格示例

class AppAvatarUploadResult {
  const AppAvatarUploadResult({
    required this.avatarUploadId,
    required this.url,
    required this.animated,
    required this.frameCount,
    required this.sha256,
  });

  final String avatarUploadId;
  final String url;
  final bool animated;
  final int frameCount;
  final String sha256;

  factory AppAvatarUploadResult.fromJson(Map<String, dynamic> json) {
    return AppAvatarUploadResult(
      avatarUploadId: json['avatar_upload_id'] as String,
      url: json['url'] as String,
      animated: json['animated'] as bool,
      frameCount: json['frame_count'] as int,
      sha256: json['sha256'] as String,
    );
  }
}

Future<AppAvatarUploadResult> uploadProfileAvatar({
  required AppPickedImageFile image,
  required String commandId,
}) async {
  final String fileName = _safeFileName(image.fileName);
  final String contentType = _contentTypeForFileName(fileName);
  final FormData formData = FormData(<String, dynamic>{
    'command_id': commandId,
    'file': MultipartFile(
      image.bytes,
      filename: fileName,
      contentType: contentType,
    ),
  });
  final Map<String, dynamic> data = await _networkService
      .postEnvelope<Map<String, dynamic>>(
        '/api/v1/users/me/avatar/upload',
        body: formData,
        decoder: _asMap,
      );
  return AppAvatarUploadResult.fromJson(data);
}

Future<AppUserProfile> saveProfileAvatar(
  AppAvatarUploadResult uploaded,
) async {
  final Map<String, dynamic> data = await _networkService
      .postEnvelope<Map<String, dynamic>>(
        '/api/v1/users/me/profile/update',
        body: <String, String>{
          'avatar_upload_id': uploaded.avatarUploadId,
        },
        decoder: _asMap,
      );
  return AppUserProfile.fromJson(data);
}

5. 错误处理

HTTP / code 含义 Flutter 动作
403 / VIP_BENEFIT_REQUIRED 动态头像缺少 animated_avatar 使用错误 metadata 的 required_level 展示升级入口;不要写死 VIP4
413 / FILE_TOO_LARGE 超过 5 MiB 保留选择页并提示重新选择
415 / UNSUPPORTED_MEDIA_TYPE 魔数、扩展名、MIME 或文件结构不一致 不重试;修正 MIME 或重新选择
409 / IDEMPOTENCY_CONFLICT 同一 command_id 换了文件 为新文件生成新 command_id
409 / CONFLICT 凭证未完成、已被其他资料更新消费 丢弃凭证并重新上传
400 / INVALID_ARGUMENT 保存时缺凭证,或同时提交 avataravatar_upload_id 修正请求体
503 / UPSTREAM_ERROR Wallet/COS/user-service 暂不可用 保留同一文件和 command_id 后重试

服务端错误是最终权限事实。Flutter 本地 VIP 数据只用于提前展示锁态,不能跳过上传请求,也不能把动态图片 URL 直接写入用户资料。

6. 兼容边界

公开注册头像入口继续只接受静态 JPEG/PNG/WebP因为注册阶段还没有可校验的用户 VIP。注册完成后用户要设置动态头像必须重新走新接口。

历史上已经被转成单帧 PNG 的伪 GIF 不含动画帧,无法恢复;修复上线后需要用户重新上传原始 GIF/WebP并按现有 CDN 流程刷新旧 URL 缓存。