Skip to content

feat(avatar): implement PUT /user/avatar with COS storage and content review - #34

Merged
s3loy merged 18 commits into
mainfrom
feature/avatar-upload-cos
Aug 3, 2026
Merged

feat(avatar): implement PUT /user/avatar with COS storage and content review#34
s3loy merged 18 commits into
mainfrom
feature/avatar-upload-cos

Conversation

@Ptilopsi4

Copy link
Copy Markdown
Member

概述

实现 PRD §4.9 头像上传(最后一个未注册的契约路径):PUT /user/avatar 上传头像至腾讯云 COS,经内容审核后写入 profile.avatar。至此 docs/openapi.yaml 所有路径均已注册。

做了什么

存储与审核(端口 + 适配器)

  • internal/objectstoreObjectStore(Upload/Delete)与 AvatarAuditor(fail-closed 审核契约)两个端口,session 服务只依赖接口,可无 bucket 测试
  • internal/adapter/cos:COS 适配器,上传带 public-read ACL(头像需公开展示),审核走同步 CI sensitive-content-recognition(porn/terrorist/politics/ads);HTTP client 带 30s 超时

上传用例(session service)

  • 魔数检测(mimetype)→ 解码校验(DecodeConfig)→ 拒绝伪装的文件名/Content-Type;格式仅 jpg/png/webp
  • 5MB 双层限制:multipart part 声明大小早期拒绝 + LimitReader 实际流校验,撒谎的 header 无法绕过
  • 审核 fail-closed:审核服务不可用 → 50300 并删除对象;敏感内容 → 42203 并删除对象 + 审计;DB 写库失败 → 补偿删除,不残留孤儿对象
  • 写库成功后 best-effort 退役旧头像对象(avatarKeyFromURL 以 base 前缀 + avatar/ 前缀 + .. 拒绝三重约束,不误删外来 URL)
  • 全程审计 upload_avatar(成功/拒绝/标签);按用户限流 RATE_LIMIT_UPLOAD_AVATAR_*

HTTP 层

  • multipart 解析,MaxBytesReader 6MB body 上限(否则超限流会被完整读到临时文件后才拒绝);缺 file 字段 → 40000

配置

  • STORAGE_* 全有或全无:全空 = 未配置(端点返回 50002,其他端点不受影响);半配置 = 启动报错
  • 启动时校验 bucket {name}-{appid} 形状、endpoint 无 scheme、base URL 绝对地址

文档:openapi.yaml(avatar 路径契约)、API 文档(错误码表 + 上传链路)、PRD(实现状态)、README、caddy runbook、.env.example、docker-compose 同步

测试

  • 单元:service 503 行(格式/大小/伪装/损坏/审核拒绝/审核不可用/补偿删除/旧对象退役/外来 URL 不删/限流/已注销用户)+ handler(multipart 缺文件/超限 body/错误码映射)+ COS 适配器(URL 构造与校验)+ config(STORAGE 全有/全无/半配置/形状校验)
  • e2e(Testcontainers 真 PostgreSQL):上传 → 对象存储 → profile.avatar 落库 → 审计行全链路;审核拒绝路径
  • go test -race -shuffle=ongolangci-lintgo vet 全绿

行为契约(错误码)

40000(格式/大小/损坏/空/缺 file)、42203(未过审)、42900(限流)、40102(未认证)、40301(已注销)、50002(存储未配置/上传失败)、50300(审核服务不可用)、50003(数据库错误)

- cos-go-sdk-v5 v0.7.75: Tencent Cloud COS client (Object.Put/Delete, CI.ImageAuditing)
- gabriel-vasile/mimetype: magic-byte detection incl. webp (mimetype was already indirect, now direct)
- golang.org/x/image: webp DecodeConfig registration for the stdlib image package
ObjectStore persists avatar objects and returns their public URL;
AvatarAuditor reviews an uploaded image and is fail-closed by contract
(auditor error means the image was NOT reviewed). Implementations live
in internal/adapter; the session service only sees these interfaces.
Implements the objectstore ports against COS (PRD §1.1): public-read
uploads, best-effort deletes, and the synchronous CI image review
(sensitive-content-recognition, porn/terrorist/politics/ads). Supports
an endpoint override and a CDN base URL for public object URLs.
The STORAGE_* group is optional but all-or-nothing: all empty means
avatar upload is disabled (endpoint answers 50002), a partially filled
set fails at startup. STORAGE_AUDIT_ENABLED defaults to true (fail-closed
content review). The avatar limiter throttles PUT /user/avatar per caller.
UploadAvatar validates the upload (5MB cap enforced on the actual
stream, magic-byte format detection, DecodeConfig integrity check),
stores it under avatar/{userID}/{uuid}.{ext}, runs the content review
(fail-closed: an unreachable reviewer rejects the upload, a sensitive
verdict deletes the object and returns 42200), writes the public URL
into profile.avatar via the existing upsert transaction, retires the
superseded object best-effort and audits upload_avatar.

A failed profile write compensates by deleting the freshly uploaded
object. ProfileUpdate gains the Avatar column.
Multipart parsing lives in the handler, the file stream is passed to
the service. Missing or malformed file parts answer 40000; service
errors map through the shared mapper (new KindObjectUploadFailed ->
50002). Includes handler tests and a PostgreSQL/Redis e2e covering the
persist + audit path and the sensitive-review rejection.
Builds the COS client from STORAGE_* when configured; a deployment
without storage keeps every other endpoint and answers 50002 on
PUT /user/avatar. The auditor is attached only when
STORAGE_AUDIT_ENABLED is on.
- API文档.md §3.3: drop the not-implemented banner, document the upload
  chain, error codes and rate limit
- openapi.yaml: /user/avatar description + 422/429/500 responses
- PRD: §4.9 upload section, appendix C and §11 checklist
- README: implementation inventory and new packages
OpenAPI 3.0.1 has no null type; the response-envelope data field used
type: 'null' in 8 schemas, which the yaml schema validator rejects.
nullable: true (empty schema, any type) expresses the same contract.
markdownlint --fix pass on the docs (kept as content fixes only):
- API文档.md: blockquote blank line before the §4.2 Lark-binding note
- PRD: rename the duplicate '#### 端点' heading under the OIDC section
  to '#### OIDC 端点'
- psql-db-design.md: wrap bare provider-doc URLs in angle brackets,
  fix a duplicate table heading
- caddy-reverse-proxy.md: tabs to spaces in the Caddyfile blocks
- handler: bound the multipart body with http.MaxBytesReader (6MB) before
  parsing. c.FormFile reads the whole stream to a temp file past Gin's
  in-memory cap, so without this an oversized body was fully received
  before the service's 5MB check ran
- service: pass the caller's User-Agent into the upload_avatar audit
  rows (was hardcoded empty); guard avatarKeyFromURL against a new URL
  that does not end with the fresh key; document the concurrent-upload
  cleanup boundary
- config: reject STORAGE_ENDPOINT values carrying a scheme (the adapter
  prefixes https://, so such a value produced an unrecoverable URL)
- test: assert PUT /user/avatar is registered alongside the other
  session routes
PUT /user/avatar is registered now; the header claimed it still 404s.
The review verdict was folded into generic codes that cannot tell a
policy rejection from a malformed request or a storage fault:

- 42203 头像未通过内容审核 (HTTP 422): the audit rejection now has its
  own code; the client can branch on it instead of parsing the message.
  The upload_avatar audit row records 42203 as the error code.
- The unreachable review service now answers 50300 (dependency
  unavailable, HTTP 503) like the other fail-closed dependencies
  (verification codes, tickets): the image was not reviewed, so the
  upload is rejected and the client is told to retry. 50002 falls back
  to its original meaning (object storage unconfigured / upload failed).

Docs updated: API文档.md error table + §3.3, openapi.yaml 422/503
responses.
docker-compose.yml's api environment is a whitelist (an omitted variable
cannot be overridden from .env), but STORAGE_* and
RATE_LIMIT_UPLOAD_AVATAR_* were missing — a compose deployment would
silently run with no object storage and answer 50002 on avatar upload.

Verified: docker compose config is valid.
- README: review rejection now 42203, review outage 50300 (fail-closed)
- PRD: §4.9 and §11 error codes; feature table no longer marks content
  review as planned (later)
- CLAUDE.md: PUT /user/avatar is registered; openapi header no longer
  lists it as the single unregistered path
The adapter used a bare http.Client with no Timeout, so a hung COS side
could pin a request goroutine forever when the caller never cancels the
context. Wider than provider.httpIOTimeout (10s for small token
exchanges): an avatar upload carries a 5MB PUT plus the synchronous CI
review, both over the public network.
Filename is client-supplied, so it only reaches a log line on the
rejection path — never storage or a URL. Also documents why readAvatar
uses DecodeConfig instead of a full Decode (pixel expansion of a 5MB
image makes a full decode a memory bomb; corrupt payloads are caught by
the COS review or a broken render).
@s3loy
s3loy merged commit 2476ea5 into main Aug 3, 2026
8 checks passed
@s3loy
s3loy deleted the feature/avatar-upload-cos branch August 3, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants