170 lines
6.5 KiB
Markdown
170 lines
6.5 KiB
Markdown
# 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 缓存。
|