From e9b9754b5735659f509f16855f0f8003facd4289 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Wed, 16 Sep 2026 02:01:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(clothes):=20=E4=BF=9D=E6=8A=A4=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8E=9F=E5=9B=A0=E5=B9=B6=E5=8F=AF=E9=9D=A0=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅属主可查看失败原因,分享只读场景置空;额度回退改用独立 ctx,避免客户端断开导致回退失败;新增 ImageProcessStatus 与 FailReason 响应字段,并抽取 detachedCtx 复用后台任务上下文。 --- internal/dto/clothes.go | 38 +++--- internal/service/closet.go | 7 +- internal/service/clothes.go | 32 ++++- scripts/alloy/config.alloy | 240 ++++++++++++++++++++++++++++++++++++ 接口文档.md | 4 + 5 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 scripts/alloy/config.alloy diff --git a/internal/dto/clothes.go b/internal/dto/clothes.go index 0f7fa9a..37301bf 100644 --- a/internal/dto/clothes.go +++ b/internal/dto/clothes.go @@ -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 衣物列表 diff --git a/internal/service/closet.go b/internal/service/closet.go index 03ed545..8e28c20 100644 --- a/internal/service/closet.go +++ b/internal/service/closet.go @@ -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) } // 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。 diff --git a/internal/service/clothes.go b/internal/service/clothes.go index 70b830f..4624e47 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -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, } } diff --git a/scripts/alloy/config.alloy b/scripts/alloy/config.alloy new file mode 100644 index 0000000..8d5d611 --- /dev/null +++ b/scripts/alloy/config.alloy @@ -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", + } +} diff --git a/接口文档.md b/接口文档.md index 11a5e54..3ce1835 100644 --- a/接口文档.md +++ b/接口文档.md @@ -401,6 +401,10 @@ Authorization: Bearer | 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`