fix(clothes): 保护失败原因并可靠回退额度

仅属主可查看失败原因,分享只读场景置空;额度回退改用独立
ctx,避免客户端断开导致回退失败;新增 ImageProcessStatus 与
FailReason 响应字段,并抽取 detachedCtx 复用后台任务上下文。
This commit is contained in:
2026-09-16 02:01:19 +08:00
parent b2e5eef3c8
commit e9b9754b57
5 changed files with 299 additions and 22 deletions
+21 -17
View File
@@ -29,23 +29,23 @@ type ClothesDeleteByUUIDResponse struct{}
// ClothesUpdateByUUIDRequest 修改衣物
type ClothesUpdateByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"`
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
Style string `json:"style" binding:"max=64"` // 风格
ColorMain string `json:"color" binding:"max=64"` // 主颜色
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
Thickness string `json:"thickness" binding:"max=64"` // 厚度
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
Pattern string `json:"pattern" binding:"max=64"` // 花纹
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
Name string `json:"name" binding:"max=64"` // 名称
Remark string `json:"remark" binding:"max=500"` // 备注
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
Style string `json:"style" binding:"max=64"` // 风格
ColorMain string `json:"color" binding:"max=64"` // 主颜色
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
Thickness string `json:"thickness" binding:"max=64"` // 厚度
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
Pattern string `json:"pattern" binding:"max=64"` // 花纹
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
Name string `json:"name" binding:"max=64"` // 名称
Remark string `json:"remark" binding:"max=500"` // 备注
}
type ClothesUpdateByUUIDResponse struct{}
@@ -76,6 +76,10 @@ type ClothesGetByUUIDResponse struct {
Name string `json:"name"`
Remark string `json:"remark"`
ProcessedImage string `json:"processed_image"`
// ImageProcessStatus 处理状态:1=抠图中 2=分类中 3=排队中 4=成功 5=失败
ImageProcessStatus int8 `json:"image_process_status"`
// FailReason 处理失败原因:仅属主可见,非属主(分享只读)场景为空
FailReason string `json:"fail_reason"`
}
// ClothesListRequest 衣物列表
+6 -1
View File
@@ -172,7 +172,12 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
}
clothesResp := make([]dto.ClothesGetByUUIDResponse, 0, len(clothesList))
for i := range clothesList {
clothesResp = append(clothesResp, buildClothesResponse(&clothesList[i]))
item := buildClothesResponse(&clothesList[i])
// 非属主只读查看:不暴露内部失败原因
if !isOwner {
item.FailReason = ""
}
clothesResp = append(clothesResp, item)
}
// 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。
+28 -4
View File
@@ -103,7 +103,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
if serr != nil {
slog.ErrorContext(ctx, "clothes upload: save original image failed", "user_id", userID, "error", serr)
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
refundUploadQuotaOnFail(ctx, userID, "原图持久化失败回退")
return nil, errcode.UploadFileSaveFailed
}
clothes := &mysqlmodel.Clothes{
@@ -117,8 +117,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
slog.ErrorContext(ctx, "clothes upload: create clothes record failed",
"user_id", userID, "clothes_uuid", clothesUUID, "error", err)
_ = os.Remove(originalPath)
// 建记录失败,回退已扣额度。
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
// 建记录失败,回退已扣额度(独立 ctx:客户端中途断开也要保证退回)。
refundUploadQuotaOnFail(ctx, userID, "上传失败回退")
return nil, errcode.ClothesCreateFailed
}
@@ -133,6 +133,23 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
}, nil
}
// detachedCtx 基于 Background 派生独立 ctx:继承 trace 链路但切断请求取消信号,
// 供「与客户端连接解耦」的后台任务与「必须完成」的收尾操作使用。
func detachedCtx(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(
trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parent)),
timeout,
)
}
// refundUploadQuotaOnFail 用不受请求取消影响的 ctx 回退上传额度:
// 客户端中途断开导致请求 ctx 已取消时,仍要保证额度退回成功。
func refundUploadQuotaOnFail(parent context.Context, userID int64, reason string) {
ctx, cancel := detachedCtx(parent, 30*time.Second)
defer cancel()
refundCosQuota(ctx, userID, 1, 0, reason)
}
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte, originalPath string) {
// 限流:获取信号量,超出并发上限时阻塞等待。
@@ -140,7 +157,7 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
defer func() { <-imageProcessSem }()
// 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。
ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
ctx, cancel := detachedCtx(parentCtx, 3*time.Minute)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync")
@@ -392,6 +409,10 @@ func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *d
}
}
response := buildClothesResponse(clothes)
// 非属主只读查看(穿搭分享):不暴露内部失败原因
if clothes.UserID != userID {
response.FailReason = ""
}
return &response, nil
}
@@ -610,6 +631,9 @@ func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse {
Name: c.Name,
Remark: c.Remark,
ProcessedImage: processed,
ImageProcessStatus: c.ImageProcessStatus,
FailReason: c.FailReason,
}
}
+240
View File
@@ -0,0 +1,240 @@
// =============================================================================
// Grafana Alloy 采集配置 —— 只采集 zt-closet-backend 的本地日志文件
//
// ⚠️ 本文件有两种「花括号」,语法规则相反,混用会直接启动失败:
// 1) 块(block) endpoint { } / stage.json { } / logging { }
// → 参数之间用【换行】分隔,【不能加逗号】
// 加了会报:Argument definitions must be separated by newlines, not commas
// 2) 对象字面量(= 号右边的表达式)
// values = { } / expressions = { } / external_labels = { } / path_targets 里的 { }
// → 字段之间【必须用逗号】分隔,否则报:missing ',' in field list
// 判断方法:`名字 {` 后面直接换行 = 块;`名字 = {` 是对象字面量。
// 改动本文件时务必按此规则,否则 Alloy 起不来。
//
// 部署方式:Alloy 以 systemd 服务运行(不是 Docker)
// 配置路径 /etc/alloy/config.alloy (把本文件拷到这里)
// 存储路径 --storage.path=/var/lib/alloy/data(见 systemctl cat alloy)
//
// 采集对象:lumberjack 滚动写出的 JSON 日志(见 main.go setupSlogLogger)
// 宿主路径 /opt/zt-closet/storage/logs/app.log
// 容器/服务内读取路径由你挂载或直接指向该目录
// 归档 app-*.log.gz(Compress: true,默认不采集,见 loki.source.file 注释)
// 实测格式 {"time":"2026-09-09T01:52:52.772742663+08:00","level":"INFO",
// "source":{"function":"...","file":"...","line":44},
// "msg":"GIN_REQUEST","request_id":"...","method":"GET","path":"/pods",
// "query":"","status":404,"ip":"...","latency":"4.559µs","user_agent":""}
// =============================================================================
// 若 Alloy 与应用不在同一文件系统视图下(比如 Alloy 装在别的主机/容器),
// 请把下面 local.file_match 里的 __path__ 改成实际可见的路径。
// -----------------------------------------------------------------------------
// logging —— Alloy 自身的运行日志(不是被采集的业务日志)
// -----------------------------------------------------------------------------
logging {
level = "info" // Alloy 自身日志级别:debug / info / warn / error,排查采集问题时改 debug
format = "logfmt" // Alloy 自身日志格式,logfmt 便于人读
}
// -----------------------------------------------------------------------------
// local.file_match —— 「发现」要采集哪些文件,并给它们打上静态标签
// 只负责找文件、产出 target 列表,不读内容。
// -----------------------------------------------------------------------------
local.file_match "app_logs" {
// ↓ 这是一个「对象字面量」,字段之间必须用逗号分隔
path_targets = [{
// ⚠️ 必须限定为 app*.log,不能用 *.log:
// 同目录下还有 classification.log(Python 服务输出,是
// "INFO:__main__:..." 纯文本 + tqdm 进度条,不是 JSON)。
// 用 *.log 会把它一起采走,解析不出任何字段,还会混入 \r 进度条垃圾。
__path__ = "/opt/zt-closet/storage/logs/app*.log",
// 以下三个是自定义标签,会附加到每一条日志上(低基数,可以安全做标签)。
job = "zt-closet-backend", // 采集任务名,多服务时用于区分
service_name = "zt-closet-backend", // 服务名,推荐保留(Loki 生态的通用标签名)
env = "prod", // 环境标识,prod / test / dev
}]
// 每隔多久重新扫描一次目录,发现新建/轮转出来的新文件。
// 该值就是官方默认值,保留只为显式可见。
sync_period = "10s"
}
// -----------------------------------------------------------------------------
// loki.source.file —— 真正「读」文件内容,按行产出日志
// -----------------------------------------------------------------------------
loki.source.file "app_logs" {
// 上游 local.file_match 发现的文件列表。
targets = local.file_match.app_logs.targets
// 下游处理管道;解析 / 打标签 / 降噪都在 loki.process 里做。
forward_to = [loki.process.app_logs.receiver]
// 从文件末尾开始读:首次部署时不重放历史日志,
// 避免把几十 MB 旧日志一次性灌进 Loki。false 则会从头回放。
tail_from_end = true
// 读取位置(offset)记录在 --storage.path 指定的目录,服务重启后接着读、不重复。
// 该目录必须持久化(systemd 下通常是 /var/lib/alloy/data,属主为 alloy 用户),
// 否则 offset 丢失,配合 tail_from_end=true 会直接跳过一段日志。
// ↓↓↓ 可选:需要采集 lumberjack 压缩归档的 app-*.log.gz 时启用
// 注意:官方明确说明 tail_from_end 与 decompression 不可同时使用,
// 启用下面这段时需要把上面的 tail_from_end 删掉。
// decompression {
// enabled = true // 注意:块参数只用换行分隔,不要加逗号
// initial_delay = "10s" // 文件写入后延迟多久开始解压读取,避免读到半截文件
// format = "gz" // 合法值只有 gz / z / bz2
// }
}
// -----------------------------------------------------------------------------
// loki.process —— 日志处理管道:把 JSON 拆成字段,决定哪些做标签、哪些做元数据
// 这是整个配置里唯一需要按业务调整的地方。
// -----------------------------------------------------------------------------
loki.process "app_logs" {
// ---- 第一步:解析 JSON:把一行 JSON 的每个 key 提取成可用字段 ----
// 左侧是管道内的字段名,右侧是 JMESPath 表达式(点号表示取嵌套字段)。
stage.json {
// ↓ 对象字面量:字段之间必须用逗号
expressions = {
time = "time", // 日志自身时间戳(RFC3339Nano),供 stage.timestamp 使用
level = "level", // 日志级别:DEBUG/INFO/WARN/ERROR
msg = "msg", // 日志消息,如 GIN_REQUEST
trace_id = "trace_id", // 链路 ID,仅 slog.XxxContext(ctx,...) 且 ctx 内有 span 时存在
span_id = "span_id", // span ID,同上
request_id = "request_id", // 单请求 ID,见 middleware/logger.go
method = "method", // HTTP 方法
path = "path", // ⚠️ 高基数字段:打的是 c.Request.URL.Path 原始路径,
// 不是 gin 路由模板,含 UUID 等动态段,
// 所以只能做元数据,绝不能提升为标签
status = "status", // HTTP 状态码
ip = "ip", // 客户端 IP
latency = "latency", // 处理耗时,字符串如 "4.559µs"(非数字,不能直接聚合)
source_file = "source.file", // 因为 AddSource:true,日志自带 文件:行号
source_line = "source.line", // ↑ 同上,定位代码位置用
}
}
// ---- 第二步:用日志自身的时间戳,而不是 Alloy 读到它的时间 ----
// 不加这个 stage,Loki 会用「采集时刻」当作日志时间:一旦 Alloy 重启、
// 限流重试或追赶积压,历史日志会被写成"现在",时间范围查询与
// count_over_time 告警全部错位,且没有任何报错提示。
// format 用 RFC3339Nano 精确匹配实测的 "2026-09-09T01:52:52.772742663+08:00"。
// 注意:若配合 decompression 回放超过 14 天的归档,Loki 会以
// "entry too far behind" 拒收整批(reject_old_samples_max_age 默认 14d)。
// ⚠️ 注意这是「块」不是对象字面量,参数之间【只能用换行分隔,禁止加逗号】
stage.timestamp {
source = "time"
format = "RFC3339Nano"
}
// ---- 第三步:把 level / status 提升为「索引标签」----
// 标签参与索引、筛选最快,但每个不同的标签组合都会形成一个独立 stream。
// 这里 level(4 种) × status(约 8 种) ≈ 32 个 stream,量级很小可以接受;
// 注意 path 是高基数字段,绝不能加进来。
stage.labels {
values = {
level = "", // 空字符串表示「沿用同名字段」,等价于 level = "level"
status = "",
}
}
// ---- 第四步:其余字段降级为「结构化元数据」(structured metadata) ----
// 元数据不建索引、不加剧 stream 基数,但 LogQL 里照样能用
// | trace_id="xxx" 过滤,也能点击跳转 Tempo 查链路。
//
// ⚠️ 启用前提(缺一不可,否则 distributor 直接返回 400 拒收整批,外部
// 表现为「Alloy 不停重试、Loki 里一条日志都没有」,很难定位):
// 1) Loki ≥ 3.0(2.9 起为实验特性)
// 2) schema 版本 ≥ v13,且索引类型必须为 tsdb(不支持已废弃的 boltdb-shipper)
// 3) limits_config.allow_structured_metadata: true
// 确认不了就先整段注释掉:其余配置照常工作,只是查询时少了这些字段。
// 另外元数据上限为每行 64KB / 128 条,这里 9 个字段远未触及。
stage.structured_metadata {
values = {
trace_id = "",
span_id = "",
request_id = "", // 按 request_id 串起一次请求的全部日志
method = "",
path = "", // 高基数字段放这里最合适:可过滤,但不产生新 stream
ip = "",
latency = "",
source_file = "", // 出错时直接看是哪个文件哪一行打的
source_line = "",
// 注意:status 已在上一 stage 提升为标签,此处不再重复设置,
// 避免同一字段既是标签又是元数据导致语义混淆
}
}
// ---- 第五步(当前已启用):丢弃 Docker HEALTHCHECK 产生的探针噪音 ----
// 若服务以 systemd 直接运行而非 Docker,没有 HEALTHCHECK,这段可删除。
// 需要保留这层可用性信号时,也把本段整段删除即可。
//
// 说明:stage.drop 的 source 只能引用「extracted map」中的字段
// (官方文档明确限定为 extracted data,且该 stage 没有 source_type 参数,
// 读不到标签、也读不到 structured metadata)。path 正是第一步
// stage.json 提取出来的,因此这里可用。多个条件之间是 AND 关系。
// 丢弃计数见 Alloy UI:loki_process_dropped_lines_total{reason="healthcheck"}。
// ⚠️ 同样这是「块」,参数之间只用换行,禁止加逗号
stage.drop {
source = "path" // 引用 stage.json 提取出的字段
value = "/ping" // 与 source 值完全相等时丢弃
drop_counter_reason = "healthcheck"
}
// 处理结果推给出口。
forward_to = [loki.write.default.receiver]
}
// -----------------------------------------------------------------------------
// loki.write —— 出口:把日志批量推送到 Loki 的 push 接口
//
// 层级注意:顶层只有 external_labels / max_streams;
// url、batch_*、*_backoff_*、remote_timeout、tenant_id、basic_auth 等
// 都必须写在 endpoint 块内。写到顶层 Alloy 启动即报 unrecognized attribute。
// -----------------------------------------------------------------------------
loki.write "default" {
endpoint {
// Loki 写入地址。
url = "http://119.45.6.212:3100/loki/api/v1/push"
// ---- 批量:攒批降低请求数 ----
batch_wait = "60s" // 最多攒 60s 才发:请求数最省,但日志最多延迟 1 分钟才可见;
// 真实延迟 = min(batch_wait, 攒满 batch_size)。
// 需要近实时排障就调到 1s~5s。
batch_size = "1MiB" // 攒够 1MiB 立即发,与 batch_wait 谁先满足用谁
// 请求超时,默认 10s;跨公网写入或 Loki 较慢时可放大。
remote_timeout = "10s"
// ---- 重试:Loki 短暂不可用时退避重试,避免丢日志 ----
// 必须保证 min < max:退避实现中一旦 nextDelayMin >= nextDelayMax
// 就直接返回 MinBackoff 且永不增长(max 被静默忽略)。
// ⚠️ endpoint 是「块」,参数只用换行分隔,禁止加逗号
min_backoff_period = "500ms" // 退避起点,官方默认 500ms
max_backoff_period = "5m" // 退避上限,官方默认 5m
max_backoff_retries = 10 // 重试 10 次后仍失败则丢弃该批
// ⚠️ 单位陷阱:Go duration 中 m=分钟、ms=毫秒,
// "500m" 是 500 分钟(8h20m),"500ms" 才是 500 毫秒。
//
// 提示:退避不是固定倍数,而是在 [min, max) 区间取随机抖动值、区间再翻倍。
// 以 min=500ms / retries=10 为例,实际发生的 9 次等待区间依次为
// [0.5s,1s) → [1s,2s) → … → [128s,256s),最大也只到 256s,
// 即使把 max 设成 5m 或 500m 也不会触及上限。真正决定「最多重试多久」
// 的是 max_backoff_retries。
// Loki 开启多租户鉴权时才需要:
// tenant_id = "zt-closet"
// basic_auth {
// username = "zt-closet"
// password = sys.env("LOKI_PASSWORD") // 密钥只从环境变量读,不要写进本文件
// }
}
// 顶层参数:附加到所有日志的全局标签,便于区分集群/机房。
external_labels = {
cluster = "prod",
}
}
+4
View File
@@ -401,6 +401,10 @@ Authorization: Bearer <access_token>
| name | string | 名称 |
| remark | string | 备注 |
| processed_image | string | 抠图后的图片完整 URL(可直接展示) |
| image_process_status | int | 处理状态:`1`=抠图中 `2`=分类中 `3`=排队中 `4`=成功 `5`=失败 |
| fail_reason | string | 处理失败原因;**仅属主可见**,非属主(穿搭分享 / 衣柜分享只读)场景为空串 |
> 衣物**列表与详情**响应均带 `image_process_status`:前端应据此区分「处理中(1/2/3)」与「处理失败(5)」,不要用属性/图片是否为空来推断。`fail_reason` 仅在属主场景返回。
### 4.3 穿搭对象 `OutfitItem`