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

170 lines
6.5 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/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. 上传头像
```http
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=1``animated=false``duration_ms=0` |
同一文件因超时重试时复用原 `command_id`;重新选择文件时生成新值。不要发送 `application/octet-stream`
成功响应:
```json
{
"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. 保存头像
```http
POST /api/v1/users/me/profile/update
Content-Type: application/json
{
"avatar_upload_id": "avatar_12ab34cd56ef789012ab34cd56ef7890"
}
```
服务端在同一数据库事务内校验凭证属于当前 `app_code + user_id`、状态为 `active`,然后更新 `users.avatar` 并把凭证推进为 `consumed``avatar_upload_id``avatar` 互斥。清空头像仍提交:
```json
{"avatar": ""}
```
## 3. 当前 Flutter 代码需要调整
当前 `AppUserService._avatarUploadPath` 仍指向 `/api/v1/files/avatar/upload``uploadProfileAvatar` 只返回 URL`updateMyProfile` 也只接受 `avatar`。Fami 接入时应改为:
1. 新增 `/api/v1/users/me/avatar/upload` 路径和 `AppAvatarUploadResult`,至少保存 `avatarUploadId``url``animated``frameCount``sha256`
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 风格示例
```dart
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` | 保存时缺凭证,或同时提交 `avatar``avatar_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 缓存。