diff --git a/.codebuddy/skills/brainstorming/SKILL.md b/.codebuddy/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..aaf9274 --- /dev/null +++ b/.codebuddy/skills/brainstorming/SKILL.md @@ -0,0 +1,212 @@ +--- +name: brainstorming +description: "在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。" +version: "1.0.0" +license: MIT +metadata: + hermes: + tags: [design, planning] +--- + +# 头脑风暴:将想法转化为设计 + +通过自然的协作对话,帮助将想法转化为完整的设计和规格说明。 + +先判断这个需求需要多少流程,然后沿着对应的路径推进:理解上下文、完善想法、展示设计、获得你的人类伙伴批准。 + + +在你告诉你的人类伙伴你打算做什么、并得到他们批准之前,不要调用任何实现技能、编写任何代码、搭建任何项目或采取任何实现行动。这适用于下面**每一条路径上的每一个任务**——仪式感随任务大小缩放,批准这道关卡永远不缩放。 + + +## 三条路径 + +在提出第一个问题之前,先给需求分类,并把分类**说出来**——"这个看起来是有界的,所以我会在这里直接给一份简短设计,而不是写规格文档"——好让你的人类伙伴能纠正你: + +- **探路(Spike)** — 一个可行性问题("我们能不能……"、"有没有可能……"、"糙一点没关系"),它的产出是一个**答案**,不是要留下的代码。用 2-3 句话说明问题和你打算怎么试,得到一个点头,然后用不牺牲正确性的最低成本去弄清楚。不写设计文档,不写规格文件。以建议的形式汇报发现;过程中搭的任何东西都明确标注为一次性的。 +- **有界(Bounded)** — 对**本仓库里已经存在的代码**做范围明确的改动:加一个开关、一个小接口、改一个文件的 bug。"知道这是个什么类型的应用"不算数——有界意味着**你要改的那条流程此刻就在仓库里、可以读**。如果没有现成的流程可改,这个任务就不是有界的。问那些真正重要的澄清问题,**在对话里**给出一份简短设计(几句话到几个短段落),然后**停下**。只有在你的人类伙伴对这份设计说"可以"之后,实现才开始——有界任务的批准和架构级任务的批准是同样硬的关卡。不写规格文件,不写实现计划文档。 +- **架构级(Architectural)** — 新项目、新子系统,以及会重构组件之间关系、或改动他人依赖的接口的改动。走完整流程:提问、方案对比、分节设计、书面规格,然后交给 writing-plans 技能。 + +在两条路径之间拿不准时,选更重的那条。这个棘轮只朝一个方向转:任务进行中发现隐藏的复杂度,就**升级**路径——停下来、说明情况、升上去。任何情况下都不在任务中途降级。 + +## 反模式:"这个太简单了,不需要批准" + +每条路径的终点都是你的人类伙伴在实现之前批准你的意图。一个待办事项列表、一个单函数工具、一个配置变更——设计可以只是对话里的两句话,但你**必须**把它展示出来并获得批准。"简单"的任务恰恰是未经检验的假设造成最多浪费的地方。随简单程度缩放的是**产出物**,永远不是批准。 + +## 危险信号 + +| 心里的想法 | 实际情况 | +|---------|---------| +| "这个太简单了,不需要设计" | 简单意味着简短的设计,不是没有设计。对话里两句话,然后获得批准。 | +| "我就说它是有界的,跳过规格文档" | 为了少干活而去够一个标签,这本身就是"拿不准"——选更重的那条路径。 | +| "它是有界的,设计也很显然——我一边让他们读一边开工" | 关卡是**批准**,不是设计的长度。展示完就停,直到听见"可以"。 | +| "这类应用我很熟,所以它是有界的" | 有界衡量的是**仓库**,不是你的熟悉程度。新项目没有现成的流程可改——那是架构级。 | +| "探路跑通了,那这些代码就留着吧" | 探路的产出是一个答案。要留下代码是一个**新的需求**——给它重新分类。 | +| "范围是变大了,但我快做完了,不用重新分类" | 隐藏的复杂度会在任务中途升级路径。停下来,说明情况。 | +| "他们批准了探路,那后续改动也算批准了" | 每个任务有自己的分类,也有自己的批准。 | + +## 检查清单 + +先分类,宣布路径,然后为你所在路径上的每个条目创建任务,并按顺序完成。 + +**探路(Spike):** +1. **探索项目上下文** — 够用来框定这次试探即可 +2. **展示问题 + 试探计划** — 2-3 句话 +3. **获得批准** — 一个点头就够 +4. **动手调查** — 用不牺牲正确性的最低成本 +5. **汇报发现** — 以建议的形式;搭出来的任何东西都标注为一次性的 + +**有界(Bounded):** +1. **探索项目上下文** — 检查文件、文档、最近的 commit +2. **提出澄清问题** — 每次一个,只问那些真正重要的 +3. **在对话里展示简短设计** — 思路、会动哪些文件、怎么测 +4. **获得批准** — **停下**并等待一个明确的"可以";展示完设计顺口就开工,等于跳过了关卡 +5. **实现** — 走正常的开发工作流(TDD 同样适用);不写计划文档 + +**架构级(Architectural):** +1. **探索项目上下文** — 检查文件、文档、最近的 commit +2. **在需要时才提供视觉伴侣** — **不要一上来就提**。第一次遇到"这个问题画出来比说出来更清楚"时,才在那一刻提供(作为独立的一条消息);对方同意后,浏览器标签页会为你打开。如果自始至终没出现视觉问题,就永远不要提。参见下方"视觉伴侣"部分。 +3. **提出澄清问题** — 每次一个,了解目的/约束/成功标准 +4. **提出 2-3 种方案** — 附带权衡分析和你的推荐 +5. **展示设计** — 按复杂度分节展示,每节展示后获得用户批准 +6. **编写设计文档** — 保存到 `docs/superpowers/specs/YYYY-MM-DD--design.md` 并 commit +7. **规格自检** — 快速内联检查占位符、矛盾、模糊性、范围(详见下方) +8. **用户审查书面规格** — 在继续之前请用户审查规格文件 +9. **过渡到实现** — 调用 writing-plans 技能创建实现计划 + +## 流程图 + +```dot +digraph brainstorming { + "分类:探路 / 有界 / 架构级" [shape=diamond]; + "展示问题 + 试探计划(2-3 句)" [shape=box]; + "提出澄清问题(有界)" [shape=box]; + "在对话里展示简短设计" [shape=box]; + "人类伙伴批准?" [shape=diamond]; + "动手调查;汇报建议" [shape=doublecircle]; + "走正常工作流实现(无计划文档)" [shape=doublecircle]; + "探索项目上下文" [shape=box]; + "提出澄清问题" [shape=box]; + "提出 2-3 种方案" [shape=box]; + "分节展示设计" [shape=box]; + "用户批准设计?" [shape=diamond]; + "编写设计文档" [shape=box]; + "规格自检\n(内联修复)" [shape=box]; + "用户审查规格?" [shape=diamond]; + "调用 writing-plans 技能" [shape=doublecircle]; + "发现隐藏复杂度? 升级路径" [shape=box]; + + "分类:探路 / 有界 / 架构级" -> "展示问题 + 试探计划(2-3 句)" [label="探路"]; + "分类:探路 / 有界 / 架构级" -> "提出澄清问题(有界)" [label="有界"]; + "分类:探路 / 有界 / 架构级" -> "探索项目上下文" [label="架构级"]; + "展示问题 + 试探计划(2-3 句)" -> "人类伙伴批准?"; + "提出澄清问题(有界)" -> "在对话里展示简短设计"; + "在对话里展示简短设计" -> "人类伙伴批准?"; + "人类伙伴批准?" -> "动手调查;汇报建议" [label="探路:是"]; + "人类伙伴批准?" -> "走正常工作流实现(无计划文档)" [label="有界:是"]; + "发现隐藏复杂度? 升级路径" -> "分类:探路 / 有界 / 架构级"; + "探索项目上下文" -> "提出澄清问题"; + "提出澄清问题" -> "提出 2-3 种方案"; + "提出 2-3 种方案" -> "分节展示设计"; + "分节展示设计" -> "用户批准设计?"; + "用户批准设计?" -> "分节展示设计" [label="否,修改"]; + "用户批准设计?" -> "编写设计文档" [label="是"]; + "编写设计文档" -> "规格自检\n(内联修复)"; + "规格自检\n(内联修复)" -> "用户审查规格?"; + "用户审查规格?" -> "编写设计文档" [label="要求修改"]; + "用户审查规格?" -> "调用 writing-plans 技能" [label="批准"]; +} +``` + +**终止状态跟着路径走。** 架构级:头脑风暴之后你唯一要调用的技能是 writing-plans——绝不调用 frontend-design、mcp-builder 或任何其他实现技能。有界:获得批准之后,直接走正常的开发工作流去实现,不写计划文档。探路:终止状态是一份汇报出去的建议。 + +## 流程详述 + +下面这些小节服务于**有界**和**架构级**两条路径(探路在"展示试探计划、拿到点头"就停了)。从**探索方案**往后都是架构级路径的深度——对有界的工作来说,上下文加几个问题再加一份对话里的简短设计,就是全部流程。 + +**理解想法:** + +- 首先查看当前项目状态(文件、文档、最近的 commit) +- 在提出详细问题之前,先评估范围:如果需求描述了多个独立子系统(例如"构建一个包含聊天、文件存储、计费和分析的平台"),立即指出这一点。不要花时间用问题去细化一个需要先拆分的项目。 +- 如果项目规模过大,单个规格说明无法覆盖,帮助用户分解为子项目:有哪些独立的部分,它们之间有什么关系,应该按什么顺序构建?然后通过正常的设计流程进行第一个子项目的头脑风暴。每个子项目都有自己的规格 → 计划 → 实现周期。 +- 对于范围适当的项目,每次提一个问题来完善想法 +- 尽量使用选择题,开放式问题也可以 +- 每条消息只提一个问题——如果一个主题需要更多探索,拆分成多个问题 +- 重点理解:目的、约束、成功标准 + +**探索方案:** + +- 提出 2-3 种不同的方案及其权衡 +- 以对话的方式展示选项,附上你的推荐和理由 +- 先展示你推荐的方案并解释原因 +- 严格遵循 YAGNI —— 从每个方案和设计里移除不必要的功能 + +**展示设计:** + +- 一旦你认为理解了要构建的内容,就展示设计 +- 每个部分的篇幅与其复杂度匹配:简单的几句话,复杂的最多 200-300 字 +- 每个部分展示后询问是否正确 +- 涵盖:架构、组件、数据流、错误处理、测试 +- 随时准备回头澄清不明确的地方 + +**面向隔离和清晰的设计:** + +- 将系统拆分为更小的单元,每个单元有一个明确的职责,通过定义良好的接口通信,可以独立理解和测试 +- 对于每个单元,你应该能回答:它做什么,如何使用,它依赖什么? +- 别人能否不看内部实现就理解一个单元的功能?你能否在不影响调用者的情况下修改内部实现?如果不能,边界需要调整。 +- 更小、边界清晰的单元也更便于你工作——你对能一次放入上下文的代码推理得更好,文件越专注你的编辑越可靠。当文件变大时,这通常意味着它承担了过多职责。 + +**在现有代码库中工作:** + +- 在提出更改之前先探索现有结构。遵循现有模式。 +- 如果现有代码存在影响当前工作的问题(例如文件过大、边界不清、职责纠缠),在设计中包含有针对性的改进——就像一个优秀的开发者在工作中改进经手的代码一样。 +- 不要提议无关的重构。专注于服务当前目标的事情。 + +## 设计之后(架构级路径) + +**文档:** + +- 将验证通过的设计(规格说明)写入 `docs/superpowers/specs/YYYY-MM-DD--design.md` + - (用户对规格位置的偏好优先于此默认值) +- 如果可用,使用 elements-of-style:writing-clearly-and-concisely 技能 +- 将设计文档 commit 到 git + +**规格自检:** +编写规格文档后,以全新的视角审视它: + +1. **占位符扫描:** 有没有"待定"、"TODO"、未完成的章节或模糊的需求?修复它们。 +2. **内部一致性:** 各章节之间有矛盾吗?架构和功能描述匹配吗? +3. **范围检查:** 这是否聚焦到可以用一个实现计划覆盖,还是需要进一步拆分? +4. **模糊性检查:** 有没有需求可以被两种方式理解?如果有,选择一种并明确写出来。 + +发现问题就直接内联修复。无需重新审查——修好继续推进。 + +**用户审查关卡:** +规格自检完成后,请用户在继续之前审查书面规格: + +> "规格已编写并 commit 到 ``。请审查一下,如果在我们开始编写实现计划之前你想做任何修改,请告诉我。" + +等待用户回复。如果他们要求修改,做出修改并重新运行规格自检。只有在用户批准后才继续。 + +**实现:** + +- 调用 writing-plans 技能创建详细的实现计划 +- 不要调用任何其他技能。writing-plans 是下一步。 + +## 视觉伴侣 + +一个基于浏览器的伴侣工具,用于在头脑风暴过程中展示原型、图表和视觉选项。它是一个工具——不是一种模式。接受伴侣意味着它可用于适合视觉呈现的问题;并不意味着每个问题都要通过浏览器。 + +**提供伴侣(在需要时才提):** **不要一上来就提。** 等到某个问题确实"画出来比说出来更清楚"时再提——要是真正的原型 / 布局 / 图表问题,而不仅仅是话题跟 UI 沾边。第一次出现这种情况时,就在那一刻提供,作为独立的一条消息: +> "接下来这部分,我展示给你看可能更容易理解——我可以在讨论过程中,在一个浏览器标签页里做原型、图表和对比。这个功能还比较新,可能会消耗较多 token。要我打开吗?我来帮你打开。" + +**此提议必须是一条独立的消息。** 只有这条提议——不含澄清问题、内容摘要或任何其他内容。等待用户回复。如果他们接受,用 `--open` 启动服务,浏览器会自动打开到第一屏。如果他们拒绝,继续纯文本进行,并且不要再提,除非他们自己提起。 + +**逐问题决策:** 即使用户接受了,也要对每个问题单独决定是使用浏览器还是终端。判断标准:**用户看到它是否比读到它更容易理解?** + +- **使用浏览器** 展示本身就是视觉的内容——原型、线框图、布局对比、架构图、并排视觉设计 +- **使用终端** 展示文本内容——需求问题、概念选择、权衡列表、A/B/C/D 文字选项、范围决策 + +关于 UI 主题的问题不一定是视觉问题。"在这个上下文中个性化是什么意思?"是一个概念问题——使用终端。"哪种向导布局更好?"是一个视觉问题——使用浏览器。 + +如果他们同意使用伴侣,在继续之前阅读详细指南: +`skills/brainstorming/visual-companion.md` diff --git a/.codebuddy/skills/brainstorming/scripts/frame-template.html b/.codebuddy/skills/brainstorming/scripts/frame-template.html new file mode 100644 index 0000000..f540bb8 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/frame-template.html @@ -0,0 +1,213 @@ + + + + + Superpowers Brainstorming + + + +
+ +
Connecting…
+
+ +
+
+ +
+
+ + + diff --git a/.codebuddy/skills/brainstorming/scripts/helper.js b/.codebuddy/skills/brainstorming/scripts/helper.js new file mode 100644 index 0000000..e11d264 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/helper.js @@ -0,0 +1,167 @@ +(function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + + let ws = null; + let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + function sessionKey() { + try { + return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key'); + } catch (e) {} + return null; + } + + function websocketUrl() { + const key = sessionKey(); + return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : ''); + } + + function reloadAfterRecovery() { + const key = sessionKey(); + if (key) { + window.location.replace('/?key=' + encodeURIComponent(key)); + } else { + window.location.reload(); + } + } + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connecting: ['Connecting…', 'var(--text-tertiary)'], + connected: ['Connected', 'var(--success)'], + reconnecting: ['Reconnecting…', 'var(--warning)'], + disconnected: ['Disconnected', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

Companion paused

' + + '

This brainstorm companion has stopped. ' + + 'Ask your coding agent to bring it back — this page reconnects automatically.

'; + if (document.body) document.body.appendChild(el); + } + + function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connecting'); + ws = new WebSocket(websocketUrl()); + + ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload through the keyed bootstrap when possible so the cookie is + // refreshed before the visible URL returns to bare /. + if (recovered) reloadAfterRecovery(); + }; + + ws.onmessage = (msg) => { + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); + }; + + ws.onclose = () => { + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); + }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/.codebuddy/skills/brainstorming/scripts/server.cjs b/.codebuddy/skills/brainstorming/scripts/server.cjs new file mode 100644 index 0000000..a828b35 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/server.cjs @@ -0,0 +1,723 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + const extendedLen = buffer.readBigUInt64BE(2); + if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + payloadLen = Number(extendedLen); + offset = 10; + } + + if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error('WebSocket frame payload exceeds maximum allowed size'); + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null; +const randomPort = () => 49152 + Math.floor(Math.random() * 16383); +// Prefer an explicit port, else the port this session last bound (so a restart +// reuses it and an already-open browser tab reconnects), else a random high port. +function preferredPort() { + if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT); + if (PORT_FILE) { + try { + const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (Number.isInteger(p) && p > 1023 && p < 65536) return p; + } catch (e) { /* no prior port recorded */ } + } + return randomPort(); +} +let PORT = preferredPort(); +const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1'; +const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm'; +const CONTENT_DIR = path.join(SESSION_DIR, 'content'); +const STATE_DIR = path.join(SESSION_DIR, 'state'); +const SUPERPOWERS_VERSION = readSuperpowersVersion(); +const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png'; +const TELEMETRY_DISABLE_ENV_VARS = [ + 'SUPERPOWERS_DISABLE_TELEMETRY', + 'DISABLE_TELEMETRY', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +]; +const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name])); +let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null; + +// Per-session secret key. The companion is reachable by any local browser tab +// and, when bound to a non-loopback host, by any host that can route to it. +// The key authenticates the real client uniformly across loopback, tunnel, and +// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist +// cannot. It rides the served URL as ?key= and is mirrored into a cookie on +// first load so same-origin subresources and the WebSocket carry it for free. +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +function generateToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function chmodOwnerOnly(file) { + try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ } +} + +function initialToken() { + if (process.env.BRAINSTORM_TOKEN) { + return { value: process.env.BRAINSTORM_TOKEN, source: 'env' }; + } + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) { + chmodOwnerOnly(TOKEN_FILE); + return { value: t, source: 'file' }; + } + } catch (e) { /* no prior token recorded */ } + } + return { value: generateToken(), source: 'generated' }; +} + +const tokenInfo = initialToken(); +let TOKEN = tokenInfo.value; +let tokenSource = tokenInfo.source; +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +function waitingPage() { + return renderBranding(` + +Brainstorm Companion + + +

Brainstorm Companion

+

Waiting for the agent to push a screen...

`); +} + +const FORBIDDEN_PAGE = ` + +Session key required + + +

Session key required

+

This page needs the full URL your coding agent gave you, including the +?key=… part. Copy the complete URL and open it again.

`; + +function bootstrapPage(key) { + const jsonKey = JSON.stringify(String(key)); + return ` + +Opening Brainstorm Companion + + + +`; +} + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function readSuperpowersVersion() { + const root = path.join(__dirname, '../../..'); + const manifests = [ + path.join(root, 'package.json'), + path.join(root, '.codex-plugin/plugin.json') + ]; + + for (const manifest of manifests) { + try { + const data = JSON.parse(fs.readFileSync(manifest, 'utf-8')); + if (data.version) return String(data.version); + } catch (e) { + // Packaged Codex plugins omit package.json; try the next manifest. + } + } + + return 'unknown'; +} + +function isTruthyEnv(value) { + if (!value) return false; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return false; + return !['0', 'false', 'no', 'off'].includes(normalized); +} + +function escapeHtmlText(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function brandMarkup() { + const version = escapeHtmlText(SUPERPOWERS_VERSION); + const text = SUPERPOWERS_TELEMETRY_DISABLED + ? 'Prime Radiant Superpowers v' + version + : 'Superpowers v' + version; + const logo = SUPERPOWERS_TELEMETRY_DISABLED + ? '' + : ''; + + return ''; +} + +function renderBranding(html) { + return html.split('').join(brandMarkup()); +} + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', content); +} + +function getNewestScreen() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => !f.startsWith('.') && f.endsWith('.html')) + .map(f => { + const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +function urlHostForHttp(host) { + const h = String(host); + if (h.startsWith('[') && h.endsWith(']')) return h; + return h.includes(':') ? '[' + h + ']' : h; +} + +function companionUrl() { + return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN; +} + +function browserLauncherForPlatform(url, { + platform = process.platform, + osRelease = require('os').release(), + env = process.env +} = {}) { + const isWSL = platform === 'linux' && /microsoft/i.test(osRelease); + if (platform === 'darwin') return { bin: 'open', args: [url] }; + if (platform === 'win32' || isWSL) { + return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }; + } + if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] }; + return null; +} + +function isRegularFileInsideContentDir(filePath) { + let stat, realContentDir, realFilePath; + try { + stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) return false; + if (!stat.isFile()) return false; + if (stat.nlink !== 1) return false; + realContentDir = fs.realpathSync(CONTENT_DIR); + realFilePath = fs.realpathSync(filePath); + } catch (e) { + return false; + } + return realFilePath.startsWith(realContentDir + path.sep); +} + +// ========== Authentication ========== + +function timingSafeEqualStr(a, b) { + const ab = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + return out; +} + +// A request is authorized if it carries the session key as ?key= or as the +// session cookie. Both are compared in constant time. +function isAuthorized(req) { + const q = req.url.indexOf('?'); + if (q >= 0) { + const params = new URLSearchParams(req.url.slice(q + 1)); + if (params.has('key')) { + const key = params.get('key'); + return Boolean(key && timingSafeEqualStr(key, TOKEN)); + } + } + const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME]; + if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true; + return false; +} + +function pathnameOf(url) { + const q = url.indexOf('?'); + return q >= 0 ? url.slice(0, q) : url; +} + +function queryKey(url) { + const q = url.indexOf('?'); + if (q < 0) return null; + return new URLSearchParams(url.slice(q + 1)).get('key'); +} + +function securityHeaders(headers = {}) { + return { + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-store', + 'X-Frame-Options': 'DENY', + 'Content-Security-Policy': "frame-ancestors 'none'", + 'Cross-Origin-Resource-Policy': 'same-origin', + ...headers + }; +} + +function isAllowedWebSocketOrigin(req) { + const origin = req.headers.origin; + if (!origin) return true; + const host = req.headers.host; + if (!host) return false; + return origin === 'http://' + host; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + if (!isAuthorized(req)) { + res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(FORBIDDEN_PAGE); + return; + } + touchActivity(); // only authorized requests count as activity + + // Mirror the key into a cookie so same-origin subresources (/files/*) can + // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the + // WebSocket Origin check below is what blocks cross-origin localhost injection. + res.setHeader('Set-Cookie', + COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/'); + + const pathname = pathnameOf(req.url); + const keyFromQuery = queryKey(req.url); + if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) { + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(bootstrapPage(keyFromQuery)); + } else if (req.method === 'GET' && pathname === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : waitingPage(); + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); + res.end(html); + } else if (req.method === 'GET' && pathname.startsWith('/files/')) { + const fileName = path.basename(pathname.slice(7)); + const filePath = path.join(CONTENT_DIR, fileName); + // Reject empty/dotfile names and anything that isn't a regular file — + // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR). + if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, securityHeaders({ 'Content-Type': contentType })); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404, securityHeaders()); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; } + + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event && event.choice) { + const eventsFile = path.join(STATE_DIR, 'events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = companionUrl(); // must carry the key or the gate 403s it + const cp = require('child_process'); + // Operator-provided launcher: run as given (this env var is trusted operator input). + if (process.env.BRAINSTORM_OPEN_CMD) { + try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } + return; + } + // Platform launchers: pass the URL as an argv element via execFile (no shell), + // so a url-host containing shell metacharacters can't inject a command. + const launcher = browserLauncherForPlatform(url); + if (!launcher) return; // headless: nothing to open + try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ } +} + +// ========== Activity Tracking ========== + +// Idle timeout: shut down after this long with no activity. Default 4 hours; +// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes). +const IDLE_TIMEOUT_MS = (() => { + const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000; +})(); +// How often the watchdog checks for owner-death / idleness. Configurable mainly +// so tests can run fast; production default is 60s. +const LIFECYCLE_CHECK_MS = (() => { + const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS); + return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000; +})(); +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true }); + if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); + + // Track known files to distinguish new screens from updates. + // macOS fs.watch reports 'rename' for both new files and overwrites, + // so we can't rely on eventType alone. + const knownFiles = new Set( + fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => { + if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(CONTENT_DIR, filename); + + if (!fs.existsSync(filePath)) return; // file was deleted + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(STATE_DIR, 'events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(STATE_DIR, 'server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(STATE_DIR, 'server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + // Close any upgraded WebSocket sockets so server.close() can complete and + // the process actually exits instead of lingering on an open connection. + for (const socket of clients) { + try { socket.destroy(); } catch (e) { /* already gone */ } + } + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!ownerPid) return true; + try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; } + } + + // Periodically exit if the owner process died or we've been idle too long. + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, LIFECYCLE_CHECK_MS); + lifecycleCheck.unref(); + + // Validate owner PID at startup. If it's already dead, the PID resolution + // was wrong (common on WSL, Tailscale SSH, and cross-user scenarios). + // Disable monitoring and rely on the idle timeout instead. + if (ownerPid) { + try { process.kill(ownerPid, 0); } + catch (e) { + if (e.code !== 'EPERM') { + console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' })); + ownerPid = null; + } + } + } + + // If the preferred port is already taken (e.g. a previous server is still + // alive), fall back to a random port once instead of failing. + let triedFallback = false; + + function onListen() { + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. + if (PORT_FILE && !triedFallback) { + try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { + fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); + chmodOwnerOnly(TOKEN_FILE); + } catch (e) { /* best effort */ } + } + } + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: companionUrl(), + screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS + }); + console.log(info); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); + } + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE' && !triedFallback) { + if (tokenSource === 'env') { + console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token'); + process.exit(1); + } + triedFallback = true; + PORT = randomPort(); + if (tokenSource === 'file') { + TOKEN = generateToken(); + tokenSource = 'generated-fallback'; + } + server.listen(PORT, HOST, onListen); + } else { + console.error('Server failed to bind:', err.message); + process.exit(1); + } + }); + server.listen(PORT, HOST, onListen); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + computeAcceptKey, + encodeFrame, + decodeFrame, + browserLauncherForPlatform, + OPCODES, + MAX_FRAME_PAYLOAD_BYTES +}; diff --git a/.codebuddy/skills/brainstorming/scripts/start-server.sh b/.codebuddy/skills/brainstorming/scripts/start-server.sh new file mode 100644 index 0000000..016a8e4 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/start-server.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Start the brainstorm server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.superpowers/brainstorm/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +IDLE_TIMEOUT_MINUTES="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --idle-timeout-minutes) + IDLE_TIMEOUT_MINUTES="$2" + shift 2 + ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then + if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then + echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}" + exit 1 + fi + export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 )) +fi + +is_windows_like_shell() { + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) return 0 ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + return 0 + fi + local uname_s + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + if is_windows_like_shell; then + FOREGROUND="true" + fi +fi + +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. + export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" +else + SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +LOG_FILE="${STATE_DIR}/server.log" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +# Create fresh session directory with content and state peers +mkdir -p "${SESSION_DIR}/content" "$STATE_DIR" + +SERVER_ID="" +if [[ -r /dev/urandom ]]; then + SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)" +fi +if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then + SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")" +fi +printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE" +chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" || exit 1 + +# Resolve the harness PID (grandparent of this script). +# $PPID is the ephemeral shell the harness spawned to run us — it dies +# when this script exits. The harness itself is $PPID's parent. +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace. +# Passing a PID node cannot verify causes server to log owner-pid-invalid +# and self-terminate at the 60-second lifecycle check. Clear it so the +# watchdog is disabled and the idle timeout becomes the only shutdown trigger. +if is_windows_like_shell; then + OWNER_PID="" +fi + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + wait "$SERVER_PID" + exit $? +fi + +# Start server, capturing output to log file +# Use nohup to survive shell exit; disown to remove from job table +nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for _ in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window (catches process reapers) + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/.codebuddy/skills/brainstorming/scripts/stop-server.sh b/.codebuddy/skills/brainstorming/scripts/stop-server.sh new file mode 100644 index 0000000..7cacfe9 --- /dev/null +++ b/.codebuddy/skills/brainstorming/scripts/stop-server.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Stop the brainstorm server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.superpowers/) are +# kept so mockups can be reviewed later. + +SESSION_DIR="$1" + +if [[ -z "$SESSION_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +STATE_DIR="${SESSION_DIR}/state" +PID_FILE="${STATE_DIR}/server.pid" +SERVER_ID_FILE="${STATE_DIR}/server-instance-id" + +mark_stopped() { + local reason="$1" + rm -f "${STATE_DIR}/server-info" + printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped" +} + +read_expected_server_id() { + [[ -f "$SERVER_ID_FILE" ]] || return 1 + local id + id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)" + [[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1 + printf '%s\n' "$id" +} + +command_line_for_pid() { + local pid="$1" + if [[ -r "/proc/$pid/cmdline" ]]; then + tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true + return 0 + fi + ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true +} + +command_has_server_id() { + local pid="$1" + local expected="$2" + local expected_arg="--brainstorm-server-id=$expected" + if [[ -r "/proc/$pid/cmdline" ]]; then + local arg + while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do + [[ "$arg" == "$expected_arg" ]] && return 0 + done < "/proc/$pid/cmdline" + return 1 + fi + local command_line + command_line="$(command_line_for_pid "$pid")" + [[ -n "$command_line" ]] || return 1 + case " $command_line " in + *" $expected_arg "*) return 0 ;; + *) return 1 ;; + esac +} + +# Confirm a PID has this session's per-start instance id, not just a familiar +# process name. Ambiguous or legacy metadata fails closed as stale_pid. +is_brainstorm_server() { + kill -0 "$1" 2>/dev/null || return 1 + local expected_id + expected_id="$(read_expected_server_id)" || return 1 + command_has_server_id "$1" "$expected_id" || return 1 + return 0 +} + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Refuse to signal a PID we can't prove is our server. A stale pid file may + # point at an unrelated process after a reboot/PID wraparound. + if ! is_brainstorm_server "$pid"; then + rm -f "$PID_FILE" "$SERVER_ID_FILE" + mark_stopped "stale_pid" + echo '{"status": "stale_pid"}' + exit 0 + fi + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + + # Give SIGKILL a moment to take effect + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log" + mark_stopped "stop-server.sh" + + # Only delete ephemeral /tmp directories + if [[ "$SESSION_DIR" == /tmp/* ]]; then + rm -rf "$SESSION_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi diff --git a/.codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md b/.codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md new file mode 100644 index 0000000..7e61864 --- /dev/null +++ b/.codebuddy/skills/brainstorming/spec-document-reviewer-prompt.md @@ -0,0 +1,48 @@ +# 规格文档审查员提示模板 + +调度规格文档审查员子代理时使用此模板。 + +**用途:** 验证规格是否完整、一致,并为实现计划做好准备。 + +**调度时机:** 规格文档写入 docs/superpowers/specs/ 之后 + +``` +Task tool(通用): + description: "审查规格文档" + prompt: | + 你是一名规格文档审查员。验证此规格是否完整并准备好进行计划编写。 + + **待审查规格:** [SPEC_FILE_PATH] + + ## 检查内容 + + | 类别 | 检查要点 | + |------|----------| + | 完整性 | TODO、占位符、"TBD"、不完整的章节 | + | 一致性 | 内部矛盾、相互冲突的需求 | + | 清晰度 | 需求模糊到可能导致构建出错误的东西 | + | 范围 | 是否足够聚焦以用于单个计划——而非涵盖多个独立子系统 | + | YAGNI | 未请求的功能、过度设计 | + + ## 校准标准 + + **只标记会在实现计划阶段造成实际问题的事项。** + 缺失的章节、矛盾之处、或者模糊到可能被两种不同方式理解的需求—— + 这些才是问题。措辞上的小改进、风格偏好、以及"某些章节不如其他章节详细"则不是。 + + 除非存在会导致计划出错的严重缺陷,否则应予以通过。 + + ## 输出格式 + + ## 规格审查 + + **状态:** 通过 | 发现问题 + + **问题(如有):** + - [章节 X]:[具体问题] - [为什么这对计划编写很重要] + + **建议(仅供参考,不阻止通过):** + - [改进建议] +``` + +**审查员返回:** 状态、问题(如有)、建议 diff --git a/.codebuddy/skills/brainstorming/visual-companion.md b/.codebuddy/skills/brainstorming/visual-companion.md new file mode 100644 index 0000000..0451e3f --- /dev/null +++ b/.codebuddy/skills/brainstorming/visual-companion.md @@ -0,0 +1,295 @@ +# 视觉伴侣指南 + +基于浏览器的视觉头脑风暴伴侣,用于展示原型、图表和选项。 + +## 何时使用 + +逐问题决定,而非按会话决定。判断标准:**用户看到它是否比读到它更容易理解?** + +**使用浏览器** 当内容本身是视觉的: + +- **UI 原型** — 线框图、布局、导航结构、组件设计 +- **架构图** — 系统组件、数据流、关系图 +- **并排视觉对比** — 对比两种布局、两种配色方案、两种设计方向 +- **设计细节打磨** — 当问题涉及外观感受、间距、视觉层次 +- **空间关系** — 状态机、流程图、实体关系图 + +**使用终端** 当内容是文字或表格的: + +- **需求和范围问题** — "X 是什么意思?"、"哪些功能在范围内?" +- **概念性 A/B/C 选择** — 在用文字描述的方案之间做选择 +- **权衡列表** — 优缺点、对比表 +- **技术决策** — API 设计、数据建模、架构方案选择 +- **澄清问题** — 任何回答是文字而非视觉偏好的问题 + +关于 UI 主题的问题不一定是视觉问题。"你想要什么样的向导?"是概念性的——使用终端。"这些向导布局中哪个感觉对?"是视觉性的——使用浏览器。 + +## 工作原理 + +服务器监视一个目录中的 HTML 文件,将最新的文件提供给浏览器。你写入 HTML 内容,用户在浏览器中看到它,并可以点击选择选项。选择结果被记录到一个 `.events` 文件中,你在下一轮会话中读取它。 + +**内容片段 vs 完整文档:** 如果你的 HTML 文件以 `/.superpowers/brainstorm/` 获取会话目录。 + +**注意:** 传入项目根目录作为 `--project-dir`,这样原型会持久化在 `.superpowers/brainstorm/` 中,不会因服务器重启而丢失。不传的话,文件会保存到 `/tmp` 并在清理时被删除。提醒用户将 `.superpowers/` 添加到 `.gitignore`(如果尚未添加)。 + +**按平台启动服务器:** + +**Claude Code (macOS / Linux):** +```bash +# 默认模式即可——脚本会自动将服务器放到后台 +scripts/start-server.sh --project-dir /path/to/project +``` + +**Claude Code (Windows):** +```bash +# Windows 会自动检测并使用前台模式,这会阻塞工具调用。 +# 在 Bash 工具调用上设置 run_in_background: true, +# 让服务器在会话轮次之间存活。 +scripts/start-server.sh --project-dir /path/to/project +``` +通过 Bash 工具调用时,设置 `run_in_background: true`。然后在下一轮读取 `$SCREEN_DIR/.server-info` 获取 URL 和端口。 + +**Codex:** +```bash +# Codex 会回收后台进程。脚本会自动检测 CODEX_CI 并 +# 切换到前台模式。正常运行即可——不需要额外标志。 +scripts/start-server.sh --project-dir /path/to/project +``` + +**Gemini CLI:** +```bash +# 使用 --foreground 并在 shell 工具调用上设置 is_background: true, +# 让进程在轮次之间存活 +scripts/start-server.sh --project-dir /path/to/project --foreground +``` + +**Copilot CLI:** +```bash +# 用 Copilot CLI 的非阻塞 / 后台 shell 机制启动,让服务器能跨会话轮次存活。 +# 保留 --foreground —— 由 harness 而不是脚本来负责放到后台。 +# 启动器是 .sh,所以要通过 bash 调用(Windows 上用 Git Bash 的 bash.exe, +# 从 PowerShell 工具里调)。 +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground +``` + +**其他环境:** 服务器必须在会话轮次之间持续在后台运行。如果你的环境会回收分离的进程,使用 `--foreground` 并通过平台的后台执行机制启动命令。 + +如果浏览器无法访问该 URL(在远程/容器化环境中常见),绑定一个非回环主机: + +```bash +scripts/start-server.sh \ + --project-dir /path/to/project \ + --host 0.0.0.0 \ + --url-host localhost +``` + +使用 `--url-host` 控制返回的 URL JSON 中显示的主机名。 + +## 工作循环 + +1. **检查服务器存活**,然后**将 HTML 写入** `screen_dir` 中的新文件: + - 每次写入前,检查 `$SCREEN_DIR/.server-info` 是否存在。如果不存在(或 `.server-stopped` 存在),服务器已关闭——在继续之前用 `start-server.sh` 重启。服务器在 30 分钟无活动后会自动退出。 + - 使用语义化文件名:`platform.html`、`visual-style.html`、`layout.html` + - **绝不复用文件名** — 每个屏幕用一个新文件 + - 使用 Write 工具 — **绝不使用 cat/heredoc**(会在终端产生噪音) + - 服务器自动提供最新的文件 + +2. **告诉用户预期内容并结束你的回合:** + - 每一步都提醒他们 URL(不仅仅是第一次) + - 简要文字说明屏幕上的内容(例如"展示了 3 个首页布局选项") + - 请他们在终端中回复:"看一下,告诉我你的想法。如果你愿意,可以点击选择一个选项。" + +3. **在你的下一轮** — 用户在终端回复后: + - 如果存在 `$SCREEN_DIR/.events`,读取它——其中包含用户的浏览器交互(点击、选择),格式为 JSON 行 + - 将终端文字和事件合并以获得完整信息 + - 终端消息是主要反馈;`.events` 提供结构化的交互数据 + +4. **迭代或推进** — 如果反馈要求修改当前屏幕,写入新文件(例如 `layout-v2.html`)。只有当前步骤验证通过后才进入下一个问题。 + +5. **回到终端时卸载** — 当下一步不需要浏览器时(例如澄清问题、权衡讨论),推送一个等待屏幕以清除过时内容: + + ```html + +
+

在终端中继续...

+
+ ``` + + 这样可以防止用户盯着一个已经解决的选择,而对话已经继续了。当下一个视觉问题出现时,照常推送新的内容文件。 + +6. 重复直到完成。 + +## 编写内容片段 + +只写放在页面内部的内容。服务器会自动用框架模板包裹它(头部、主题 CSS、选择指示器和所有交互基础设施)。 + +**最简示例:** + +```html +

哪种布局更好?

+

考虑可读性和视觉层次

+ +
+
+
A
+
+

单栏

+

简洁、专注的阅读体验

+
+
+
+
B
+
+

双栏

+

侧边栏导航加主内容区

+
+
+
+``` + +就这些。不需要 ``,不需要 CSS,不需要 ` + + +{{end}} diff --git a/internal/handler/templates/pages/article-form.html b/internal/handler/templates/pages/article-form.html new file mode 100644 index 0000000..1cbd472 --- /dev/null +++ b/internal/handler/templates/pages/article-form.html @@ -0,0 +1,39 @@ +{{define "title"}}新建文章 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 新建文章{{end}} +{{define "barRight"}}← 返回列表{{end}} +{{define "content"}} +{{template "bar" .}} +
+

新建文章

+

走秀与街拍二选一:走秀填品牌/季节/系列,街拍填城市/年份;图片一步到位上传,创建后状态为「待审核」。

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + + + + + + +
类型 + +
品牌英文名
标题(英文)
标题(中文)
年份
季节 + +
系列 + +
城市
图片 + +
至少 1 张;JPG / PNG / WEBP;单张 ≤ 10MB。按选择顺序作为 Look 1..N,第一张作封面;同内容图片会自动去重。
+
+

取消

+
+
+{{end}} diff --git a/internal/handler/templates/pages/articles.html b/internal/handler/templates/pages/articles.html new file mode 100644 index 0000000..151f787 --- /dev/null +++ b/internal/handler/templates/pages/articles.html @@ -0,0 +1,55 @@ +{{define "title"}}文章管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 文章管理{{end}} +{{define "barRight"}}+ 新建 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

文章管理

+

走秀与街拍合并管理:通过 / 编辑 / 删除(下架,清空 S4)都在此处发起。待审核优先排在前面。

+ + + +
+ + + +{{if .ShowDup}}{{end}} + + +{{range .Items}} + + + + + +{{if $.ShowDup}}{{end}} + + +{{end}} + +
类型ID标题副标题图数(主+副)跨图集重复原图总大小状态操作
{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.ID}}{{.Title}}{{.Subtitle}}{{.ImageTotal}}{{if .ImageDetail}} 主{{.ImageMain}}+副{{.ImageDetail}}{{end}}{{if .DupImages}}{{.DupImages}} 张 · {{printf "%.0f" .DupRate}}%{{else}}—{{end}}{{formatBytes .ImageTotalSize}}{{.Status}} +
+{{if eq .Kind "runway"}}编辑{{else}}编辑{{end}} +{{if eq .Status "pending"}}
{{end}} +{{if eq .Kind "runway"}}
{{else}}
{{end}} +
+
+
+{{template "pager" .Pager}} + + +
+{{end}} diff --git a/internal/handler/templates/pages/brand-form.html b/internal/handler/templates/pages/brand-form.html new file mode 100644 index 0000000..826285a --- /dev/null +++ b/internal/handler/templates/pages/brand-form.html @@ -0,0 +1,15 @@ +{{define "title"}}{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台{{end}} +{{define "barLabel"}}管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}{{end}} +{{define "barRight"}}返回列表 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

{{if .UID}}编辑品牌{{else}}新建品牌{{end}}

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
+
+{{end}} diff --git a/internal/handler/templates/pages/brands.html b/internal/handler/templates/pages/brands.html new file mode 100644 index 0000000..552ccff --- /dev/null +++ b/internal/handler/templates/pages/brands.html @@ -0,0 +1,30 @@ +{{define "title"}}品牌管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 品牌管理{{end}} +{{define "barRight"}}+ 新建 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

品牌列表

+
+ +{{if .All}}{{end}} + +{{if .Keyword}}清除{{end}} + +{{if .All}}只看有图集的品牌{{else}}显示全部品牌(含无图集){{end}} +
+

默认只显示「已有图集」的品牌;品牌目录共 2 万余条,其余请用上面的搜索或开关查看。

+
+全部 +中文 +{{range $l := .Letters}}{{$l}}{{end}} +
+ + + +{{range .Items}}{{end}} + +
编码 ID英文名中文名操作
{{.UID}}{{.NameEn}}{{.NameCn}}编辑
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/dashboard.html b/internal/handler/templates/pages/dashboard.html new file mode 100644 index 0000000..28a664e --- /dev/null +++ b/internal/handler/templates/pages/dashboard.html @@ -0,0 +1,16 @@ +{{define "title"}}管理后台{{end}} +{{define "barLabel"}}管理后台 · Fashion Archive{{end}} +{{define "barRight"}}退出({{.Username}}){{end}} +{{define "content"}} +{{template "bar" .}} + +{{end}} diff --git a/internal/handler/templates/pages/error.html b/internal/handler/templates/pages/error.html new file mode 100644 index 0000000..0931052 --- /dev/null +++ b/internal/handler/templates/pages/error.html @@ -0,0 +1,5 @@ +{{define "title"}}错误{{end}} +{{define "content"}} +

出错了

{{.Error}}

+

返回首页

+{{end}} diff --git a/internal/handler/templates/pages/ingest-jobs.html b/internal/handler/templates/pages/ingest-jobs.html new file mode 100644 index 0000000..2ba5fae --- /dev/null +++ b/internal/handler/templates/pages/ingest-jobs.html @@ -0,0 +1,24 @@ +{{define "title"}}入库任务 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

爬虫入库任务

+

worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。

+ + + +{{range .Jobs}} + + + + + + +{{end}} + +
ID状态重试错误信息更新时间操作
{{.ID}}{{.Status}}{{.Attempts}}{{.LastError}}{{.UpdatedAt}}{{if eq .Status "failed"}}
{{else}}—{{end}}
+{{template "pager" .Pager}} +
+{{end}} diff --git a/internal/handler/templates/pages/login.html b/internal/handler/templates/pages/login.html new file mode 100644 index 0000000..313e424 --- /dev/null +++ b/internal/handler/templates/pages/login.html @@ -0,0 +1,10 @@ +{{define "title"}}管理后台 · 登录{{end}} +{{define "content"}} +

管理后台登录

+
+{{if .Error}}
{{.Error}}
{{end}} + + + +
+{{end}} diff --git a/internal/handler/templates/pages/runway-detail.html b/internal/handler/templates/pages/runway-detail.html new file mode 100644 index 0000000..940d084 --- /dev/null +++ b/internal/handler/templates/pages/runway-detail.html @@ -0,0 +1,14 @@ +{{define "title"}}走秀详情 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 走秀详情{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

+

{{.Article.Title}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

+
+{{range .Article.Images}}
{{.Name}}
{{range .Detail}}
{{.Name}} · 副图
{{end}}{{end}} +
+
+{{end}} diff --git a/internal/handler/templates/pages/runway-edit.html b/internal/handler/templates/pages/runway-edit.html new file mode 100644 index 0000000..1e616b3 --- /dev/null +++ b/internal/handler/templates/pages/runway-edit.html @@ -0,0 +1,36 @@ +{{define "title"}}编辑走秀 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 编辑走秀 {{.UID}}{{end}} +{{define "barRight"}}返回详情 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

品牌 {{.BrandName}}{{if .IsDown}} · 已下架{{end}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

+
+
+
+
+
+ + + + + +
+ +{{template "imageGrid" .}} + +{{if eq .Runway.Status "pending"}} +
+
+
+{{end}} + +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
+{{end}} diff --git a/internal/handler/templates/pages/snap-edit.html b/internal/handler/templates/pages/snap-edit.html new file mode 100644 index 0000000..dfa8f54 --- /dev/null +++ b/internal/handler/templates/pages/snap-edit.html @@ -0,0 +1,28 @@ +{{define "title"}}编辑街拍 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 编辑街拍 {{.UID}}{{end}} +{{define "barRight"}}返回详情 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

{{if .IsDown}}已下架{{end}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

+
+
+
+ +
+ +{{template "imageGrid" .}} + +{{if eq .Snap.Status "pending"}} +
+
+
+{{end}} + +
+{{if .IsDown}}{{else}}{{end}} +
+

下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。

+
+{{end}} diff --git a/internal/handler/templates/pages/street-snap-detail.html b/internal/handler/templates/pages/street-snap-detail.html new file mode 100644 index 0000000..8872a0c --- /dev/null +++ b/internal/handler/templates/pages/street-snap-detail.html @@ -0,0 +1,14 @@ +{{define "title"}}街拍详情 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 街拍详情{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

编码 {{.Snap.UID}}

+

{{.Snap.Title}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

+
+{{range .Snap.Images}}
{{.Name}}
{{range .Detail}}
{{.Name}} · 副图
{{end}}{{end}} +
+
+{{end}} diff --git a/internal/handler/templates/pages/users.html b/internal/handler/templates/pages/users.html new file mode 100644 index 0000000..3a0362d --- /dev/null +++ b/internal/handler/templates/pages/users.html @@ -0,0 +1,30 @@ +{{define "title"}}用户管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 用户管理{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

用户管理({{len .Users}})

+

图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。

+ + + +{{range .Users}} + + + + + +{{end}} + +
ID用户名邮箱等级操作
{{.ID}}{{.Username}}{{.Email}}{{if eq .Tier "vip"}}VIP{{else}}FREE{{end}} +
+ + +
+
+
+{{end}} diff --git a/internal/handler/templates/partials.html b/internal/handler/templates/partials.html new file mode 100644 index 0000000..86cae5a --- /dev/null +++ b/internal/handler/templates/partials.html @@ -0,0 +1,80 @@ +{{/* + 共享片段:所有后台页面通用的顶部导航与翻页器。 + + 由 templates.go 在启动时与 layout.html 一起解析进「基模板」,各页面模板只需 + {{template "bar" .}} / {{template "pager" .Pager}} 引用,改一处即全局生效 + —— 这也是把导航从 14 份复制粘贴里提出来、补全模块入口的原因。 + + 页面需要提供的数据: + .NavKey 由 renderPage 自动注入(= 当前页面名),用于导航高亮 + .PendingReviews 由 backstageNavStats 中间件注入(待审总数,导航角标) + barLabel/barRight 两个 define 由各页面模板自己提供(见 pages/*.html) +*/}} + +{{define "bar"}} +
+ {{template "barLabel" .}} + +
+{{end}} + +{{define "pager"}} +
+ {{if .HasPrev}}上一页{{else}}上一页{{end}} + {{if .Label}}{{.Label}}{{end}} + {{if .HasNext}}下一页{{else}}下一页{{end}} +
+{{end}} + +{{/* + 共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。 + 两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。 + + 页面需要提供的字段: + .Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案) + .UID 记录的 hashid(用于 /admin/{street-snaps|runways}//... 路由) + .RecordID 记录数字 id(用于 /admin/articles///... 路由) + .Status 记录状态(pending 时显示待审角标) + .Cells []gin.H,每项 {id, image, name, isDetail, role} +*/}} +{{define "imageGrid"}} +

图片管理({{len .Cells}} 张{{if eq .Status "pending"}} · 待审核{{end}})

+
+ 操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
+ {{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}} + +
+{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}} +
+
+{{range .Cells}}
+ + {{.role}} + {{.name}} + {{range .similarHints}}{{if .Image}}{{end}}{{.OwnerTitle}}{{.Name}} · {{printf "%.2f" .Similar}}{{end}} +
+ {{if eq $kind "runway"}}
+
+ {{else}}
+
+ {{end}} + {{if eq .isDetail 0}}{{end}} + + {{if and (eq .isDetail 0) .DetCount}}{{end}} + {{if eq .isDetail 1}}
{{end}} +
+
{{end}} +
+
+ + 勾选同一人的图,并在其中一张主图上点「主图」 +
+{{end}} diff --git a/internal/handler/templates_format_test.go b/internal/handler/templates_format_test.go new file mode 100644 index 0000000..6f8cc9e --- /dev/null +++ b/internal/handler/templates_format_test.go @@ -0,0 +1,25 @@ +package handler + +import "testing" + +// TestFormatBytes 校验后台「图集原图总大小」的字节格式化(0/负数为「—」)。 +func TestFormatBytes(t *testing.T) { + cases := []struct { + n int64 + want string + }{ + {0, "—"}, + {-1, "—"}, + {500, "500 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {int64(1.5 * 1024 * 1024), "1.5 MB"}, + {int64(1.5 * 1024 * 1024 * 1024), "1.5 GB"}, + } + for _, c := range cases { + if got := formatBytes(c.n); got != c.want { + t.Errorf("formatBytes(%d) = %q, want %q", c.n, got, c.want) + } + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index fdd5121..8076f58 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -16,19 +16,10 @@ import ( const ( ContextUserID = "userID" ContextUsername = "username" - // ContextTier 存放用户等级(free/vip),供详情接口据其拼装普通/高清图。 + // ContextTier 存放用户等级(free/vip)。图片质量模型已统一,当前无消费方,仅作兼容保留。 ContextTier = "tier" ) -// TierFree / TierVIP 用户等级枚举值。 -const ( - TierFree = "free" - TierVIP = "vip" -) - -// IsVIP 判断等级是否为 VIP。 -func IsVIP(tier string) bool { return tier == TierVIP } - // bearerPrefix Authorization 头的令牌前缀。 const bearerPrefix = "Bearer " @@ -105,13 +96,3 @@ func UserIDFrom(c *gin.Context) (uint32, bool) { } return id, true } - -// TierFrom 从 Context 取出当前用户等级;未登录(无 tier)回落 free。 -func TierFrom(c *gin.Context) string { - if v, exists := c.Get(ContextTier); exists { - if t, ok := v.(string); ok && t != "" { - return t - } - } - return TierFree -} diff --git a/internal/middleware/ginlogger.go b/internal/middleware/ginlogger.go new file mode 100644 index 0000000..176bab5 --- /dev/null +++ b/internal/middleware/ginlogger.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "log/slog" +) + +// GinLogger 用结构化日志(slog,由 main 设为 JSON handler)记录每个请求, +// 替代 gin 默认的 text logger,便于生产环境集中采集与检索。 +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + + c.Next() + + // 跳过健康检查等高频噪声路径 + if path == "/api/health" || path == "/api/v1/health" { + return + } + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + method := c.Request.Method + if raw != "" { + path = path + "?" + raw + } + + attrs := []slog.Attr{ + slog.String("method", method), + slog.String("path", path), + slog.Int("status", status), + slog.String("ip", clientIP), + slog.Duration("latency", latency), + } + if len(c.Errors) > 0 { + attrs = append(attrs, slog.String("error", c.Errors.ByType(gin.ErrorTypePrivate).String())) + } + + switch { + case status >= 500: + slog.LogAttrs(c.Request.Context(), slog.LevelError, "request", attrs...) + case status >= 400: + slog.LogAttrs(c.Request.Context(), slog.LevelWarn, "request", attrs...) + default: + slog.LogAttrs(c.Request.Context(), slog.LevelInfo, "request", attrs...) + } + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..de6f3f8 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,97 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "fashionapi/internal/config" + + "github.com/gin-gonic/gin" +) + +// rateLimiter 基于令牌桶的按 IP 限流(进程内,单实例足够;多实例需换集中式如 redis)。 +// +// 设计取舍:公开接口「首页公开、翻页需登录」已天然抬高批量枚举门槛;本中间件作为 +// 兜底,按客户端 IP 限制总量,兜住 casual 爬虫与突发流量,避免单 IP 打垮服务。 +type rateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate float64 // 每秒补充令牌数 + burst float64 // 桶容量 + lastSeen map[string]time.Time +} + +type visitor struct { + tokens float64 + last time.Time +} + +func newRateLimiter(rps, burst int) *rateLimiter { + rl := &rateLimiter{ + visitors: make(map[string]*visitor), + lastSeen: make(map[string]time.Time), + rate: float64(rps), + burst: float64(burst), + } + go rl.cleanup(10 * time.Minute) + return rl +} + +// cleanup 周期清理长时间无活动的客户端,避免 map 无限增长。 +func (rl *rateLimiter) cleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + for ip, t := range rl.lastSeen { + if time.Since(t) > 3*interval { + delete(rl.visitors, ip) + delete(rl.lastSeen, ip) + } + } + rl.mu.Unlock() + } +} + +func (rl *rateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + v, ok := rl.visitors[ip] + if !ok { + // 首次访问直接发放 burst-1 个令牌(允许一个突发请求),后续按速率补充 + rl.visitors[ip] = &visitor{tokens: rl.burst - 1, last: now} + rl.lastSeen[ip] = now + return true + } + elapsed := now.Sub(v.last).Seconds() + v.tokens += elapsed * rl.rate + if v.tokens > rl.burst { + v.tokens = rl.burst + } + v.last = now + rl.lastSeen[ip] = now + if v.tokens >= 1 { + v.tokens-- + return true + } + return false +} + +// RateLimit 按 IP 的速率限制中间件。Enabled=false 时为 noop(便于灰度与本地开发)。 +func RateLimit(cfg config.RateLimitConfig) gin.HandlerFunc { + if !cfg.Enabled || cfg.RPS <= 0 { + return func(c *gin.Context) { c.Next() } + } + rl := newRateLimiter(cfg.RPS, cfg.Burst) + return func(c *gin.Context) { + if !rl.allow(c.ClientIP()) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "too many requests", + }) + return + } + c.Next() + } +} diff --git a/internal/model/brand.go b/internal/model/brand.go index cc99284..7078f02 100644 --- a/internal/model/brand.go +++ b/internal/model/brand.go @@ -2,7 +2,7 @@ package model // Brand 品牌表。 // -// 注意:时间戳字段为 int unsigned 存储的 Unix 秒,不是 MySQL datetime, +// 注意:时间戳字段为 int unsigned 存储的 Unix 秒,不是数据库 datetime, // 迁移时保持原样,避免与既有数据不兼容。 type Brand struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` @@ -17,4 +17,4 @@ type Brand struct { } // TableName 指定表名。 -func (Brand) TableName() string { return "brand" } +func (Brand) TableName() string { return "brands" } diff --git a/internal/model/content_status.go b/internal/model/content_status.go new file mode 100644 index 0000000..437aa63 --- /dev/null +++ b/internal/model/content_status.go @@ -0,0 +1,17 @@ +package model + +// 内容发布状态(单表发布模型)。 +// +// 历史上「爬虫草稿」与「正式记录」是两张表,审核 = 从草稿晋升到正式表。 +// 现在合并为一张表,审核态由本字段承载: +// - pending:刚入库,等待审核;公开视图不可见 +// - published:已通过,对外可见 +// - rejected:已驳回,留在库中备查;公开视图不可见 +// +// 新增记录默认 pending(见各自模型的 gorm default),漏赋值的结果是「不可见」, +// 即 fail-closed,而不是意外对外发布。 +const ( + StatusPending = "pending" + StatusPublished = "published" + StatusRejected = "rejected" +) diff --git a/internal/model/favorite.go b/internal/model/favorite.go index fdb5d24..8507974 100644 --- a/internal/model/favorite.go +++ b/internal/model/favorite.go @@ -4,13 +4,15 @@ package model // // 存储「对外编码 uid」而非数字主键,这样无需 decode 即可与前端卡片的 id 直接对齐, // 取消收藏时也只靠该串匹配。两种粒度共用一张表,由 kind 区分: -// - kind=gallery(默认):收藏整篇图集,target_uid 是图集 uid(r/s 前缀), +// - kind=gallery(默认):收藏整篇图集,target_uid 是图集 uid(类型由 TargetType 决定), // title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表; -// - kind=image:收藏单张图片,target_uid 是图片自身的编码 id(i/j 前缀), -// image_url 存这张图,parent_uid 指向所属图集(用于从收藏回跳原图集)。 +// - kind=image:收藏单张图片,target_uid 是图片自身的编码 id,parent_uid 指向所属图集 +// (用于从收藏回跳原图集)。 // -// 两种粒度的 uid 前缀互不冲突(图集 r/s,图片 i/j),因此唯一键 (user_id, target_uid) -// 无需调整即可同时约束两种收藏。 +// 编码:新写入用 hashid.EncodeWithType(类型进密码、无前缀);历史行可能仍是带 r/s/i/j 前缀的 +// 旧 EncodeTyped 串,后端详情 handler 有向后兼容回退,故旧 uid 仍可正常解析。类型以 TargetType +// 列为准(runway | street | runway_image | snap_image),不再靠串前缀区分。 +// 两种粒度的 uid 因类型进密码、串值不同互不冲突,唯一键 (user_id, target_uid) 无需调整。 type Favorite struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` diff --git a/internal/model/history.go b/internal/model/history.go index 88e33ed..4bbbc99 100644 --- a/internal/model/history.go +++ b/internal/model/history.go @@ -4,8 +4,8 @@ package model // // 2026-09-02 收敛为「文章级」记录:点开某篇走秀/街拍详情(/item/xxx)时记一条, // 进入列表页不再记。仅存三要素:user_id + target_uid + viewed_at。 -// target_uid 为带类型前缀的对外编码串(runway r=xxx / street s=xxx,见 hashid.EncodeTyped), -// 前缀本身携带类型,无需 kind / target_type 列;title/cover/brand 快照也已移除, +// target_uid 为对外编码串(类型进密码、无前缀,见 hashid.EncodeWithType);类型由账户页 +// 存储的 kind / target_type 或回查详情接口决定,串本身不含类型字母。title/cover/brand 快照已移除, // 账户页展示时按 target_uid 回查公开详情接口(保持表最简、数据新鲜)。 // 唯一键 (user_id, target_uid) 保证同一篇文章反复打开只刷新 viewed_at、不新增行。 type History struct { diff --git a/internal/model/ingest_job.go b/internal/model/ingest_job.go index 4dbb2fc..854a507 100644 --- a/internal/model/ingest_job.go +++ b/internal/model/ingest_job.go @@ -4,16 +4,15 @@ package model // // 设计:爬虫 POST 到 :8092 的 ingest 接口(HMAC 验签后)只做「入队 + 立即 202」, // 真正的去重 / 补分类 / 下载图 / 写正式表 由后台 worker 异步处理, -// 因此接口本身很薄、不会被慢任务(尤其是 OSS 上传)拖垮。 +// 因此接口本身很薄、不会被慢任务(尤其是 S4 上传)拖垮。 // -// 并发安全靠 MySQL 的 `SELECT ... FOR UPDATE SKIP LOCKED`:多个 worker 同时 +// 并发安全靠 PostgreSQL 的 `SELECT ... FOR UPDATE SKIP LOCKED`:多个 worker 同时 // Claim 不会抢到同一条,未提交的行对其它 worker 不可见。 type IngestJob struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` - LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收 - SourceURL string `gorm:"column:source_url" json:"source_url"` + LockedAt uint32 `gorm:"column:locked_at" json:"locked_at"` // worker 领取时间;用于超时兜底回收 Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` // pending|processing|done|failed Attempts uint8 `gorm:"column:attempts;not null;default:0" json:"attempts"` Payload string `gorm:"column:payload;type:text" json:"payload"` // 原始 JSON(RunwayIngest 或 MediaCleanupPayload) @@ -33,10 +32,10 @@ const ( IngestStatusFailed = "failed" ) -// IngestJob 任务类型(kind 列):区分「爬虫入库」与「清理七牛孤儿图」两条管线。 +// IngestJob 任务类型(kind 列):区分「爬虫入库」与「清理S4孤儿图」两条管线。 const ( IngestKindCrawl = "crawl" // 爬虫上报的走秀/街拍入库 - IngestKindMediaCleanup = "media_cleanup" // 删除图集时异步清理七牛孤儿文件 + IngestKindMediaCleanup = "media_cleanup" // 删除图集时异步清理S4孤儿文件 ) // IngestMaxAttempts 单条任务最大处理次数(含首次)。达上限仍未成功则置 failed,需后台手动重试。 diff --git a/internal/model/runway.go b/internal/model/runway.go index 3217c24..6907c99 100644 --- a/internal/model/runway.go +++ b/internal/model/runway.go @@ -17,20 +17,25 @@ type BrandRunway struct { BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` Year uint16 `gorm:"column:year" json:"year"` Cover string `gorm:"column:cover" json:"cover"` - SourceURL string `gorm:"column:source_url" json:"source_url"` // 分类字段:由 title 解析回填(历史数据迁移一次性写入,现已由外部 SQL 迁移维护) CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25 + PublishedAt uint32 `gorm:"column:published_at" json:"published_at"` // 发布时间 Unix 时间戳(秒);0=未抽取。Vogue datePublished;标题缺年份时用于矫正 year + + // 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。 + Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` + JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源) + Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号) } // TableName 指定表名。 -func (BrandRunway) TableName() string { return "brand_runway" } +func (BrandRunway) TableName() string { return "brand_runways" } // RunwayRow 走秀档案的查询投影行。 // -// 独立于 BrandRunway 定义,是为了承载 JOIN brand 得到的品牌名(en/cn 两列)。 +// 独立于 BrandRunway 定义,是为了承载 JOIN brands 得到的品牌名(en/cn 两列)。 type RunwayRow struct { ID uint32 `gorm:"column:id"` BrandID uint32 `gorm:"column:brand_id"` @@ -44,8 +49,8 @@ type RunwayRow struct { CollectionType string `gorm:"column:collection_type"` Season string `gorm:"column:season"` SeasonCode string `gorm:"column:season_code"` + Status string `gorm:"column:status"` CreatedAt uint32 `gorm:"column:created_at"` - SourceURL string `gorm:"column:source_url"` BrandNameEn string `gorm:"column:brand_name_en"` BrandNameCn string `gorm:"column:brand_name_cn"` } diff --git a/internal/model/runway_draft.go b/internal/model/runway_draft.go deleted file mode 100644 index 3fbaaf9..0000000 --- a/internal/model/runway_draft.go +++ /dev/null @@ -1,54 +0,0 @@ -package model - -// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。 -// -// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending; -// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runway 正式表。 -// 字段与 brand_runway 对齐(含 review 专用字段),便于审核页直接预览/微调。 -type BrandRunwayDraft struct { - ID uint32 `gorm:"primaryKey;column:id" json:"id"` - JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源) - BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` // 品牌数字主键 - TitleEn string `gorm:"column:title_en" json:"title_en"` - TitleCn string `gorm:"column:title_cn" json:"title_cn"` - DescriptionEn string `gorm:"column:description_en" json:"description_en"` - DescriptionCn string `gorm:"column:description_cn" json:"description_cn"` - Year uint16 `gorm:"column:year" json:"year"` - Season string `gorm:"column:season" json:"season"` - CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall - SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25 - Cover string `gorm:"column:cover" json:"cover"` - SourceURL string `gorm:"column:source_url" json:"source_url"` - ImageCount uint16 `gorm:"column:image_count" json:"image_count"` - Status string `gorm:"column:status" json:"status"` // pending | approved | rejected - Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号) - RejectReason string `gorm:"column:reject_reason" json:"reject_reason"` - IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` - CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` -} - -// TableName 指定草稿主表名。 -func (BrandRunwayDraft) TableName() string { return "brand_runway_draft" } - -// BrandRunwayDraftImage 草稿图片(对应 brand_runway_draft_images)。 -type BrandRunwayDraftImage struct { - ID uint32 `gorm:"primaryKey;column:id" json:"id"` - DraftID uint32 `gorm:"column:draft_id" json:"draft_id"` - Image string `gorm:"column:image" json:"image"` - Name string `gorm:"column:name" json:"name"` - SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` - IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` - CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` -} - -// TableName 指定草稿图片表名。 -func (BrandRunwayDraftImage) TableName() string { return "brand_runway_draft_images" } - -// 草稿状态常量。 -const ( - DraftStatusPending = "pending" - DraftStatusApproved = "approved" - DraftStatusRejected = "rejected" -) diff --git a/internal/model/runway_image.go b/internal/model/runway_image.go index fe35afa..0b537bc 100644 --- a/internal/model/runway_image.go +++ b/internal/model/runway_image.go @@ -11,6 +11,16 @@ type BrandRunwayImage struct { BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` Name string `gorm:"column:name" json:"name"` SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增 + IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图 + + // 主副图分组(列由 db/migrations/2026-09-24-01-runway-parent-image-id.sql 添加): + // 与 street_snap_images 同构——is_detail=0 为主图(parent_image_id=0); + // =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。 + ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` + + // 原图大小(字节):worker 下载图片时记录源文件字节数,供后台「图集原图总大小」展示。 + // 历史已入库图片为 0(未重爬、无源数据),后台按「—」处理;重爬后自动补齐。 + ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"` } // TableName 指定表名。 diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index 42bad2a..9ad19de 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -1,24 +1,30 @@ package model -// StreetSnap 街拍主表(对应 db_dev.street_snap)。 +// StreetSnap 街拍主表(对应 db_dev.street_snaps)。 // // 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。 -// 与 brand_runway 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。 +// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。 type StreetSnap struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` - Title string `gorm:"column:title" json:"title"` + Title string `gorm:"column:title" json:"title"` // 英文标题(既有源,改动前唯一标题列) + TitleCn string `gorm:"column:title_cn" json:"title_cn"` // 中文标题:后台人工翻译;为空时前端/接口回落英文 Year uint16 `gorm:"column:year" json:"year"` City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类 Cover string `gorm:"column:cover" json:"cover"` - SourceURL string `gorm:"column:source_url" json:"source_url"` ImageCount uint16 `gorm:"column:image_count" json:"image_count"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + + // 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。 + Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"` + JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` + Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` + + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` } // TableName 指定主表名。 -func (StreetSnap) TableName() string { return "street_snap" } +func (StreetSnap) TableName() string { return "street_snaps" } // StreetSnapImage 街拍图片表(对应 db_dev.street_snap_images)。 // @@ -32,6 +38,14 @@ type StreetSnapImage struct { IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + + // 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加): + // is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。 + IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"` + ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` + + // 原图大小(字节):同 BrandRunwayImage.ImageSize(见该字段注释)。 + ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"` } // TableName 指定图片明细表名。 diff --git a/internal/model/street_snap_draft.go b/internal/model/street_snap_draft.go deleted file mode 100644 index d90670d..0000000 --- a/internal/model/street_snap_draft.go +++ /dev/null @@ -1,43 +0,0 @@ -package model - -// StreetSnapDraft 街拍草稿(爬虫入库待审核)。 -// -// 与 brand_runway_draft 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置 -// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snap 正式表。 -// -// 字段对齐 street_snap 正式表(单 Title、有 City、无 brand_id、无 season 系列列), -// 外加审核专用字段,便于审核页直接预览/微调。 -type StreetSnapDraft struct { - ID uint32 `gorm:"primaryKey;column:id" json:"id"` - JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源) - Title string `gorm:"column:title" json:"title"` // 单标题(街拍无双语) - Year uint16 `gorm:"column:year" json:"year"` - City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类 - Cover string `gorm:"column:cover" json:"cover"` - SourceURL string `gorm:"column:source_url" json:"source_url"` - ImageCount uint16 `gorm:"column:image_count" json:"image_count"` - Status string `gorm:"column:status" json:"status"` // pending | approved | rejected - Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号) - RejectReason string `gorm:"column:reject_reason" json:"reject_reason"` - IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` - CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` -} - -// TableName 指定草稿主表名。 -func (StreetSnapDraft) TableName() string { return "street_snap_draft" } - -// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。 -type StreetSnapDraftImage struct { - ID uint32 `gorm:"primaryKey;column:id" json:"id"` - DraftID uint32 `gorm:"column:draft_id" json:"draft_id"` - Image string `gorm:"column:image" json:"image"` - Name string `gorm:"column:name" json:"name"` - SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` - IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` - CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` - UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` -} - -// TableName 指定草稿图片表名。 -func (StreetSnapDraftImage) TableName() string { return "street_snap_draft_images" } diff --git a/internal/model/user.go b/internal/model/user.go index 57f0c01..c0601b1 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -11,7 +11,7 @@ type User struct { Email string `gorm:"column:email;size:191;uniqueIndex" json:"email"` PasswordHash string `gorm:"column:password_hash;size:255" json:"-"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` - // Tier 用户等级:free(普通/未登录)/ vip(高级/VIP)。详情页据其决定返回普通还是高清图。 + // Tier 用户等级:free / vip。仅作账号标记(后台可提级 VIP);图片质量模型已统一,tier 不再影响图片 URL。 Tier string `gorm:"column:tier;size:16" json:"tier"` } diff --git a/internal/pkg/hashid/hashid.go b/internal/pkg/hashid/hashid.go index aced7c7..07072a0 100644 --- a/internal/pkg/hashid/hashid.go +++ b/internal/pkg/hashid/hashid.go @@ -50,7 +50,13 @@ func ensure() { // Feistel 网络的逆只需逆序执行轮函数,因此无论 round function 是否可逆都能精确还原。 // 加密轮:f 作用于右半块,左下一 = 右、右下一 = 左 ^ f(右)。 // 解密轮:f 作用于左半块(解密时左半块即上一轮的右半块),右下一 = 左、左下一 = 右 ^ f(左)。 +// 全局密钥版(品牌等无类型实体用);类型化编码见 feistelK。 func feistel(v uint32, encrypt bool) uint32 { + return feistelK(v, encrypt, keys) +} + +// feistelK 与 feistel 同构,但使用调用方提供的密钥(用于把实体类型揉进编码)。 +func feistelK(v uint32, encrypt bool, k [4]uint32) uint32 { const rounds = 8 l, r := uint16(v>>16), uint16(v&0xffff) for i := 0; i < rounds; i++ { @@ -60,7 +66,7 @@ func feistel(v uint32, encrypt bool) uint32 { } // round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位 round := func(h uint16) uint16 { - f := uint32(h)*0x9E3779B1 + keys[idx%4] + f := uint32(h)*0x9E3779B1 + k[idx%4] return uint16((f ^ (f >> 16)) & 0xffff) } if encrypt { @@ -76,8 +82,20 @@ func feistel(v uint32, encrypt bool) uint32 { return uint32(l)<<16 | uint32(r) } -func encodeNum(n uint32) string { - x := feistel(n, true) +// typeKeys 由全局混淆密钥与实体类型派生出 per-type 子密钥。 +// 因此同一数字主键在不同类型下得到完全不同的编码串,且不依赖首字母前缀来区分类型。 +// 仅作确定性混合(Feistel 自身用同密钥逆序还原,typeKeys 无需可逆)。 +func typeKeys(typ byte) [4]uint32 { + var k [4]uint32 + t := uint32(typ) * 0x9E3779B1 + for i := 0; i < 4; i++ { + k[i] = keys[i] ^ t ^ (uint32(typ) << (8*uint(i) + 1)) + } + return k +} + +// numToBase62 把 32-bit 值序列化为定长(minLen)base62 串,高位在前。 +func numToBase62(x uint32) string { var sb strings.Builder for x > 0 { sb.WriteByte(alphabet[x%base]) @@ -98,7 +116,8 @@ func encodeNum(n uint32) string { return out } -func decodeStr(s string) (uint32, error) { +// base62Parse 是 numToBase62 的逆;含非法字符返回 error。 +func base62Parse(s string) (uint32, error) { var x uint32 for _, c := range s { idx := strings.IndexRune(alphabet, c) @@ -107,9 +126,38 @@ func decodeStr(s string) (uint32, error) { } x = x*base + uint32(idx) } + return x, nil +} + +// encodeNum 用全局密钥编码(品牌等无类型实体)。 +func encodeNum(n uint32) string { + return numToBase62(feistel(n, true)) +} + +// decodeStr 用全局密钥解码(品牌等无类型实体)。 +func decodeStr(s string) (uint32, error) { + x, err := base62Parse(s) + if err != nil { + return 0, err + } return feistel(x, false), nil } +// encodeNumWithType 把类型揉进 Feistel 子密钥后编码,公开串不含类型字母, +// 且同类数字 id 在不同类型下完全不相关(runway#1 ≠ streetsnap#1)。 +func encodeNumWithType(n uint32, typ byte) string { + return numToBase62(feistelK(n, true, typeKeys(typ))) +} + +// decodeStrWithType 还原 encodeNumWithType,需传入与编码一致的 typ。 +func decodeStrWithType(s string, typ byte) (uint32, error) { + x, err := base62Parse(s) + if err != nil { + return 0, err + } + return feistelK(x, false, typeKeys(typ)), nil +} + // Encode 把数字主键编码为对外暴露的无序串。 func Encode(id uint32) string { ensure() @@ -125,11 +173,33 @@ func Decode(s string) (uint32, error) { return decodeStr(strings.TrimSpace(s)) } -// 类型前缀:让同一数字主键在不同实体下得到不同的编码串,前端 /item/[id] 可凭前缀区分实体类型, -// 从而把 runway / street snap 等详情统一收口到同一路由而无需担心 id 撞车。 +// EncodeWithType 把数字主键按实体类型编码为对外串:类型被揉进 Feistel 子密钥, +// 因此公开串既不含类型字母,同类数字 id 在不同类型下也完全不相关(runway#1 ≠ streetsnap#1), +// 外人无部署盐无法反解。前端按类型化路由(/runway-looks、/street-snaps)或存储 type 分流类型。 +// 这是新的公开编码方式,取代带前缀的 EncodeTyped。 +func EncodeWithType(id uint32, typ byte) string { + ensure() + return encodeNumWithType(id, typ) +} + +// DecodeWithType 还原 EncodeWithType 的串,需传入与编码一致的 typ(由路由/存储提供)。 +// 类型不符会解出错误主键,查库自然 404,从而强制按类型分流而非靠前缀嗅探。 +func DecodeWithType(s string, typ byte) (uint32, error) { + ensure() + if strings.TrimSpace(s) == "" { + return 0, errors.New("empty hashid") + } + return decodeStrWithType(strings.TrimSpace(s), typ) +} + +// 类型标签:r/s/i/j。两个用途: +// - 新编码 EncodeWithType(id, typ):作为 per-type 子密钥的种子揉进 Feistel(公开串无前缀)。 +// - 旧编码 EncodeTyped(typ, id):直接作为可见前缀拼在串前(仅 /item 301 与一次性迁移仍用)。 +// +// 前端按类型化路由(/runway-looks、/street-snaps)或存储 type 分流,后端按路由 typ 调 DecodeWithType。 const ( - TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表) - TypeSnap = 's' // 街拍(street_snap 表) + TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runways 表) + TypeSnap = 's' // 街拍(street_snaps 表) // 图片级前缀:单张图片也要有对外标识(图片收藏用)。 // 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串, @@ -138,15 +208,15 @@ const ( TypeSnapImage = 'j' // 街拍单图(street_snap_images 表) ) -// EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表, -// 不参与 Feistel 还原、仅作类型标识。DecodeTyped 负责剥离前缀并还原数字主键。 +// EncodeTyped 旧编码:在串前拼一个类型字符前缀(如 "r"+base62 串)。仅 /item 301 与一次性 +// 迁移工具使用;新公开编码请用 EncodeWithType(无前缀、类型进密码)。 func EncodeTyped(t byte, id uint32) string { ensure() return string(t) + encodeNum(id) } -// DecodeTyped 还原带类型前缀的编码串,返回 (类型字符, 数字主键, error)。 -// 调用方通常忽略类型(路由已确定实体),只取数字主键查库;旧的无前缀串仍用 Decode。 +// DecodeTyped 还原旧带前缀编码串,返回 (类型字符, 数字主键, error)。仅 /item 301 与迁移工具使用; +// 新公开解码请用 DecodeWithType(raw, typ)(需路由/存储提供的 typ)。 func DecodeTyped(s string) (byte, uint32, error) { s = strings.TrimSpace(s) if len(s) < 2 { diff --git a/internal/pkg/hashid/hashid_test.go b/internal/pkg/hashid/hashid_test.go index 4c9d5f3..e14153f 100644 --- a/internal/pkg/hashid/hashid_test.go +++ b/internal/pkg/hashid/hashid_test.go @@ -73,3 +73,39 @@ func TestTyped(t *testing.T) { t.Fatal("长度不足 2 的 typed 串应返回错误") } } + +// TestTypedWithType 验证新编码(类型进密码、无前缀): +// - 往返严格成立 DecodeWithType(EncodeWithType(n, typ), typ) == n; +// - 同数字不同类别得到完全不相关的串(runway#5 ≠ snap#5),且串本身不含类型字母; +// - 用错误 typ 解码得到错误主键(≠ n),从而查库自然 404。 +func TestTypedWithType(t *testing.T) { + Init("test-salt") + + // 往返 + for _, typ := range []byte{TypeRunway, TypeSnap, TypeRunwayImage, TypeSnapImage} { + for _, n := range []uint32{0, 1, 5, 999, 1 << 20, (1 << 32) - 1} { + got, err := DecodeWithType(EncodeWithType(n, typ), typ) + if err != nil || got != n { + t.Fatalf("往返不一致: typ=%c n=%d got=%d err=%v", typ, n, got, err) + } + } + } + + // 同数字不同类别:串不同且不相关,且都不含类型字母 + r := EncodeWithType(5, TypeRunway) + s := EncodeWithType(5, TypeSnap) + if r == s { + t.Fatalf("同数字不同类别应得到不同串: runway=%q snap=%q", r, s) + } + if r[0] == TypeRunway || r[0] == TypeSnap || s[0] == TypeRunway || s[0] == TypeSnap { + t.Fatalf("新编码不应含类型字母前缀: runway=%q snap=%q", r, s) + } + // 用错误类别解码得不到原主键 + if wrong, err := DecodeWithType(r, TypeSnap); err != nil || wrong == 5 { + t.Fatalf("用错误类别解码应得不到原主键: got=%d err=%v", wrong, err) + } + // 空串报错 + if _, err := DecodeWithType("", TypeRunway); err == nil { + t.Fatal("空串应返回错误") + } +} diff --git a/internal/pkg/imgurl/imgurl.go b/internal/pkg/imgurl/imgurl.go index 3c971ee..ce9d879 100644 --- a/internal/pkg/imgurl/imgurl.go +++ b/internal/pkg/imgurl/imgurl.go @@ -1,109 +1,71 @@ // Package imgurl 把数据库里存储的图片存储值拼装为对外可访问的完整 URL。 // -// 存储约定(见迁移 007):库里只存「七牛 key」(如 runway/abc.jpg)或「本地相对路径」 +// 存储约定(见迁移 007):库里只存「对象 key」(如 runway/abc.jpg)或「本地相对路径」 // (如 /uploads/runway/x.jpg),绝不存完整 URL。这样做两个好处: // 1. 换 CDN 域名只改配置,无需迁移全表; // 2. 做「普通 / 高清」分级时无需从 URL 反向解析 key。 // -// 分级与安全(付费墙): -// - 高清(hd=true,仅 VIP):私有空间下载签名 URL(base_url/key-StyleVip,如 key-vip),限时有效; -// 免费/未登录用户拿不到 VIP 样式 key,即使从别处得到 key,因无签名也会被七牛拒绝(403)。 -// - 普通(hd=false,所有人):base_url/key-StyleNormal(如 key-free)的私有空间签名 URL,长过期(供 SSG 静态页与 CDN 缓存)。 +// 质量模型(2026-09-11 调整):VIP 与免费用户现在看到同一质量,高清样式名为 `high`, +// 由缤纷云 CoreIX 公开样式提供(base_url/key!style:high),匿名可访问、免 token。 +// 旧「免费低清 / VIP 原图预签名」付费墙已取消,故 Composer 不再区分 tier。 +// - 列表缩略图:另走 `thumb` 公开样式(ComposeThumb),仅用于列表/卡片等小尺寸场景,省流量。 // - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host)。 -// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套七牛样式,签名无意义)。 -// -// 七牛私有空间下载签名:对 "base_url/key?e=" 做 HMAC-SHA1(SK),urlsafe base64 后拼 -// "AK:sign" 作为 token。签名覆盖完整 path(含 -StyleNormal / -StyleVip 后缀),七牛校验通过才返回资源。 -// 前置:bucket 须在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名。 +// - 外链 http(s):原样返回(历史遗留 / vogue 等,无法套样式)。 package imgurl import ( - "crypto/hmac" - "crypto/sha1" - "encoding/base64" "fmt" "strings" - "time" ) // Composer 图片 URL 拼装器。 type Composer struct { - // BaseURL 七牛 bucket 绑定域名,如 https://cdn.toom-studio.com(末尾无斜杠)。 + // BaseURL 对外访问域名,如 https://toomstudio.s3.bitiful.net(末尾无斜杠)。 BaseURL string - // StyleNormal 普通图七牛命名样式名(对应七牛控制台创建的样式),如 "free"。 - // 访问即 base_url/key-StyleNormal。务必与控制台样式名一致(如 free 或 free.webp)。 - StyleNormal string - // StyleVip VIP 高清七牛命名样式名(对应七牛控制台创建的样式),如 "vip"。 - // 访问即 base_url/key-StyleVip。VIP 用户走此样式;务必与控制台样式名一致。 - StyleVip string - // LocalBase 本地兜底图片前缀;通常空——本地 /uploads 路径由前端 toAbs 拼 host。 - LocalBase string - // AK/SK 七牛 AccessKey/SecretKey,用于私有空间下载签名。未配置时回落为公开 URL。 - AK string - SK string - // SignTTLNormal 普通/压缩图(含命名样式 -free)签名过期秒;长过期供 SSG 静态页与 CDN 缓存。 - SignTTLNormal int64 - // SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。 - SignTTLHD int64 + // StyleDisplay 全量展示图 CoreIX 样式名(如 "high",对应控制台建的公开样式); + // 若值含 "=" 则视为查询串(如 "w=1080&q=80&fmt=webp"),拼成 base_url/key?查询串。 + StyleDisplay string + // StyleThumb 列表缩略图 CoreIX 样式名(如 "thumb");仅用于列表/卡片等小尺寸场景。 + // 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致)。 + StyleThumb string } -// New 构造 Composer。styleNormal 缺省回落 "free";styleVip 缺省回落 "vip";两个 TTL 缺省回落 31536000 / 3600。 -func New(baseURL, styleNormal, styleVip, localBase, ak, sk string, ttlNormal, ttlHD int64) *Composer { - if styleNormal == "" { - styleNormal = "free" +// New 构造 Composer。 +// - styleDisplay 缺省回落 "high"(高清公开样式,VIP 与免费用户同质量); +// - styleThumb 缺省回落 styleDisplay(未单独配置缩略图样式时,列表与详情同图)。 +func New(baseURL, styleDisplay, styleThumb string) *Composer { + if styleDisplay == "" { + styleDisplay = "high" } - if styleVip == "" { - styleVip = "vip" - } - if ttlNormal <= 0 { - ttlNormal = 31536000 - } - if ttlHD <= 0 { - ttlHD = 3600 + if styleThumb == "" { + styleThumb = styleDisplay } return &Composer{ - BaseURL: strings.TrimRight(baseURL, "/"), - StyleNormal: styleNormal, - StyleVip: styleVip, - LocalBase: strings.TrimRight(localBase, "/"), - AK: ak, - SK: sk, - SignTTLNormal: ttlNormal, - SignTTLHD: ttlHD, + BaseURL: strings.TrimRight(baseURL, "/"), + StyleDisplay: styleDisplay, + StyleThumb: styleThumb, } } -// sign 生成七牛私有空间下载签名 URL。 -// -// rawKey 是待访问的对象路径(可能含 -StyleNormal 后缀)。签名字符串为 -// "base_url/rawKey?e=",对其做 HMAC-SHA1(SK) 后 urlsafe base64 得到 token。 -// -// 无 AK/SK 或 BaseURL 时回落为公开 URL(兼容未配置七牛的开发态:公开空间会忽略 token 直接返回)。 -func (c *Composer) sign(rawKey string, ttl int64) string { - if c.BaseURL == "" { - return rawKey - } - if c.AK == "" || c.SK == "" { - return c.BaseURL + "/" + rawKey - } - deadline := time.Now().Unix() + ttl - toSign := fmt.Sprintf("%s/%s?e=%d", c.BaseURL, rawKey, deadline) - mac := hmac.New(sha1.New, []byte(c.SK)) - mac.Write([]byte(toSign)) - enc := base64.URLEncoding.EncodeToString(mac.Sum(nil)) - return fmt.Sprintf("%s&token=%s:%s", toSign, c.AK, enc) -} - -// Compose 把存储值拼成可访问 URL。 +// Compose 把存储值拼成可访问 URL(详情/大图场景,统一走 StyleDisplay)。 // // - stored 为空:返回 "" // - 本地兜底 /uploads/*:原样返回(前端 toAbs 拼 host) -// - 已是完整 http(s) URL(历史遗留 / 外链图,如 vogue):hd 原样返回; -// normal 尝试从 base_url 还原 key 再套样式签名,还原失败原样返回(外链无法套样式) -// - 七牛 key:hd → 原图签名 URL(短过期);normal → 普通图(命名样式)签名 URL(长过期) -// -// 这样免费/未登录用户永远拿不到「不带签名的原图 URL」;VIP 拿到的是限时签名原图 URL。 -func (c *Composer) Compose(stored string, hd bool) string { +// - 已是完整 http(s) URL(历史遗留 / 外链图):原样返回(无法套样式) +// - key:base_url/key!style:high(或查询串模式) +func (c *Composer) Compose(stored string) string { + return c.compose(stored, c.StyleDisplay) +} + +// ComposeThumb 把存储值拼成「列表缩略图」URL(卡片 / 列表封面 / 图条等小尺寸场景)。 +// 与 Compose 共用同一套兜底逻辑,仅样式名换成 StyleThumb(未配置时回落 StyleDisplay)。 +// 注意:仅适用于同一 base_url 下的对象 key;外链与 /uploads 路径不做处理(无法套样式)。 +func (c *Composer) ComposeThumb(stored string) string { + return c.compose(stored, c.StyleThumb) +} + +// compose 应用指定样式拼装 URL。 +func (c *Composer) compose(stored, style string) string { stored = strings.TrimSpace(stored) if stored == "" { return "" @@ -112,22 +74,22 @@ func (c *Composer) Compose(stored string, hd bool) string { if strings.HasPrefix(stored, "/uploads/") { return stored } - // 已是完整 URL(历史遗留 / 外链图)。 + // 已是完整 URL(历史遗留 / 外链图):原样返回(无法套样式)。 if strings.HasPrefix(stored, "http://") || strings.HasPrefix(stored, "https://") { - if hd { - return stored - } - // 同域七牛 URL:剥掉 base_url 还原 key,再套普通样式签名。 - if c.BaseURL != "" { - if key, ok := strings.CutPrefix(stored, c.BaseURL+"/"); ok { - return c.sign(key+"-"+c.StyleNormal, c.SignTTLNormal) - } - } - return stored // 外链图无法套样式,原样返回 + return stored } - // 七牛 key:VIP 高清(-StyleVip 命名样式)签名(短过期);免费图(-StyleNormal 命名样式)签名(长过期)。 - if hd { - return c.sign(stored+"-"+c.StyleVip, c.SignTTLHD) - } - return c.sign(stored+"-"+c.StyleNormal, c.SignTTLNormal) + // 对象 key:按指定样式拼装(命名样式 key!style:xxx 或查询串 key?w=..&q=..)。 + return c.styledURL(stored, style) +} + +// styledURL 按样式名生成地址:命名样式(key!style:high)或查询串(key?w=..&q=..)两种模式。 +func (c *Composer) styledURL(key, style string) string { + if strings.Contains(style, "=") { + sep := "?" + if strings.Contains(style, "?") { + sep = "&" + } + return fmt.Sprintf("%s/%s%s%s", c.BaseURL, key, sep, style) + } + return fmt.Sprintf("%s/%s!style:%s", c.BaseURL, key, style) } diff --git a/internal/pkg/imgurl/vip_check_test.go b/internal/pkg/imgurl/vip_check_test.go index 8338e53..5b35a07 100644 --- a/internal/pkg/imgurl/vip_check_test.go +++ b/internal/pkg/imgurl/vip_check_test.go @@ -5,29 +5,78 @@ import ( "testing" ) -// TestVipStyle 验证:免费(free)用 -free 命名样式、VIP 用 -vip 命名样式,且均带私有空间签名。 -func TestVipStyle(t *testing.T) { - c := New("http://toom-studio.23cm.cn", "free", "vip", "", "AK_TEST", "SK_TEST", 31536000, 3600) +// TestDisplayStyle 验证新模型(2026-09-11):VIP 与免费用户同质量,统一走展示样式。 +// - 命名样式(默认 high):base_url/key!style:high +// - 查询串模式(style 含 =):base_url/key?w=..&q=.. +// - /uploads/ 与 http(s) 原样返回 +func TestDisplayStyle(t *testing.T) { + c := New("https://mybucket.s3.bitiful.net", "high", "thumb") + got := c.Compose("runway/abc123.jpg") + want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" + if got != want { + t.Fatalf("命名样式拼装错误: 期望 %s 实际 %s", want, got) + } - free := c.Compose("runway/abc123.jpg", false) - vip := c.Compose("runway/abc123.jpg", true) + // 缺省回落 high + c0 := New("https://mybucket.s3.bitiful.net", "", "") + if c0.Compose("runway/abc123.jpg") != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" { + t.Fatal("style 缺省应回落 high") + } - t.Logf("free URL = %s", free) - t.Logf("vip URL = %s", vip) + // 查询串模式 + cq := New("https://mybucket.s3.bitiful.net", "w=1080&q=80&fmt=webp", "") + gq := cq.Compose("runway/abc123.jpg") + wq := "https://mybucket.s3.bitiful.net/runway/abc123.jpg?w=1080&q=80&fmt=webp" + if gq != wq { + t.Fatalf("查询串模式拼装错误: 期望 %s 实际 %s", wq, gq) + } - if free == "" || vip == "" { - t.Fatal("URL 不应为空") + // 本地兜底与外链原样返回 + if c.Compose("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" { + t.Fatal("/uploads/ 应原样返回") } - if !strings.Contains(free, "-free") { - t.Fatalf("免费图应拼 -free 命名样式,实际: %s", free) + if c.Compose("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" { + t.Fatal("http(s) 应原样返回") } - if !strings.Contains(vip, "-vip") { - t.Fatalf("VIP 图应拼 -vip 命名样式,实际: %s", vip) - } - if !strings.Contains(free, "token=") || !strings.Contains(vip, "token=") { - t.Fatal("两种图都必须带私有空间下载签名 token=") - } - if strings.Contains(vip, "-free") { - t.Fatal("VIP 图不应混入 -free") + if c.Compose("") != "" { + t.Fatal("空串应返回空") + } +} + +// TestComposeThumb 验证列表缩略图拼装:走 StyleThumb,兜底逻辑与 Compose 一致。 +func TestComposeThumb(t *testing.T) { + c := New("https://mybucket.s3.bitiful.net", "high", "thumb") + got := c.ComposeThumb("runway/abc123.jpg") + want := "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:thumb" + if got != want { + t.Fatalf("缩略图样式拼装错误: 期望 %s 实际 %s", want, got) + } + + // 未配置 styleThumb 时应回落展示样式,不得拼出空样式(key!style:) + c0 := New("https://mybucket.s3.bitiful.net", "high", "") + if got := c0.ComposeThumb("runway/abc123.jpg"); got != "https://mybucket.s3.bitiful.net/runway/abc123.jpg!style:high" { + t.Fatalf("styleThumb 缺省应回落 styleDisplay,实际 %s", got) + } + + // 兜底:本地路径 / 外链 / 空串 + if c.ComposeThumb("/uploads/runway/x.jpg") != "/uploads/runway/x.jpg" { + t.Fatal("/uploads/ 应原样返回") + } + if c.ComposeThumb("https://vogue.com/foo.jpg") != "https://vogue.com/foo.jpg" { + t.Fatal("http(s) 应原样返回") + } + if c.ComposeThumb("") != "" { + t.Fatal("空串应返回空") + } +} + +// TestDisplayStyleURLSafe 确认展示 URL 不含任何签名/过期参数(匿名可访问)。 +func TestDisplayStyleURLSafe(t *testing.T) { + c := New("https://mybucket.s3.bitiful.net", "high", "thumb") + u := c.Compose("runway/abc123.jpg") + for _, bad := range []string{"X-Amz", "sign", "e="} { + if strings.Contains(u, bad) { + t.Fatalf("展示 URL 不应含签名参数 %q,实际: %s", bad, u) + } } } diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go index 4ee710e..d52d084 100644 --- a/internal/pkg/jwt/jwt.go +++ b/internal/pkg/jwt/jwt.go @@ -19,7 +19,7 @@ type Claims struct { UserID uint32 Username string Email string - // Tier 用户等级 free/vip;详情页据其拼装图片 URL(普通/高清)。 + // Tier 用户等级 free/vip;仅作账号标记(图片质量模型已统一,不再据此拼装 URL)。 Tier string } @@ -41,11 +41,6 @@ func NewManager(secret string, expireHours int) *Manager { } } -// 注:Generate(签发令牌)已随注册 / 登录接口一并移除——当前服务没有任何签发入口, -// 只保留 Parse 供 middleware.Auth 校验既有令牌。 -// 将来接入登录时恢复:构造 jwtlib.MapClaims{"uid","username","email","exp"}, -// 再用 jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) 签名即可。 - // Generate 签发令牌(登录成功后调用)。 // // 构造与 Parse 对称的 Claims(uid / username / email / tier / exp),用 HS256 签名。 diff --git a/internal/pkg/pagination/pagination.go b/internal/pkg/pagination/pagination.go new file mode 100644 index 0000000..ab42fa4 --- /dev/null +++ b/internal/pkg/pagination/pagination.go @@ -0,0 +1,153 @@ +// Package pagination 提供后端分页的通用封装:查询参数解析、分页元数据计算、SQL 偏移量, +// 以及后台 HTML 模板可复用的翻页链接构造。 +// +// 目标:消除各 handler 里重复的「page/size 解析 + 上/下一页计算 + 越界钳制」样板, +// 同时保证对外协议(JSON 字段名、HTML 展示文案)完全不变。 +package pagination + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// Params 是一页请求的规范化参数(page 已钳制到 >=1;size 已钳制到 [1, maxSize])。 +type Params struct { + Page int + PageSize int +} + +// Parse 从 gin 查询串读取并规范化分页参数。 +// +// pageKey/sizeKey 为查询参数名;sizeKey 为空表示不读取条数(固定用 defSize,不校验上限)。 +// 解析规则(与既有后台列表页、history/favorite 接口保持一致): +// - page 缺失或非数字 → defPage;<1 → defPage +// - size 缺失或非数字 → defSize;<1 → defSize;>maxSize → maxSize +func Parse(c *gin.Context, pageKey, sizeKey string, defPage, defSize, maxSize int) Params { + page := atoiDefault(c.Query(pageKey), defPage) + if page < 1 { + page = defPage + } + size := defSize + if sizeKey != "" { + size = atoiDefault(c.Query(sizeKey), defSize) + if size < 1 { + size = defSize + } + if maxSize > 0 && size > maxSize { + size = maxSize + } + } + return Params{Page: page, PageSize: size} +} + +// Offset 返回 GORM 翻页用的 SQL 偏移量:(page-1)*size。 +func (p Params) Offset() int { return (p.Page - 1) * p.PageSize } + +// Meta 是分页元数据(总页数、上/下一页、是否可翻)。 +// +// NewMeta 不会修改传入的 Page(与既有 brands/runways/street/reviews 行为一致:越界页码 +// 照常透传给数据库,返回空列表,仅 HasNext 据此计算)。需要越界钳制显示(ingest-jobs) +// 的调用方拿到 Meta 后调用 Clamp。 +type Meta struct { + Page int + PageSize int + Total int64 + TotalPages int + PrevPage int + NextPage int + HasPrev bool + HasNext bool +} + +// NewMeta 计算分页元数据。 +// +// HasNext 等价于「page < 总页数」,与原有 `int64(page*size) < total` 在 total>0 时一致; +// total==0 时总页数记为 1,HasNext 恒为 false(与原逻辑相同)。 +func (p Params) NewMeta(total int64) Meta { + totalPages := LastPage(total, p.PageSize) + m := Meta{ + Page: p.Page, + PageSize: p.PageSize, + Total: total, + TotalPages: totalPages, + } + if p.Page > 1 { + m.PrevPage = p.Page - 1 + m.HasPrev = true + } + if p.Page < totalPages { + m.NextPage = p.Page + 1 + m.HasNext = true + } + return m +} + +// Clamp 把页码钳制到 [1, TotalPages],并重新计算 Prev/Next。 +// 用于「越界页码应展示最后一页」的场景(ingest-jobs),不改变数据库查询用的原始 offset。 +func (m *Meta) Clamp() { + if m.TotalPages > 0 && m.Page > m.TotalPages { + m.Page = m.TotalPages + m.HasPrev = m.Page > 1 + m.HasNext = false + m.PrevPage = 0 + m.NextPage = 0 + if m.HasPrev { + m.PrevPage = m.Page - 1 + } + } +} + +// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。 +func LastPage(total int64, size int) int { + if size <= 0 { + return 1 + } + last := int(total) / size + if int(total)%size > 0 { + last++ + } + if last == 0 { + last = 1 + } + return last +} + +// Pager 是后台 HTML 模板复用的翻页视图。 +// +// 由 handler 用 PageLink/PageLinkWith 生成 PrevURL/NextURL 后塞进模板数据, +// 模板只需写一行 {{template "pager" .Pager}}。 +type Pager struct { + PrevURL string + NextURL string + HasPrev bool + HasNext bool + Label string // 页码信息文案(如「第 3 页 · 共 120 条」),可为空 +} + +// PageLink 基于当前请求构造仅替换 page 的翻页链接,保留其余查询参数。 +// 用于 brands/runways/street/reviews 等(URL 路径本身已含 kind 段,无需额外拼接)。 +func PageLink(c *gin.Context, page int) string { + q := c.Request.URL.Query() + q.Set("page", strconv.Itoa(page)) + return c.Request.URL.Path + "?" + q.Encode() +} + +// PageLinkWith 除替换 page 外,额外固定一个参数(如 ingest-jobs 的 pageSize)。 +func PageLinkWith(c *gin.Context, page int, extraKey string, extraVal int) string { + q := c.Request.URL.Query() + q.Set("page", strconv.Itoa(page)) + q.Set(extraKey, strconv.Itoa(extraVal)) + return c.Request.URL.Path + "?" + q.Encode() +} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go index 51d5c32..8db969f 100644 --- a/internal/pkg/response/response.go +++ b/internal/pkg/response/response.go @@ -1,14 +1,21 @@ // Package response 统一 HTTP 响应结构。 // -// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名: -// - 列表:{ data, last_page } // last_page 供前端真分页(页码导航)使用;total/per_page/current_page 前端无消费点,已移除以精简 -// - 详情:{ data } +// 响应形状严格沿用原项目约定,前端依赖这些字段名: +// - 公开列表(SSG):Page 返回 { data, last_page },last_page 供前端页码导航; +// 运行期不回传 total / current_page / per_page(这些由 SSG 在构建期消费),故此处精简。 +// - 「我的资源」列表(history/favorite):经 Data 返回 { items, total, last_page }, +// total 供前端显示总数(如「共 N 条」)。 +// - 详情:{ data }(可选 preview / image_total 门禁标记,见 DataPreview) // - 错误:{ error } // // 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。 package response -import "github.com/gin-gonic/gin" +import ( + "github.com/gin-gonic/gin" + + "fashionapi/internal/pkg/pagination" +) // Data 返回单个对象:{ "data": v }。 func Data(c *gin.Context, status int, v any) { @@ -30,10 +37,11 @@ func DataPreview(c *gin.Context, status int, v any, preview bool, imageTotal int c.JSON(status, m) } -// Page 返回分页列表:{ data, last_page }。 +// Page 返回分页列表:{ data, last_page },用于 SSG 公开列表(前端仅消费 data 与 last_page)。 // -// 只保留前端真正消费的字段:data(本页数据)与 last_page(总页数,供页码导航)。 -// total / current_page / per_page 前端从无读取,移除以精简协议;last_page 仍由后端用 total 与固定 size 计算。 +// 运行期不回传 total / current_page / per_page——这些由 SSG 在构建期消费,调用方无需关心; +// last_page 由后端用 total 与固定 size 计算。 +// 注意:history/favorite 这类「我的资源」列表需返回 total,请用 response.Data 自行构造。 func Page(c *gin.Context, status int, data any, total int64, size int) { c.JSON(status, gin.H{ "data": data, @@ -41,21 +49,6 @@ func Page(c *gin.Context, status int, data any, total int64, size int) { }) } -// PagePreview 同 Page,但可附加 preview 标记(未登录截断预览时 preview=true)。 -// -// 列表接口对未登录请求只返回预览上限(PreviewLimit)条数据,并打 preview=true, -// 前端据此弹出「登录查看全部」门禁;已登录或总量本就不超上限时不打标记。 -func PagePreview(c *gin.Context, status int, data any, total int64, size int, preview bool) { - m := gin.H{ - "data": data, - "last_page": LastPage(total, size), - } - if preview { - m["preview"] = true - } - c.JSON(status, m) -} - // Error 返回错误:{ "error": msg }。 func Error(c *gin.Context, status int, msg string) { c.JSON(status, gin.H{"error": msg}) @@ -67,16 +60,7 @@ func AbortError(c *gin.Context, status int, msg string) { } // LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。 +// 实现委托给 pagination.LastPage,避免分页数学在多处重复。 func LastPage(total int64, size int) int { - if size <= 0 { - return 1 - } - last := int(total) / size - if int(total)%size > 0 { - last++ - } - if last == 0 { - last = 1 - } - return last + return pagination.LastPage(total, size) } diff --git a/internal/pkg/season/season.go b/internal/pkg/season/season.go index ff4bbbc..f317b4e 100644 --- a/internal/pkg/season/season.go +++ b/internal/pkg/season/season.go @@ -1,8 +1,9 @@ // Package season 走秀季节 / 年份 → season_code 的推导与解析辅助。 // // season_code 是对外与排序共用的紧凑编码:SS26 / FW25 / RES26 / PF25, -// 规则:前缀由 collection_type(rtw/menswear→SS/FW;resort→RES;pre_fall→PF) -// 或 season(spring→SS / fall→FW)决定,后缀为两位年份。 +// 规则:前缀由 collection_type 决定(resort→RES;pre_fall→PF;couture→FW; +// menswear→FW);rtw 无独立前缀,落入 season 分支(spring→SS / fall→FW), +// 后缀为两位年份。 // 历史代码 vogue.go 漏填 season_code 是已知 bug,这里集中推导逻辑,worker 与回填脚本复用。 package season @@ -46,8 +47,6 @@ func prefixOf(collectionType, season string) string { return "FW" case ColMenswear: return "FW" - case ColRTW: - return "SS" } switch season { case SeasonFall: diff --git a/internal/pkg/storage/qiniu.go b/internal/pkg/storage/qiniu.go deleted file mode 100644 index a78a7a4..0000000 --- a/internal/pkg/storage/qiniu.go +++ /dev/null @@ -1,196 +0,0 @@ -package storage - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha1" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "path" - "strings" - "time" -) - -// QiniuUploader 通过七牛云表单直传(标准库实现,无第三方 SDK 依赖)。 -// -// 上传凭证算法(与官方 SDK 一致): -// -// putPolicy = {"scope":bucket,"deadline":unix+3600,"insertOnly":1} -// encPolicy = base64.URLEncoding(putPolicy) // urlsafe,保留 '=' 填充 -// sign = HMAC-SHA1(SK, encPolicy) -// encSign = base64.URLEncoding(sign) // 同样保留填充 -// token = AK + ":" + encSign + ":" + encPolicy -// 注意:必须用 base64.URLEncoding(带 '=' 填充),不能用 RawURLEncoding,否则服务端报 "bad token"。 -// -// 然后以 multipart/form-data POST 到对应区域的上传域名,表单含 token / key / file。 -type QiniuUploader struct { - ak, sk, bucket, baseURL string - upHost string - client *http.Client -} - -// NewQiniuUploader 构造七牛上传器。 -// - zone: z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡 -// - baseURL: bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL; -// 为空时仅返回对象 key(调用方需另行拼接域名)。 -func NewQiniuUploader(ak, sk, bucket, zone, baseURL string) *QiniuUploader { - return &QiniuUploader{ - ak: ak, - sk: sk, - bucket: bucket, - baseURL: strings.TrimRight(baseURL, "/"), - upHost: upHostByZone(zone), - client: &http.Client{Timeout: 60 * time.Second}, - } -} - -// upHostByZone 把区域映射为七牛上传域名。 -func upHostByZone(zone string) string { - switch zone { - case "z0": - return "https://up-z0.qiniup.com" - case "z1": - return "https://up-z1.qiniup.com" - case "z2": - return "https://up-z2.qiniup.com" - case "na0": - return "https://up-na0.qiniup.com" - case "as0": - return "https://up-as0.qiniup.com" - default: - return "https://up-z1.qiniup.com" - } -} - -// Enabled 任一凭证缺失即视为未启用。 -func (q *QiniuUploader) Enabled() bool { - return q.ak != "" && q.sk != "" && q.bucket != "" -} - -// makeToken 生成七牛上传凭证。 -func (q *QiniuUploader) makeToken() (string, error) { - putPolicy := map[string]any{ - "scope": q.bucket, - "deadline": time.Now().Add(time.Hour).Unix(), - "insertOnly": 1, - } - b, err := json.Marshal(putPolicy) - if err != nil { - return "", err - } - // 注意:七牛 token 使用 urlsafe base64 但保留 '=' 填充(base64.URLEncoding), - // 与官方 SDK 一致;不能用 RawURLEncoding,否则服务端校验签名失败报 "bad token"。 - encPolicy := base64.URLEncoding.EncodeToString(b) - mac := hmac.New(sha1.New, []byte(q.sk)) - mac.Write([]byte(encPolicy)) - encSign := base64.URLEncoding.EncodeToString(mac.Sum(nil)) - return q.ak + ":" + encSign + ":" + encPolicy, nil -} - -// Upload 把 data 直传到七牛 bucket 的 key,返回可访问 URL。 -func (q *QiniuUploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { - token, err := q.makeToken() - if err != nil { - return "", err - } - - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - if err := mw.WriteField("token", token); err != nil { - return "", err - } - if err := mw.WriteField("key", key); err != nil { - return "", err - } - fw, err := mw.CreateFormFile("file", path.Base(key)) - if err != nil { - return "", err - } - if _, err := fw.Write(data); err != nil { - return "", err - } - if err := mw.Close(); err != nil { - return "", err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.upHost, &buf) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", mw.FormDataContentType()) - - resp, err := q.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("qiniu upload failed: status=%d body=%s", resp.StatusCode, string(body)) - } - - // 成功返回 {"hash":"...","key":"..."};失败返回 {"error":"..."} - var out struct { - Key string `json:"key"` - Error string `json:"error"` - } - if err := json.Unmarshal(body, &out); err == nil && out.Error != "" { - return "", fmt.Errorf("qiniu error: %s", out.Error) - } - - if q.baseURL != "" { - return q.baseURL + "/" + strings.TrimLeft(key, "/"), nil - } - return key, nil -} - -// rsHost 七牛管理 API 域名(全局,不分区域)。删除对象走此端点。 -const rsHost = "rs.qiniu.com" - -// Delete 删除七牛 bucket 中的对象(按 key)。仅当引用计数归零时由调用方触发, -// 避免误删仍被其他图集引用的文件(七牛 key 为 sha1 内容寻址,同名图跨图集共享 key)。 -// -// 管理凭证(QBox 方案,与官方 SDK 一致): -// -// entry = base64.URLEncoding(bucket + ":" + key) // 带 '=' 填充 -// path = "/delete/" + entry -// signing = path + "\n" // 仅 Path + 换行,不含 Method/Host/Content-Type -// sign = HMAC-SHA1(SK, signing) -// token = AK + ":" + base64.URLEncoding(sign) -// Authorization: QBox // 注意前缀是 QBox(下载凭证才是 Qiniu) -func (q *QiniuUploader) Delete(ctx context.Context, key string) error { - if !q.Enabled() { - return nil - } - // 七牛管理凭证(QBox)签名串仅为 Path + "\n",前缀 "QBox";与下载凭证(imgurl.sign,前缀 "Qiniu")不同。 - entry := base64.URLEncoding.EncodeToString([]byte(q.bucket + ":" + key)) - path := "/delete/" + entry - signing := path + "\n" - mac := hmac.New(sha1.New, []byte(q.sk)) - mac.Write([]byte(signing)) - token := q.ak + ":" + base64.URLEncoding.EncodeToString(mac.Sum(nil)) - - url := "https://" + rsHost + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "QBox "+token) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("qiniu delete failed: status=%d body=%s", resp.StatusCode, string(body)) - } - return nil -} diff --git a/internal/pkg/storage/qiniu_purge_verify_test.go b/internal/pkg/storage/qiniu_purge_verify_test.go deleted file mode 100644 index ebef93f..0000000 --- a/internal/pkg/storage/qiniu_purge_verify_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - "fashionapi/internal/config" -) - -// TestQiniuPurgeRoundTrip 真实验证七牛删除能力(管理凭证签名是否正确): -// 上传一个临时 key → 删除(期望 200)→ 再删一次(期望 612 资源不存在,证明已被删)。 -// 若管理 token 签名错误,七牛会返回 401(invalid token),测试即失败。 -// 仅在七牛启用且能连通时运行;否则跳过。 -func TestQiniuPurgeRoundTrip(t *testing.T) { - cfg, err := config.Load("../../../configs/config.yml") - if err != nil { - t.Skipf("加载配置失败,跳过: %v", err) - } - if !cfg.Qiniu.Enabled { - t.Skip("七牛未启用,跳过删除验证") - } - - up := NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "") - key := fmt.Sprintf("runway/_purge_verify_%d.bin", time.Now().UnixNano()) - - ctx := context.Background() - // 1) 上传临时对象(任意字节即可,七牛不校验内容格式) - if _, err := up.Upload(ctx, key, []byte("purge-verify-test-content"), "application/octet-stream"); err != nil { - t.Fatalf("上传临时对象失败(无法继续验证删除): %v", err) - } - t.Logf("已上传临时 key=%s", key) - - // 2) 删除(期望 200 / nil) - if err := up.Delete(ctx, key); err != nil { - // 401 表示管理 token 签名错误,是本次验证的核心目标 - if strings.Contains(err.Error(), "401") { - t.Fatalf("七牛删除返回 401,管理凭证签名错误: %v", err) - } - t.Fatalf("首次删除失败: %v", err) - } - t.Logf("首次删除成功(200),key=%s", key) - - // 3) 再删一次(期望 612 资源不存在,证明确实被删除) - if err := up.Delete(ctx, key); err != nil { - if !strings.Contains(err.Error(), "612") { - t.Fatalf("二次删除期望 612(资源不存在),实际: %v", err) - } - t.Logf("二次删除返回 612,确认对象已删除: %s", key) - } else { - t.Fatalf("二次删除应返回 612(资源不存在),却返回 200,说明首次删除未生效") - } -} diff --git a/internal/pkg/storage/s4.go b/internal/pkg/storage/s4.go new file mode 100644 index 0000000..4a7b1ba --- /dev/null +++ b/internal/pkg/storage/s4.go @@ -0,0 +1,121 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// s4DefaultEndpoint 缤纷云 S4 的 S3 兼容接入点(官方文档确认 https://s3.bitiful.net)。 +// 采用虚拟主机风格寻址,请求落在 https://.s3.bitiful.net/。 +const s4DefaultEndpoint = "https://s3.bitiful.net" + +// S4Uploader 缤纷云 S4 对象存储上传器(兼容 S3 协议,基于 aws-sdk-go-v2)。 +// +// 图片经 CoreIX 公开样式(如 key!style:high)对外提供,VIP 与免费用户同质量、匿名可访问。 +// 库里只存对象 key(如 runway/abc.jpg),完整 URL 由 imgurl.Composer 拼装。 +// 本类型同时实现 storage.Uploader 与 storage.Deleter。 +type S4Uploader struct { + client *s3.Client + bucket string + baseURL string + enabled bool +} + +// NewS4Uploader 构造缤纷云 S4 上传器。 +// - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限)。 +// - bucket:存储桶名(须为「公开桶」,图片经 CoreIX 公开样式对外提供)。 +// - endpoint:S3 接入点,默认 https://s3.bitiful.net;虚拟主机风格寻址。 +// - baseURL:对外访问域名(GET 图片用)。为空时自动推导为 https://.s3.bitiful.net, +// 须与该 S4 接入点的虚拟主机域名一致,保证展示图 URL 同源。 +// - region:任意值(如 cn-east-1),缤纷云不校验。 +func NewS4Uploader(ak, sk, bucket, endpoint, baseURL, region string) *S4Uploader { + if ak == "" || sk == "" || bucket == "" { + return &S4Uploader{enabled: false} + } + if endpoint == "" { + endpoint = s4DefaultEndpoint + } + if region == "" { + region = "cn-east-1" + } + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, "")), + ) + if err != nil { + return &S4Uploader{enabled: false} + } + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = false // 虚拟主机风格:https://.s3.bitiful.net/ + }) + if baseURL == "" { + baseURL = deriveS4BaseURL(endpoint, bucket) + } + return &S4Uploader{ + client: client, + bucket: bucket, + baseURL: strings.TrimRight(baseURL, "/"), + enabled: true, + } +} + +// deriveS4BaseURL 由 endpoint + bucket 推导虚拟主机风格访问域名: +// endpoint=https://s3.bitiful.net, bucket=toom → https://toom.s3.bitiful.net +func deriveS4BaseURL(endpoint, bucket string) string { + endpoint = strings.TrimRight(endpoint, "/") + scheme := "https://" + host := endpoint + if strings.HasPrefix(host, "https://") { + host = host[len("https://"):] + } else if strings.HasPrefix(host, "http://") { + scheme = "http://" + host = host[len("http://"):] + } + return scheme + bucket + "." + host +} + +// PublicBaseURL 返回对外访问域名(可能由 NewS4Uploader 自动推导)。 +func (s *S4Uploader) PublicBaseURL() string { return s.baseURL } + +// Enabled 凭证齐全才视为启用。 +func (s *S4Uploader) Enabled() bool { return s.enabled } + +// Upload 把 data 直传到缤纷云 bucket 的 key,返回 key(库里只存 key,baseURL 由 Composer 拼)。 +func (s *S4Uploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { + if !s.enabled { + return "", fmt.Errorf("s4 uploader not enabled") + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + } + if _, err := s.client.PutObject(ctx, input); err != nil { + return "", fmt.Errorf("s4 put object failed: %w", err) + } + return key, nil +} + +// Delete 删除缤纷云 bucket 中的对象(按 key)。key 可能带 /uploads/ 等前缀,先剥离。 +func (s *S4Uploader) Delete(ctx context.Context, key string) error { + if !s.enabled { + return nil + } + key = strings.TrimPrefix(key, "/") + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("s4 delete object failed: %w", err) + } + return nil +} diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index 63f2dcd..60cfd44 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -2,9 +2,9 @@ // // 两种实现: // - LocalUploader:落到本地磁盘,对外以静态路径暴露(兜底/默认实现)。 -// - QiniuUploader:直传七牛云对象存储(标准库实现,无第三方 SDK 依赖)。 +// - S4Uploader:直传缤纷云 S4 对象存储(兼容 S3,基于 aws-sdk-go-v2)。 // -// 入库 worker 主用 Qiniu(若启用),单张失败时兜底写本地,避免图片完全丢失。 +// 入库 worker 主用 S4(若启用),单张失败时兜底写本地,避免图片完全丢失。 package storage import ( @@ -16,7 +16,7 @@ import ( // Uploader 抽象图片上传。 // Upload 返回写入数据库的图片访问地址: -// - Qiniu 返回完整 https URL(base_url + key) +// - S4 返回对象 key(base_url 由 imgurl.Composer 在渲染时拼) // - 本地实现返回 Upload.URLPrefix + "/" + key 的相对路径 type Uploader interface { // Upload 上传 data 到 key 指定的对象,返回访问 URL。 @@ -26,7 +26,7 @@ type Uploader interface { } // Deleter 抽象图片删除(清理孤儿文件用)。 -// QiniuUploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中 +// S4Uploader / LocalUploader 都实现本接口;仅当某 key 在所有图集/草稿表中 // 引用计数归零时才由调用方触发,避免误删仍被引用的文件。 type Deleter interface { // Delete 删除 key 指定的对象/文件。key 不存在时按成功处理(幂等)。 diff --git a/internal/pkg/textutil/textutil.go b/internal/pkg/textutil/textutil.go deleted file mode 100644 index 70164ea..0000000 --- a/internal/pkg/textutil/textutil.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package textutil 提供文本处理辅助函数。 -package textutil - -// Summarize 按 rune 截断字符串,避免中文被按字节切断;超出部分追加省略号。 -// -// 行为与原项目 handlers.summarize 完全一致(含省略号字符 U+2026)。 -func Summarize(s string, n int) string { - runes := []rune(s) - if len(runes) <= n { - return s - } - return string(runes[:n]) + "…" -} diff --git a/internal/repository/admin_mixed_integration_test.go b/internal/repository/admin_mixed_integration_test.go new file mode 100644 index 0000000..e51ced9 --- /dev/null +++ b/internal/repository/admin_mixed_integration_test.go @@ -0,0 +1,197 @@ +//go:build integration + +// 集成测试:文章管理混合列表必须跨 brand_runways / street_snaps 统一排序与过滤。 +// +// 运行(库需先就绪,见 dedup_integration_test.go 顶部说明): +// +// go test -tags integration ./internal/repository/ -run TestAdminMixed -v +package repository + +import ( + "testing" + + "gorm.io/gorm" +) + +// seedMixedRecords 各插一条走秀(已发布)与街拍(待审),返回它们的内部 id。 +// 用测试专用 city / season_code 隔离,避免与库里既有行撞唯一实体键。 +func seedMixedRecords(t *testing.T, db *gorm.DB) (runwayID, streetID uint32) { + t.Helper() + if err := db.Raw(` + INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at) + VALUES (999999, 'AdminRepoTestRunway', 3, ?, 0, 2099, 'ZZZ', 'test', 1800000000) + RETURNING id`, "published").Scan(&runwayID).Error; err != nil { + t.Fatalf("插入走秀种子失败: %v", err) + } + if err := db.Raw(` + INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at) + VALUES ('TestCityZZZ', 'AdminRepoTestStreet', 2, ?, 0, 2099, 1800000000) + RETURNING id`, "pending").Scan(&streetID).Error; err != nil { + t.Fatalf("插入街拍种子失败: %v", err) + } + t.Cleanup(func() { + _ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error + _ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error + }) + return runwayID, streetID +} + +// TestAdminMixedListFilterAndOrder 验证: +// 1. 全部视图同时返回走秀与街拍; +// 2. 待审核视图只返回 pending 的街拍,不含已发布的走秀; +// 3. 排序为「pending 优先、其次 id 倒序」—— 任一 pending 都排在所有 published 之前。 +func TestAdminMixedListFilterAndOrder(t *testing.T) { + db := testDB(t) + runwayID, streetID := seedMixedRecords(t, db) + + repo := NewAdminRepository(db) + + // 页大小必须显著大于库中总行数:本测试连的是**活库**(与开发库同一套 configs), + // 重爬会持续写入 pending 行,而排序是「pending 优先、其次 id 倒序」——种子里的走秀是 + // published,恒排在所有 pending 之后。曾用固定 100,重爬把 pending 写到 115 行后, + // 种子就被挤出第一页,断言随数据量随机失败(与被测逻辑无关)。 + const pageSize = 100000 + + all, total, err := repo.ListMixed(t.Context(), "", "", 1, pageSize) + if err != nil { + t.Fatalf("ListMixed 全部失败: %v", err) + } + if total < 2 { + t.Fatalf("全部视图至少应含 2 条种子,实际 total=%d", total) + } + var sawRunway, sawStreet bool + for _, c := range all { + if c.Kind == "runway" && c.ID == runwayID { + sawRunway = true + } + if c.Kind == "street" && c.ID == streetID { + sawStreet = true + } + } + if !sawRunway || !sawStreet { + t.Fatalf("全部视图应同时含走秀与街拍: runway=%v street=%v", sawRunway, sawStreet) + } + + // 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。 + pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, pageSize) + if err != nil { + t.Fatalf("ListMixed pending 失败: %v", err) + } + var pendingHasStreet, pendingHasRunway bool + for _, c := range pending { + if c.Kind == "street" && c.ID == streetID { + pendingHasStreet = true + } + if c.Kind == "runway" && c.ID == runwayID { + pendingHasRunway = true + } + } + if !pendingHasStreet { + t.Fatalf("待审核视图应含待审街拍") + } + if pendingHasRunway { + t.Fatalf("待审核视图不应含已发布走秀") + } + + // 排序:任一 pending 必须排在所有 published 之前。 + var maxPendingIdx, minPublishedIdx = -1, 1 << 30 + for i, c := range all { + if c.Status == "pending" { + if i > maxPendingIdx { + maxPendingIdx = i + } + } else if c.Status == "published" { + if i < minPublishedIdx { + minPublishedIdx = i + } + } + } + if maxPendingIdx >= 0 && minPublishedIdx < 1<<30 && maxPendingIdx > minPublishedIdx { + t.Fatalf("排序错误:存在 published 排在 pending 之前 (pending@%d, published@%d)", maxPendingIdx, minPublishedIdx) + } + + // CountMixed 与列表口径一致。 + if n, err := repo.CountMixed(t.Context(), "pending", ""); err != nil || n < 1 { + t.Fatalf("CountMixed(pending) 应 >=1,实际 n=%d err=%v", n, err) + } +} + +// TestAdminMixedKindFilter 验证 kind 筛选:只取走秀 / 只取街拍 / 全部。 +func TestAdminMixedKindFilter(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewAdminRepository(db) + ctx := t.Context() + + // 直接插入各一条,用独特 city / title 隔离唯一实体键。 + var runwayID, streetID uint32 + if err := db.Raw(` + INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at) + VALUES (999999, 'KindFilterRunway', 1, 'published', 0, 2097, 'KFZ', 'test', 1800000000) + RETURNING id`, + ).Scan(&runwayID).Error; err != nil { + t.Fatalf("插入走秀种子失败: %v", err) + } + if err := db.Raw(` + INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at) + VALUES ('KindFilterCity', 'KindFilterStreet', 1, 'published', 0, 2097, 1800000000) + RETURNING id`, + ).Scan(&streetID).Error; err != nil { + t.Fatalf("插入街拍种子失败: %v", err) + } + t.Cleanup(func() { + _ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error + _ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error + }) + + runwayOnly, _, err := repo.ListMixed(ctx, "", "runway", 1, 50) + if err != nil { + t.Fatalf("kind=runway 失败: %v", err) + } + for _, c := range runwayOnly { + if c.Kind != "runway" { + t.Fatalf("kind=runway 不应含 %s", c.Kind) + } + } + if len(runwayOnly) == 0 { + t.Fatalf("kind=runway 至少应含 1 条") + } + + streetOnly, _, err := repo.ListMixed(ctx, "", "street", 1, 50) + if err != nil { + t.Fatalf("kind=street 失败: %v", err) + } + for _, c := range streetOnly { + if c.Kind != "street" { + t.Fatalf("kind=street 不应含 %s", c.Kind) + } + } + if len(streetOnly) == 0 { + t.Fatalf("kind=street 至少应含 1 条") + } + + // 注意:必须比对**总数**而非本页行数——三种查询都按 size=50 截断, + // 库里 runway 超过 50 条时 len(all) 恒为 50,拿页长相加会假失败(与数据量相关的脆弱断言)。 + all, allTotal, err := repo.ListMixed(ctx, "", "", 1, 50) + if err != nil { + t.Fatalf("kind='' 失败: %v", err) + } + _, runwayTotal, err := repo.ListMixed(ctx, "", "runway", 1, 50) + if err != nil { + t.Fatalf("kind=runway 计数失败: %v", err) + } + _, streetTotal, err := repo.ListMixed(ctx, "", "street", 1, 50) + if err != nil { + t.Fatalf("kind=street 计数失败: %v", err) + } + if allTotal != runwayTotal+streetTotal { + t.Fatalf("全部总数应等于两类型之和:all=%d runway=%d street=%d", allTotal, runwayTotal, streetTotal) + } + // 本页行数不得超过分页上限,且 kind='' 的第一页应同时反映两种类型的存在。 + if len(all) == 0 { + t.Fatal("kind='' 至少应含 1 条") + } + if len(all) > 50 { + t.Fatalf("本页行数不应超过 size=50,实际 %d", len(all)) + } +} diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go new file mode 100644 index 0000000..e4e2b87 --- /dev/null +++ b/internal/repository/admin_repository.go @@ -0,0 +1,184 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" +) + +// AdminRecordCard 是「文章管理」混合列表的统一卡片:跨 brand_runways 与 street_snaps +// 两张表,字段取两者并集的最小可读子集(类型由 Kind 区分)。 +// +// 为什么是 UNION 而非分别查两张表再在 Go 里拼:混合列表需要按(待审优先 + id 倒序) +// 做跨表统一排序与分页,分别查两表再分页会得到错误的边界;一条 UNION 查询在数据库侧 +// 完成排序与 LIMIT/OFFSET,分页语义才正确,也避免把整张表拉进内存。 +type AdminRecordCard struct { + Kind string // "runway" | "street" + ID uint32 // 内部数值主键(供通过/驳回/批量接口使用) + Title string + Subtitle string // 走秀=品牌名;街拍=城市 + ImageCount int + Status string // pending / published / rejected +} + +// AdminRecordStats 是一条记录(图集)在列表上要展示的派生统计:原图字节数之和 + 主图 / 副图行数。 +// +// 为什么主副要分开、而不能直接用 brand_runways.image_count: +// 那一列的口径**只数主图**(见 service.countMainImages),并驱动公开卡片的「N 张」角标 +// 与 FeaturedIDs 的 SUM(image_count) 热度,不能改。但后台「图数」列若照搬它,就会和编辑页 +// 看到的图片总数对不上(实测 image_count=49 的图集实际有 224 行:49 主图 + 175 细节图)。 +// 因此列表另取真实行数,主副分别展示。 +type AdminRecordStats struct { + Size int64 // 原图字节数之和(image_size 列,worker 下载时写入) + Main int // 主图行数(is_detail = 0) + Detail int // 副图(细节图)行数(is_detail = 1) +} + +// AdminRepository 文章管理页所需的跨表只读查询。 +type AdminRepository interface { + // ListMixed 按状态 + 类型过滤(status 为空表示全部;kind 为空表示走秀+街拍, + // "runway"/"street" 则只取对应一类),跨表统一排序后分页返回。 + // 排序:pending 优先,其次 id 倒序(最新待审排最前)。 + ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) + // CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。 + CountMixed(ctx context.Context, status, kind string) (int64, error) + // ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表); + // 返回 owner id → 统计,不存在的 id 不出现在 map 中。供文章管理混合列表展示「图数」「原图总大小」。 + ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error) +} + +type adminRepository struct { + db *gorm.DB +} + +// NewAdminRepository 创建文章管理仓储。 +func NewAdminRepository(db *gorm.DB) AdminRepository { + return &adminRepository{db: db} +} + +// legWhere 单条腿(带表别名)的过滤条件:is_deleted=0 是后台可见性的硬约束, +// status 过滤可选。别名必须显式指定——两表 LEFT JOIN 时 is_deleted 会歧义。 +func legWhere(alias, status string) (string, []interface{}) { + where := alias + ".is_deleted = 0" + args := []interface{}{} + if status != "" { + where += " AND " + alias + ".status = ?" + args = append(args, status) + } + return where, args +} + +func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 24 + } + rw, rargs := legWhere("r", status) + sw, sargs := legWhere("s", status) + + // kind 决定本次跨表 UNION 取哪几腿:""=两表都取,"street"=只取街拍,"runway"=只取走秀。 + var legs []string + var args []interface{} + if kind != "street" { + legs = append(legs, ` + SELECT 'runway' AS kind, r.id, COALESCE(r.title_en, '') AS title, COALESCE(b.name_en, '') AS subtitle, + CAST(r.image_count AS BIGINT) AS image_count, r.status + FROM brand_runways r + LEFT JOIN brands b ON b.id = r.brand_id + WHERE `+rw) + args = append(args, rargs...) + } + if kind != "runway" { + legs = append(legs, ` + SELECT 'street' AS kind, s.id, COALESCE(s.title, '') AS title, COALESCE(s.city, '') AS subtitle, + CAST(s.image_count AS BIGINT) AS image_count, s.status + FROM street_snaps s + WHERE `+sw) + args = append(args, sargs...) + } + + q := ` + SELECT * FROM (` + strings.Join(legs, " UNION ALL ") + `) sub + ORDER BY (status = 'pending') DESC, id DESC + LIMIT ? OFFSET ?` + listArgs := append(append([]interface{}{}, args...), size, (page-1)*size) + + var cards []AdminRecordCard + if err := r.db.WithContext(ctx).Raw(q, listArgs...).Scan(&cards).Error; err != nil { + return nil, 0, err + } + + total, err := r.CountMixed(ctx, status, kind) + if err != nil { + return nil, 0, err + } + return cards, total, nil +} + +// ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表)。 +// 返回 owner id → 统计,不存在的 id 不出现在 map 中。 +// +// 字节数、主图数、副图数在**同一次扫描**里算完(都只依赖同一批图片行),避免为「图数」再查一遍库。 +// 一律过滤 is_deleted = 0:软删的图不参与展示,也不该计入图数。 +func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error) { + out := make(map[uint32]AdminRecordStats) + if len(ids) == 0 { + return out, nil + } + var table, fk string + switch kind { + case "runway": + table, fk = "brand_runway_images", "runway_id" + case "street": + table, fk = "street_snap_images", "snap_id" + default: + return out, nil + } + type agg struct { + Owner uint32 `gorm:"column:owner"` + Total int64 `gorm:"column:total"` + Main int `gorm:"column:main"` + Detail int `gorm:"column:detail"` + } + var rows []agg + if err := r.db.WithContext(ctx). + Table(table). + Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total, " + + "COUNT(*) FILTER (WHERE is_detail = 0) AS main, " + + "COUNT(*) FILTER (WHERE is_detail = 1) AS detail"). + Where(fk+" IN ? AND is_deleted = 0", ids). + Group(fk). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + out[row.Owner] = AdminRecordStats{Size: row.Total, Main: row.Main, Detail: row.Detail} + } + return out, nil +} + +func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) { + rw, rargs := legWhere("r", status) + sw, sargs := legWhere("s", status) + + var legs []string + var args []interface{} + if kind != "street" { + legs = append(legs, `SELECT COUNT(*) AS c FROM brand_runways r WHERE `+rw) + args = append(args, rargs...) + } + if kind != "runway" { + legs = append(legs, `SELECT COUNT(*) AS c FROM street_snaps s WHERE `+sw) + args = append(args, sargs...) + } + q := ` + SELECT COALESCE(SUM(c), 0) FROM (` + strings.Join(legs, " UNION ALL ") + `) t` + var total int64 + if err := r.db.WithContext(ctx).Raw(q, args...).Scan(&total).Error; err != nil { + return 0, err + } + return total, nil +} diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index 0c2eedd..6a85846 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -16,21 +16,26 @@ import ( type ArticleRepository interface { // List 按筛选条件分页查询文章,同时返回符合条件的总数。 List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) - // ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code/source_url 等管理字段。 + // ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。 ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) - // FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 + // FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 FindByID(ctx context.Context, id string) (*model.RunwayRow, error) - // ListImages 查询某篇文章的全部图片,按排序值升序。 + // FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。 + // 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。 + FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) + // ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。 ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) + // ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。 + ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) // ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。 ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) // GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) // UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。 UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error - // DeleteRunwayImage 软删单张走秀图片。 + // DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。 DeleteRunwayImage(ctx context.Context, imageID uint32) error - // GetRunwayImageKey 取单张走秀图片的七牛 key(删除前清理用);不存在返回空串。 + // GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。 GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) // SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。 SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) @@ -38,6 +43,8 @@ type ArticleRepository interface { SetRunwayCover(ctx context.Context, runwayID uint32, image string) error // SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, runwayID uint32) (int64, error) } type articleRepository struct { @@ -52,26 +59,26 @@ func NewArticleRepository(db *gorm.DB) ArticleRepository { // selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。 // 注意:year / season / season_code / collection_type / created_at 等仅用于排序,不 SELECT 到响应里 // (前端按年份/季节筛选是走查询参数,响应无需回带这些元数据)。 -const articleListColumns = `brand_runway.id, brand_runway.brand_id, - brand_runway.title_en, brand_runway.title_cn, - brand_runway.cover, brand_runway.image_count, +const articleListColumns = `brand_runways.id, brand_runways.brand_id, + brand_runways.title_en, brand_runways.title_cn, + brand_runways.cover, brand_runways.image_count, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` -const articleDetailColumns = `brand_runway.id, brand_runway.title_en, brand_runway.title_cn, - brand_runway.description_en, brand_runway.description_cn, - brand_runway.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` +const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_runways.title_cn, + brand_runways.description_en, brand_runways.description_cn, + brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year / -// collection_type / season_code / source_url 等管理字段(前端列表不展示这些)。 -const articleAdminColumns = `brand_runway.id, brand_runway.brand_id, - brand_runway.title_en, brand_runway.title_cn, - brand_runway.cover, brand_runway.image_count, brand_runway.year, - brand_runway.collection_type, brand_runway.season, brand_runway.season_code, - brand_runway.source_url, +// collection_type / season_code / status 等管理字段(前端列表不展示这些)。 +const articleAdminColumns = `brand_runways.id, brand_runways.brand_id, + brand_runways.title_en, brand_runways.title_cn, + brand_runways.cover, brand_runways.image_count, brand_runways.year, + brand_runways.collection_type, brand_runways.season, brand_runways.season_code, + brand_runways.status, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 -const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0" +const brandJoin = "LEFT JOIN brands b ON b.id = brand_runways.brand_id AND b.is_deleted = 0" // filterScope 把查询条件编译为 GORM Scope。 // @@ -79,18 +86,18 @@ const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_de // 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。 func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { - db = db.Where("brand_runway.is_deleted = 0") + db = db.Where("brand_runways.is_deleted = 0") // 按品牌过滤来自列表接口的 ?brand_id 查询参数(q.BrandIDs 由 handler 解码注入)。 - db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs)) + db = whereMulti(db, "brand_runways.brand_id", toAnySlice(q.BrandIDs)) if q.Collection != "" { - db = db.Where("brand_runway.collection_type = ?", q.Collection) + db = db.Where("brand_runways.collection_type = ?", q.Collection) } if q.Season != "" { - db = db.Where("brand_runway.season = ?", q.Season) + db = db.Where("brand_runways.season = ?", q.Season) } if q.Year != 0 { - db = db.Where("brand_runway.year = ?", q.Year) + db = db.Where("brand_runways.year = ?", q.Year) } return db } @@ -127,14 +134,14 @@ func toAnySlice[T any](in []T) []any { // newest(默认):按走秀真实新旧排序 —— created_at 仅是导入时间,不能代表走秀新旧, // 故用 year DESC + season_code 优先级(FW>PF>SS>RES)+ id 兜底,与首页「最新走秀」口径一致。 func orderBy(sort string) string { - seasonPrio := `CASE brand_runway.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END` + seasonPrio := `CASE brand_runways.season_code WHEN 'FW' THEN 4 WHEN 'PF' THEN 3 WHEN 'SS' THEN 2 WHEN 'RES' THEN 1 ELSE 0 END` switch sort { case "year_asc": - return "brand_runway.year ASC, brand_runway.id ASC" + return "brand_runways.year ASC, brand_runways.id ASC" case "year_desc": - return "brand_runway.year DESC, brand_runway.id DESC" + return "brand_runways.year DESC, brand_runways.id DESC" default: // newest - return "brand_runway.year DESC, " + seasonPrio + " DESC, brand_runway.id DESC" + return "brand_runways.year DESC, " + seasonPrio + " DESC, brand_runways.id DESC" } } @@ -143,7 +150,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod var total int64 if err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). + Table(publicRunways). Scopes(scope). Count(&total).Error; err != nil { return nil, 0, err @@ -154,7 +161,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod var rows []model.RunwayRow if err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). + Table(publicRunways). Scopes(scope). Select(articleListColumns). Joins(brandJoin). @@ -197,13 +204,22 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) ( return rows, total, nil } +// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。 func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) { + return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id) +} + +// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。 +func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) { + return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id) +} + +// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。 +func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) { var row model.RunwayRow - err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). - Select(articleDetailColumns). + err := q.Select(articleDetailColumns). Joins(brandJoin). - Where("brand_runway.id = ? AND brand_runway.is_deleted = 0", id). + Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id). First(&row).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -214,7 +230,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run return &row, nil } +// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。 func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { + var imgs []model.BrandRunwayImage + err := r.db.WithContext(ctx). + Table(publicRunwayImages). + Where("runway_id = ?", runwayID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + return imgs, err +} + +// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。 +func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). Where("runway_id = ? AND is_deleted = 0", runwayID). @@ -231,8 +259,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). + Table(publicRunwayImages). Select("runway_id, image, name, sort_order"). - Where("runway_id IN ? AND is_deleted = 0", ids). + Where("runway_id IN ? AND is_detail = 0", ids). Order("sort_order ASC, id ASC"). Find(&imgs).Error if err != nil { @@ -266,7 +295,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B } return nil, nil, err } - imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10)) + imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10)) if err != nil { return nil, nil, err } @@ -290,11 +319,64 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields Updates(clean).Error } +// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。 +// +// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算 +// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。 +// +// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径, +// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化, +// 并直接显示在公开列表卡片的「N 张」角标里。 +// +// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。 +// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。 func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error { - return r.db.WithContext(ctx). - Model(&model.BrandRunwayImage{}). - Where("id = ? AND is_deleted = 0", imageID). - Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var img model.BrandRunwayImage + err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + // 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。 + var rw model.BrandRunway + if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil { + return err + } + if err := tx.Model(&model.BrandRunwayImage{}). + Where("id = ? AND is_deleted = 0", imageID). + Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return err + } + if err := tx.Exec(`UPDATE brand_runways SET image_count = + (SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0) + WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil { + return err + } + // 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。 + if rw.Cover == img.Image { + cover := "" + var survivor struct { + Image string `gorm:"column:image"` + } + serr := tx.Raw( + "SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1", + img.RunwayID, + ).Scan(&survivor).Error + if serr == nil { + cover = survivor.Image + } else if !errors.Is(serr, gorm.ErrRecordNotFound) { + return serr + } + if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID). + Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return err + } + } + return nil + }) } func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) { @@ -310,7 +392,7 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3 } // SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。 -// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。 +// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。 func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) { var imgs []model.BrandRunwayImage if err := r.db.WithContext(ctx). @@ -331,6 +413,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return nil, err } + // 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。 + if err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Where("id = ?", runwayID). + Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return nil, err + } return keys, nil } @@ -347,3 +436,14 @@ func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, del Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error } + +// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(image_size 由 worker 下载时写入源文件字节数)。 +func (r *articleRepository) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) { + var total int64 + err := r.db.WithContext(ctx). + Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0", runwayID). + Select("COALESCE(SUM(image_size), 0)"). + Scan(&total).Error + return total, err +} diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go index b04f3b5..36febf3 100644 --- a/internal/repository/brand_repository.go +++ b/internal/repository/brand_repository.go @@ -13,12 +13,16 @@ import ( // BrandRepository 品牌数据访问接口。 type BrandRepository interface { - // List 按字母索引 / 关键词分页查询品牌。 + // List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。 // // restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内; // 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。 List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) + // ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布, + // 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。 + ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) + // FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中, // 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。 FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) @@ -33,6 +37,11 @@ type BrandRepository interface { Create(ctx context.Context, b *model.Brand) (uint32, error) // Update 按数字主键更新品牌中英文名。 Update(ctx context.Context, id uint32, nameEn, nameCn string) error + // CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数(品牌删除的护栏依据)。 + CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error) + // SetDeleted 软删 / 恢复品牌(只改 is_deleted 与 updated_at,不动其他列)。 + // 用软删而非物理删除:品牌可能被历史数据引用,且需要可恢复。不存在返回 ErrNotFound。 + SetDeleted(ctx context.Context, id uint32, deleted uint8) error // CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。 // 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。 @@ -71,16 +80,28 @@ func NewBrandRepository(db *gorm.DB) BrandRepository { // 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。 const latinPrefixRegexp = "^[A-Za-z]" -// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。 +// hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。 // 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。 +// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。 func (r *brandRepository) hasArticlesSubQuery() *gorm.DB { + return r.db.Table(publicRunways). + Select("DISTINCT brand_id") +} + +// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。 +// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌, +// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。 +func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB { return r.db.Model(&model.BrandRunway{}). Select("DISTINCT brand_id"). Where("is_deleted = 0") } // brandFilterScope 把品牌查询条件编译为 GORM Scope。 -func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB { +// +// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status, +// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。 +func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { db = db.Where("is_deleted = 0") @@ -92,16 +113,23 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3 case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1: db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter) default: - db = db.Where("name_en REGEXP ?", latinPrefixRegexp) + db = db.Where("name_en ~ ?", latinPrefixRegexp) } - // 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌, - // 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。 - // 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌, - // 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头); - // 中文分桶(OTHER)同样列出全部中文名品牌便于检索。 - if q.Keyword == "" && q.Letter != "OTHER" { - db = db.Where("id IN (?)", r.hasArticlesSubQuery()) + // 字母索引默认只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌, + // 不过滤会污染 A-Z 索引)。两个例外: + // 1) 关键词搜索时不限制(用户按名字找品牌,即使暂无档案也应命中); + // 2) 中文分桶(OTHER)同样列出全部中文名品牌便于检索; + // 3) 后台显式开 All(q.All):品牌目录有 2 万+ 条,后台需要能翻到任意品牌 + // (否则导入的品牌在界面上等于"不存在")。公开侧永远忽略 All。 + if q.Keyword == "" && q.Letter != "OTHER" && !(admin && q.All) { + if admin { + // 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。 + db = db.Where("id IN (?)", r.hasArticlesSubQueryBase()) + } else { + // 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。 + db = db.Where("id IN (?)", r.hasArticlesSubQuery()) + } } if q.Keyword != "" { @@ -121,8 +149,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3 } } +// List 公开品牌列表:字母索引只保留「至少有一篇已发布走秀档案」的品牌。 func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) { - scope := r.brandFilterScope(q, restrictIDs) + return r.list(ctx, q, restrictIDs, false) +} + +// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布, +// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。 +func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) { + return r.list(ctx, q, restrictIDs, true) +} + +func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) { + scope := r.brandFilterScope(q, restrictIDs, admin) var total int64 if err := r.db.WithContext(ctx). @@ -157,10 +196,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit var agg []struct { BrandID uint32 `gorm:"column:brand_id"` } + // 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。 err := r.db.WithContext(ctx). - Model(&model.BrandRunway{}). + Table(publicRunways). Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows"). - Where("is_deleted = 0"). Group("brand_id"). Order(orderCol + " DESC"). Limit(limit). @@ -176,7 +215,7 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit return ids, nil } -// ListByIDs 按 id 集合取品牌行;MySQL IN 查询不保证返回顺序,调用方自行重排。 +// ListByIDs 按 id 集合取品牌行;IN 查询不保证返回顺序,调用方自行重排。 func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) { if len(ids) == 0 { return []model.Brand{}, nil @@ -213,6 +252,39 @@ func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, e return b.ID, nil } +// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数。 +// 品牌删除的护栏依据:有档案时禁止删除,否则会留下一批 brand_id 指不到品牌的图集 +// (brand_runways.brand_id 无外键级联),前台品牌页 / 热门榜都会异常。 +func (r *brandRepository) CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error) { + var n int64 + if err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Where("brand_id = ? AND is_deleted = 0", brandID). + Count(&n).Error; err != nil { + return 0, err + } + return n, nil +} + +// SetDeleted 软删 / 恢复品牌:只改 is_deleted 与 updated_at。 +// 不存在返回 ErrNotFound(而不是静默成功),避免「点了删除但其实没删」。 +func (r *brandRepository) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { + res := r.db.WithContext(ctx). + Model(&model.Brand{}). + Where("id = ?", id). + Updates(map[string]any{ + "is_deleted": deleted, + "updated_at": uint32(time.Now().Unix()), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + // Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。 func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error { return r.db.WithContext(ctx). @@ -281,7 +353,7 @@ WITH latest_per_brand AS ( END DESC, id DESC ) AS rn - FROM brand_runway + FROM public_brand_runways brand_runways WHERE is_deleted = 0 AND brand_id IN (?) ) SELECT id, brand_id, cover, title_en, title_cn diff --git a/internal/repository/editor_image_delete_integration_test.go b/internal/repository/editor_image_delete_integration_test.go new file mode 100644 index 0000000..e15b28f --- /dev/null +++ b/internal/repository/editor_image_delete_integration_test.go @@ -0,0 +1,328 @@ +//go:build integration + +// 集成测试:后台正式编辑页(ArticleRepository / StreetSnapRepository)删图后, +// image_count 必须按实际存活行**重算** —— 与审核侧 SoftDelete*Image 口径完全一致。 +// +// 为什么必须有这条:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径。 +// 旧流程下编辑页删图造成的计数漂移会在下次发布被抹平;现在不会了,漂移会永久留在公开列表 +// 卡片的「N 张」角标上。入口链路:templates/pages/*-edit.html 删除按钮 → +// backstage_handler.go → article_service/street_snap_service.DeleteImage → 本仓储方法。 +// +// 运行:go test -tags integration ./internal/repository/ -run TestEditorDeleteImage -v +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// editorSeedSeq 让每次 seed 出来的实体键互不相同(避开 uq_br_entity / uq_ss_entity 部分唯一索引)。 +var editorSeedSeq int + +// seedEditorRunway 插入一条走秀记录(image_count 预置为入参,便于观察是否被重算)并返回其 id。 +func seedEditorRunway(t *testing.T, db *gorm.DB, imageCount uint16) uint32 { + t.Helper() + editorSeedSeq++ + now := uint32(time.Now().Unix()) + rw := model.BrandRunway{ + BrandID: 1, TitleEn: "editor-delete-test", Year: 2026, + SeasonCode: fmt.Sprintf("ED%04d", editorSeedSeq), CollectionType: "rtw", + Status: model.StatusPending, ImageCount: imageCount, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&rw).Error; err != nil { + t.Fatalf("插入走秀记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID) + }) + return rw.ID +} + +// seedEditorRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回行 id。 +func seedEditorRunwayImage(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8) uint32 { + t.Helper() + img := model.BrandRunwayImage{ + RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name, + SortOrder: order, IsDetail: isDetail, + } + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入走秀图片失败: %v", err) + } + return img.ID +} + +// seedEditorSnap 插入一条街拍记录(image_count 预置为入参)并返回其 id。 +func seedEditorSnap(t *testing.T, db *gorm.DB, imageCount uint16) uint32 { + t.Helper() + editorSeedSeq++ + now := uint32(time.Now().Unix()) + snap := model.StreetSnap{ + Title: "editor-delete-test", City: fmt.Sprintf("EditorCity-%d", editorSeedSeq), + Year: 2026, Status: model.StatusPending, ImageCount: imageCount, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&snap).Error; err != nil { + t.Fatalf("插入街拍记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID) + }) + return snap.ID +} + +// seedEditorSnapImage 插入一张街拍图(isDetail=1 表示副图)并返回行 id。 +func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string, order uint32, isDetail uint8, parent uint32) uint32 { + t.Helper() + img := model.StreetSnapImage{ + SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order, + IsDetail: isDetail, ParentImageID: parent, + } + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入街拍图片失败: %v", err) + } + return img.ID +} +// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。 +func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 { + t.Helper() + var row struct { + ImageCount uint16 `gorm:"column:image_count"` + } + if err := db.Table(table).Select("image_count").Where(idCol+" = ?", id).Scan(&row).Error; err != nil { + t.Fatalf("读 %s.image_count 失败: %v", table, err) + } + return row.ImageCount +} + +// TestEditorDeleteRunwayImageRecomputesCount 走秀编辑页删图:口径 = 存活主图行数(is_detail=0)。 +func TestEditorDeleteRunwayImageRecomputesCount(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewArticleRepository(db) + + id := seedEditorRunway(t, db, 2) // 与「2 张主图」一致的初始值 + keep := seedEditorRunwayImage(t, db, id, "keep", 1, 0) + drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0) + detail := seedEditorRunwayImage(t, db, id, "detail", 3, 1) + + if got := editorImageCount(t, db, "brand_runways", "id", id); got != 2 { + t.Fatalf("前置 image_count 应为 2,实际 %d", got) + } + + // 删一张主图:应按存活主图行数重算为 1。若只置 is_deleted 不重算,会停在 2。 + if err := repo.DeleteRunwayImage(ctx, drop); err != nil { + t.Fatalf("编辑页删图失败: %v", err) + } + if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 { + t.Fatalf("删一张主图后 image_count 应重算为 1,实际 %d(只置 is_deleted 不重算会停在 2)", got) + } + + // 删细节图:口径只计主图,计数不变(锁住 is_detail = 0 过滤)。 + if err := repo.DeleteRunwayImage(ctx, detail); err != nil { + t.Fatalf("删细节图失败: %v", err) + } + if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 { + t.Fatalf("删细节图不应改变主图计数,实际 %d", got) + } + + // 重复删同一张(已软删):重放幂等,计数不变。 + if err := repo.DeleteRunwayImage(ctx, drop); err != nil { + t.Fatalf("重复删同一张应幂等,实际报错: %v", err) + } + if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 { + t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got) + } + + // 不存在的 imageID:静默无操作,不得影响计数。 + if err := repo.DeleteRunwayImage(ctx, 4_294_967_290); err != nil { + t.Fatalf("不存在的 imageID 不应报错: %v", err) + } + if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 { + t.Fatalf("不存在的 imageID 不应改变 image_count,实际 %d", got) + } + + var alive []uint32 + if err := db.Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0", id).Order("id ASC"). + Pluck("id", &alive).Error; err != nil { + t.Fatalf("读存活图失败: %v", err) + } + if len(alive) != 1 || alive[0] != keep { + t.Fatalf("应只剩 keep 一行,实际 %v", alive) + } +} + +// TestEditorDeleteSnapImageRecomputesCount 街拍编辑页删图:口径 = 全部存活图片行(主图 + 副图)。 +func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewStreetSnapRepository(db) + + id := seedEditorSnap(t, db, 2) + main := seedEditorSnapImage(t, db, id, "main", 1, 0, 0) + detail := seedEditorSnapImage(t, db, id, "detail", 2, 1, main) + + // 删主图:副图仍存活,街拍口径计全部存活行 → 应重算为 1。 + // 若误用走秀的「只计主图」口径,会得 0 —— 本断言锁得住。 + if err := repo.DeleteSnapImage(ctx, main); err != nil { + t.Fatalf("编辑页删图失败: %v", err) + } + if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 { + t.Fatalf("删主图后 image_count 应按存活行重算为 1(副图也计入),实际 %d", got) + } + + // 重复删同一张(已软删):重放幂等,计数不变。 + if err := repo.DeleteSnapImage(ctx, main); err != nil { + t.Fatalf("重复删同一张应幂等,实际报错: %v", err) + } + if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 { + t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got) + } + + // 删副图:全部删完 → 0。 + if err := repo.DeleteSnapImage(ctx, detail); err != nil { + t.Fatalf("删副图失败: %v", err) + } + if got := editorImageCount(t, db, "street_snaps", "id", id); got != 0 { + t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got) + } +} + +// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。 +func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string { + t.Helper() + var row struct{ Cover string `gorm:"column:cover"` } + if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil { + t.Fatalf("读 %s.cover 失败: %v", table, err) + } + return row.Cover +} + +// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。 +func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewArticleRepository(db) + + id := seedEditorRunway(t, db, 2) + seedEditorRunwayImage(t, db, id, "keep", 1, 0) + drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0) + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + + if err := repo.DeleteRunwayImage(ctx, drop); err != nil { + t.Fatalf("删封面图失败: %v", err) + } + if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" { + t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got) + } +} + +// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。 +func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewArticleRepository(db) + + id := seedEditorRunway(t, db, 1) + only := seedEditorRunwayImage(t, db, id, "only", 1, 0) + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + if err := repo.DeleteRunwayImage(ctx, only); err != nil { + t.Fatalf("删最后一张图失败: %v", err) + } + if got := editorCover(t, db, "brand_runways", "id", id); got != "" { + t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got) + } +} + +// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。 +func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewArticleRepository(db) + + id := seedEditorRunway(t, db, 2) + seedEditorRunwayImage(t, db, id, "a", 1, 0) + seedEditorRunwayImage(t, db, id, "b", 2, 0) + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil { + t.Fatalf("下架删图失败: %v", err) + } + if got := editorCover(t, db, "brand_runways", "id", id); got != "" { + t.Fatalf("下架清空图片后封面应清空,实际 %q", got) + } +} + +// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。 +func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewStreetSnapRepository(db) + + id := seedEditorSnap(t, db, 2) + seedEditorSnapImage(t, db, id, "keep", 1, 0, 0) + drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0) + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + + if err := repo.DeleteSnapImage(ctx, drop); err != nil { + t.Fatalf("删封面图失败: %v", err) + } + if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" { + t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got) + } +} + +// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。 +func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewStreetSnapRepository(db) + + id := seedEditorSnap(t, db, 1) + only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0) + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + if err := repo.DeleteSnapImage(ctx, only); err != nil { + t.Fatalf("删最后一张图失败: %v", err) + } + if got := editorCover(t, db, "street_snaps", "id", id); got != "" { + t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got) + } +} + +// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。 +func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewStreetSnapRepository(db) + + id := seedEditorSnap(t, db, 2) + seedEditorSnapImage(t, db, id, "a", 1, 0, 0) + seedEditorSnapImage(t, db, id, "b", 2, 0, 0) + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil { + t.Fatalf("设封面失败: %v", err) + } + if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil { + t.Fatalf("下架删图失败: %v", err) + } + if got := editorCover(t, db, "street_snaps", "id", id); got != "" { + t.Fatalf("下架清空图片后封面应清空,实际 %q", got) + } +} + diff --git a/internal/repository/embedding_integration_test.go b/internal/repository/embedding_integration_test.go new file mode 100644 index 0000000..d3d4414 --- /dev/null +++ b/internal/repository/embedding_integration_test.go @@ -0,0 +1,180 @@ +//go:build integration + +// 集成测试:在真实 PostgreSQL + pgvector 上验证 image_embeddings 的读写与余弦近邻检索。 +// +// 单测(embedding_repository_test.go)只覆盖向量串的格式化/解析等纯逻辑; +// 本测试覆盖真正跑 SQL 的部分——`?::vector` 转换、`<=>` 余弦算子、 +// 以及与图片表 join 过滤软删行的行为。这些在 fake 上无法验证。 +// +// 运行:go test -tags integration ./internal/repository/ -run TestEmbeddingRepo -v +package repository + +import ( + "context" + "testing" + + "fashionapi/internal/model" +) + +// TestEmbeddingRepoUpsertListAndSimilar 覆盖写入 → 批量读回 → 余弦近邻检索这一整条链路。 +func TestEmbeddingRepoUpsertListAndSimilar(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewEmbeddingRepository(db) + + // 1) 造两条走秀图片(近邻检索要 join 图片表过滤软删,因此图片必须真实存在且 is_deleted=0)。 + now := uint32(1) + runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&runway).Error; err != nil { + t.Fatalf("建走秀失败: %v", err) + } + imgs := []model.BrandRunwayImage{ + {RunwayID: runway.ID, BrandID: 1, Image: "it/emb-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now}, + {RunwayID: runway.ID, BrandID: 1, Image: "it/emb-b.jpg", Name: "b", SortOrder: 2, CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(&imgs).Error; err != nil { + t.Fatalf("建图片失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID) + db.Delete(&model.BrandRunwayImage{}, imgs[0].ID) + db.Delete(&model.BrandRunwayImage{}, imgs[1].ID) + db.Delete(&model.BrandRunway{}, runway.ID) + }) + + // 2) 两个几乎相同的 768 维向量(第二个与第一个只差一个分量),相似度应远高于阈值。 + va := make([]float32, 768) + for i := range va { + va[i] = 0.1 + } + va[0] = 0.5 + vb := make([]float32, 768) + copy(vb, va) + vb[0] = 0.499 + + if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, va); err != nil { + t.Fatalf("写向量 A 失败: %v", err) + } + if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vb); err != nil { + t.Fatalf("写向量 B 失败: %v", err) + } + + // 3) 批量读回:维度必须保持 768,且值能精确往返。 + got, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID, imgs[1].ID}) + if err != nil { + t.Fatalf("ListEmbeddings 失败: %v", err) + } + if len(got) != 2 { + t.Fatalf("应读回 2 个向量,得到 %d", len(got)) + } + if len(got[imgs[0].ID]) != 768 { + t.Fatalf("向量维度应为 768,得到 %d", len(got[imgs[0].ID])) + } + if got[imgs[0].ID][0] != 0.5 { + t.Fatalf("向量分量往返后不一致:%v", got[imgs[0].ID][0]) + } + + // 4) 近邻检索:不排除任何图时,A 应找到同图集的 B(高相似度),且结果里不含自身。 + neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, got[imgs[0].ID], 0.9, 5) + if err != nil { + t.Fatalf("FindSimilarImages 失败: %v", err) + } + var found *SimilarImage + for i := range neighbors { + if neighbors[i].ImageID == imgs[0].ID { + t.Fatalf("近邻结果不应包含自身 imageID=%d", imgs[0].ID) + } + if neighbors[i].ImageID == imgs[1].ID { + found = &neighbors[i] + } + } + if found == nil { + t.Fatalf("应找到近邻 B(imageID=%d),实际结果 %+v", imgs[1].ID, neighbors) + } + if found.Similar < 0.99 { + t.Fatalf("两向量几乎相同,相似度应接近 1,实际 %f", found.Similar) + } + if found.OwnerID != runway.ID { + t.Fatalf("近邻所属记录应为 %d,实际 %d", runway.ID, found.OwnerID) + } + // 近邻的描述字段必须带回,后台靠它们显示「和哪张图像」。 + if found.Image != "it/emb-b.jpg" || found.Name != "b" || found.OwnerTitle != "embedding-it" { + t.Fatalf("近邻描述字段不完整: %+v", found) + } + + // 5) 传入同图集排除集后,B 必须被排除——需求是只看「本图集 vs 其他图集」, + // 同图集内所有照片共享背景/灯光/造型,相似度天然偏高,参与比对只会是噪音。 + excluded, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, + []uint32{imgs[0].ID, imgs[1].ID}, got[imgs[0].ID], 0.9, 5) + if err != nil { + t.Fatalf("排除同图集时 FindSimilarImages 失败: %v", err) + } + for _, n := range excluded { + if n.ImageID == imgs[1].ID { + t.Fatalf("同图集的图应被排除,但仍返回了 imageID=%d", imgs[1].ID) + } + } + + // 6) 重复写入同一 (kind, image_id) 应走 UPSERT 更新而非插入新行。 + if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vb); err != nil { + t.Fatalf("重复写向量失败: %v", err) + } + again, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID}) + if err != nil { + t.Fatalf("重复写后 ListEmbeddings 失败: %v", err) + } + if len(again) != 1 { + t.Fatalf("同一 (kind,image_id) 应只有一行,得到 %d", len(again)) + } + if again[imgs[0].ID][0] != 0.499 { + t.Fatalf("UPSERT 应更新为新值,实际 %v", again[imgs[0].ID][0]) + } +} + +// TestEmbeddingRepoSkipsSoftDeletedImages 软删的图片不得作为「疑似同款」参照 +// (image_embeddings 无 is_deleted 列,靠 join 图片表过滤)。 +func TestEmbeddingRepoSkipsSoftDeletedImages(t *testing.T) { + db := testDB(t) + ctx := context.Background() + repo := NewEmbeddingRepository(db) + + now := uint32(1) + runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it-softdel", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&runway).Error; err != nil { + t.Fatalf("建走秀失败: %v", err) + } + imgs := []model.BrandRunwayImage{ + {RunwayID: runway.ID, BrandID: 1, Image: "it/sd-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now}, + {RunwayID: runway.ID, BrandID: 1, Image: "it/sd-b.jpg", Name: "b", SortOrder: 2, IsDeleted: 1, CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(&imgs).Error; err != nil { + t.Fatalf("建图片失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID) + db.Delete(&model.BrandRunwayImage{}, imgs[0].ID) + db.Delete(&model.BrandRunwayImage{}, imgs[1].ID) + db.Delete(&model.BrandRunway{}, runway.ID) + }) + + vec := make([]float32, 768) + for i := range vec { + vec[i] = 0.1 + } + if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vec); err != nil { + t.Fatalf("写向量 A 失败: %v", err) + } + if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vec); err != nil { + t.Fatalf("写向量 B(软删图)失败: %v", err) + } + + neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, vec, 0.0, 50) + if err != nil { + t.Fatalf("FindSimilarImages 失败: %v", err) + } + for _, n := range neighbors { + if n.ImageID == imgs[1].ID { + t.Fatalf("已软删的图片(imageID=%d)不应作为同款参照", imgs[1].ID) + } + } +} diff --git a/internal/repository/embedding_repository.go b/internal/repository/embedding_repository.go new file mode 100644 index 0000000..1cd7d78 --- /dev/null +++ b/internal/repository/embedding_repository.go @@ -0,0 +1,512 @@ +package repository + +import ( + "context" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +// SimilarImage 是一张「疑似同款」的近邻结果。 +// +// 除 id 与分数外,还带回对方的图片 key、名称与所属图集标题: +// 后台必须能直接看出「和哪张图像」,只给一个分数等于让人在全库里翻找。 +type SimilarImage struct { + ImageID uint32 // 近邻图片 id + OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转) + Similar float64 // 余弦相似度 0~1 + Image string // 近邻图片存储 key(前端据此显示缩略图) + Name string // 近邻图片名(如 "Look 12") + OwnerTitle string // 近邻所属图集标题(跨图集命中时用来指明在哪个图集) +} + +// SimilarPair 是 image_similar_pairs 的写入行:只带 id 与分数,不带描述。 +// +// 描述靠读侧 join 图片表/图集表取得,这样图片改名、换图集后不会留下陈旧文案。 +type SimilarPair struct { + SelfID uint32 + OtherID uint32 + Similar float64 +} + +// SimilarNeighbor 是读出来的近邻:在 SimilarImage 基础上补齐对方的描述字段。 +type SimilarNeighbor struct { + OtherID uint32 // 近邻图片 id + OwnerID uint32 // 近邻所属图集 id(供前端跳转) + Similar float64 // 余弦相似度 0~1 + Image string // 近邻图片存储 key(前端显示缩略图) + Name string // 近邻图片名(如 "Look 12") + OwnerTitle string // 近邻所属图集标题 +} + +// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。 +// +// Total 由图片表实时统计得出,**刻意不用 brand_runways.image_count** —— +// 那一列是过期缓存(实测 runway 124 实际 240 张、该列却写 87),拿它当分母会把重复率算错。 +type DupStat struct { + Total int // 该图集存活图片总数(实时统计) + Images int // 其中有跨图集重复的图片数 + FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位 +} + +// EmbeddingRepository 读写 image_embeddings 并做近邻检索。 +// +// 该表存 FashionSigLIP 产出的 768 维归一化向量,仅用于后台「疑似同款」提示, +// **不参与任何自动去重**。表无 is_deleted 列,因此读侧一律 join 图片表过滤软删行。 +// +// 近邻结果走 image_similar_pairs 预计算表:入库时(或 dbtool similarbackfill 回填时) +// 算一次存表,后台页面只读结果。原因见 ReplaceSimilarPairs 注释。 +type EmbeddingRepository interface { + // UpsertEmbedding 写入或更新一张图的向量((kind, image_id) 唯一)。 + UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error + // BulkUpsertEmbedding 批量写入;vecs[i] 对应 ids[i],vecs[i] 为 nil 则跳过该行。 + BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error + // ListEmbeddings 批量取一批图片的已存向量;无向量的图片不出现在结果中。 + ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) + // FindSimilarImages 返回该图的近邻(已排除自身与 excludeIDs、已过滤软删图片,按相似度降序)。 + // + // excludeIDs 用于排除「同图集的图」:当前需求只看「本图集的图与其他图集有没有重复」, + // 同图集内所有照片共享背景/灯光/模特/造型,相似度天然偏高(实测同场中位 0.69、最高 0.91), + // 参与比对只会产生噪音。 + FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) + // ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对(先删后插,同一事务)。 + // + // rows 必须**双向**(A→B 与 B→A 各一行):这样打开任一侧所在的图集都能看到提示, + // 读侧无需 UNION。入库写完向量后调用。 + ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error + // ListSimilarPairs 读一批图的近邻(含对方的图片 key / 名称 / 所属图集标题,已过滤软删)。 + ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error) + // DupStatsByOwner 按图集汇总跨图集重复情况(含每组存活图片总数),供文章列表标注。 + // + // 返回「图集 id -> 概况」;没有任何图片的图集不会出现(调用方按缺失处理为 0)。 + DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error) +} + +// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量(`[x,y,...]`)。 +// +// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。 +func FormatEmbeddingVector(vec []float32) string { + var b strings.Builder + b.Grow(len(vec)*12 + 2) + b.WriteByte('[') + for i, v := range vec { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(strconv.FormatFloat(float64(v), 'g', -1, 32)) + } + b.WriteByte(']') + return b.String() +} + +// ParseEmbeddingVector 把 pgvector 的文本表示(`[x,y,...]`)解析回 float32 切片。 +// 解析失败返回 nil(调用方据此跳过该行,不阻断渲染)。 +func ParseEmbeddingVector(s string) []float32 { + s = strings.TrimSpace(s) + if len(s) < 2 || s[0] != '[' || s[len(s)-1] != ']' { + return nil + } + s = s[1 : len(s)-1] + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]float32, 0, len(parts)) + for _, p := range parts { + f, err := strconv.ParseFloat(strings.TrimSpace(p), 32) + if err != nil { + return nil + } + out = append(out, float32(f)) + } + return out +} + +// imageOwner 描述一种图片所属关系的表名、外键列名,以及主表用于展示的标题列。 +// +// 上述表名与列名均为代码内常量,不接受外部输入,因此拼接进 SQL 无注入面。 +type imageOwner struct { + table string // 图片表 + fk string // 图片表里指向主表的外键列 + titleTable string // 主表(图集) + titleCol string // 主表的标题列 +} + +// OwnerTable 返回某类图片的「图片表名 / 指向图集的外键列名」,供 dbtool 按图集遍历(回填近邻)使用。 +func OwnerTable(kind string) (table, fk string, ok bool) { + o, ok := ownerOf(kind) + if !ok { + return "", "", false + } + return o.table, o.fk, true +} + +// ownerOf 按 kind 返回对应图片表的所属关系;未知 kind 返回 ok=false。 +func ownerOf(kind string) (imageOwner, bool) { + switch kind { + case "runway": + // 走秀主表的英文标题列是 title_en(原通用 title 列已由 i18n 迁移删除)。 + return imageOwner{ + table: "brand_runway_images", fk: "runway_id", + titleTable: "brand_runways", titleCol: "title_en", + }, true + case "street": + return imageOwner{ + table: "street_snap_images", fk: "snap_id", + titleTable: "street_snaps", titleCol: "title", + }, true + default: + return imageOwner{}, false + } +} + +type embeddingRepository struct { + db *gorm.DB +} + +// NewEmbeddingRepository 创建向量仓储。 +func NewEmbeddingRepository(db *gorm.DB) EmbeddingRepository { + return &embeddingRepository{db: db} +} + +func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error { + return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec}) +} + +// BulkUpsertEmbedding 逐行写入而非 unnest 批量:日入库仅几百张,量级极小, +// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。 +func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error { + if len(ids) == 0 { + return nil + } + now := uint32(time.Now().Unix()) + for i, id := range ids { + if i >= len(vecs) || vecs[i] == nil { + continue + } + if err := r.db.WithContext(ctx).Exec(` + INSERT INTO image_embeddings (kind, image_id, embedding, created_at) + VALUES (?, ?, ?::vector, ?) + ON CONFLICT (kind, image_id) DO UPDATE + SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`, + kind, id, FormatEmbeddingVector(vecs[i]), now).Error; err != nil { + return err + } + } + return nil +} + +func (r *embeddingRepository) ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) { + if len(imageIDs) == 0 { + return nil, nil + } + var rows []struct { + ImageID uint32 `gorm:"column:image_id"` + Embedding string `gorm:"column:embedding"` + } + if err := r.db.WithContext(ctx). + Table("image_embeddings"). + Select("image_id, embedding"). + Where("kind = ? AND image_id IN ?", kind, imageIDs). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint32][]float32, len(rows)) + for _, row := range rows { + if vec := ParseEmbeddingVector(row.Embedding); vec != nil { + out[row.ImageID] = vec + } + } + return out, nil +} + +// FindSimilarImages 两步走:先走 HNSW 取候选(只查 image_embeddings,索引用得上), +// 再按 kind join 图片表过滤软删行并取所属文章 id(这一步不走索引,但候选集很小)。 +// +// ⚠️ 第一步必须用 Raw 写死参数顺序,不能用 GORM 的 Table/Select/Where/Order 链式组装: +// 链式写法里 `Select(..., vecStr)` 与 `Order(gorm.Expr("embedding <=> ?::vector", vecStr))` +// 各带一个同名参数,实测 GORM 会把 ORDER BY 的那个参数绑到错误位置, +// 结果 `ORDER BY embedding <=> ...` 退化为按垃圾值排序 —— 候选集变成随机若干行, +// 真正的近邻被大量漏掉(相似度阈值那一步仍用正确的向量,因此现象是「提示的并不是最像的那张」)。 +func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) { + if len(vec) == 0 || limit <= 0 { + return nil, nil + } + owner, ok := ownerOf(kind) + if !ok { + return nil, nil + } + // 放大候选倍数:部分候选会因图片已软删被第二步过滤掉。 + candidate := limit * 5 + if candidate < 20 { + candidate = 20 + } + vecStr := FormatEmbeddingVector(vec) + + // 排除同图集的图必须写在候选查询里、而不是留到第二步过滤: + // 若留到第二步,同图集的图会先占满候选名额(同场相似度天然偏高), + // 真正要看的跨图集近邻反而被 LIMIT 截断在外面。 + // + // ⚠️ 已知性能瓶颈(2026-10-13 实测):本函数每张图调用一次,一条 116 张图的图集要跑 + // 116 次,而每次往返要传约 20KB(768 维向量文本在 SELECT 与 ORDER BY 各出现一次)。 + // 本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,该链路大 payload 吞吐极差 + // (实测约 0.47MB/s):服务端执行这条查询只要 0.45ms,客户端往返却要 ~45ms, + // 合计 116×45ms ≈ 5 秒。**这是页面慢的真正原因**,且与 GORM/参数化/PG 日志/连接池均无关 + // (已逐项实测排除:原生 database/sql 同样 45ms、SELECT 1 只要 1ms、池等待为 0)。 + // 后续方向见 docs 里的讨论:把向量改为二进制参数、或整图集一条 LATERAL 查询、或用内容哈希替代。 + where := "kind = ? AND image_id <> ?" + args := []any{kind, imageID} + if len(excludeIDs) > 0 { + ids := make([]string, 0, len(excludeIDs)) + for _, id := range excludeIDs { + ids = append(ids, strconv.FormatUint(uint64(id), 10)) + } + where += " AND image_id NOT IN (" + strings.Join(ids, ",") + ")" + } + args = append(args, candidate) + sqlText := "SELECT image_id, 1 - (embedding <=> '" + vecStr + "'::vector) AS sim " + + "FROM image_embeddings " + + "WHERE " + where + " " + + "ORDER BY embedding <=> '" + vecStr + "'::vector LIMIT ?" + + type cand struct { + ImageID uint32 `gorm:"column:image_id"` + Sim float64 `gorm:"column:sim"` + } + var cands []cand + if err := r.db.WithContext(ctx).Raw(sqlText, args...).Scan(&cands).Error; err != nil { + return nil, err + } + if len(cands) == 0 { + return nil, nil + } + + ids := make([]uint32, 0, len(cands)) + for _, c := range cands { + ids = append(ids, c.ImageID) + } + // 第二步:join 图集主表,一次性取回「对方的图片 key / 名称 / 所属图集标题」。 + // 有了这三项,后台角标就能显示缩略图并指明是哪张图、在哪个图集, + // 而不是只给一个相似度分数让人自己去找。 + var neighbors []struct { + ID uint32 `gorm:"column:id"` + Image string `gorm:"column:image"` + Name string `gorm:"column:name"` + Owner uint32 `gorm:"column:owner"` + OwnerTitle string `gorm:"column:owner_title"` + } + // 显式展开占位符,不写 `IN ?`:Raw 路径下切片展开依赖 GORM 内部实现, + // 显式 `IN (?,?,…)` 才能确定逐值绑定。占位符是常量、表名列名来自 ownerOf 常量, + // 值一律走参数绑定,无注入面。 + placeholders := make([]string, len(ids)) + joinArgs := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + joinArgs[i] = id + } + if err := r.db.WithContext(ctx).Raw( + "SELECT i.id AS id, i.image AS image, i.name AS name, "+ + "i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+ + "FROM "+owner.table+" AS i "+ + "JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+ + "WHERE i.id IN ("+strings.Join(placeholders, ",")+") AND i.is_deleted = 0", + joinArgs...).Scan(&neighbors).Error; err != nil { + return nil, err + } + type neighborMeta struct { + image string + name string + owner uint32 + title string + } + alive := make(map[uint32]neighborMeta, len(neighbors)) + for _, n := range neighbors { + alive[n.ID] = neighborMeta{image: n.Image, name: n.Name, owner: n.Owner, title: n.OwnerTitle} + } + + res := make([]SimilarImage, 0, limit) + for _, c := range cands { + meta, ok := alive[c.ImageID] + if !ok { + continue // 图片已软删,不作为「疑似同款」参照 + } + if c.Sim < minSim { + continue + } + res = append(res, SimilarImage{ + ImageID: c.ImageID, + OwnerID: meta.owner, + Similar: c.Sim, + Image: meta.image, + Name: meta.name, + OwnerTitle: meta.title, + }) + if len(res) >= limit { + break + } + } + return res, nil +} + +// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对。 +// +// 为什么要有这张预计算表:原实现是在**打开图集页时**对每张图实时做一次向量近邻检索, +// 一个 116 张图的图集要发 116 次查询、每次约 20KB SQL。本机 PostgreSQL 跑在 WSL2 里、 +// Windows 经 wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计要 5 秒。 +// 改成入库时算一次存表后,页面只读结果(一次几百字节的小查询)。 +// +// 排除 id 用内联字面量而非参数:这些 id 是 uint32,格式化为十进制串后字符集受限,无注入面。 +func (r *embeddingRepository) ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error { + if len(selfIDs) == 0 { + return nil + } + ids := make([]string, 0, len(selfIDs)) + for _, id := range selfIDs { + ids = append(ids, strconv.FormatUint(uint64(id), 10)) + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 先删掉这批图的旧近邻:图集重爬/重算时避免残留过期对。 + if err := tx.Exec( + "DELETE FROM image_similar_pairs WHERE kind = ? AND self_id IN ("+strings.Join(ids, ",")+")", + kind).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + now := uint32(time.Now().Unix()) + vals := make([]string, 0, len(rows)) + args := make([]any, 0, len(rows)*5) + for _, p := range rows { + vals = append(vals, "(?, ?, ?, ?, ?)") + args = append(args, kind, p.SelfID, p.OtherID, p.Similar, now) + } + return tx.Exec( + "INSERT INTO image_similar_pairs (kind, self_id, other_id, sim, created_at) VALUES "+ + strings.Join(vals, ",")+ + " ON CONFLICT (kind, self_id, other_id) DO UPDATE SET sim = EXCLUDED.sim, created_at = EXCLUDED.created_at", + args...).Error + }) +} + +// ListSimilarPairs 批量读近邻,并 join 图片表与图集主表补齐对方描述。 +// +// 两条查询、payload 极小(这是页面变快的关键):先按 (kind, self_id) 主键前缀取对, +// 再按对方 id 一次性取描述。软删图片在此被过滤掉——预计算表里的行可能残留, +// 不级联清理,靠这里过滤即可。 +func (r *embeddingRepository) ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error) { + if len(imageIDs) == 0 { + return nil, nil + } + owner, ok := ownerOf(kind) + if !ok { + return nil, nil + } + var pairs []struct { + SelfID uint32 `gorm:"column:self_id"` + OtherID uint32 `gorm:"column:other_id"` + Sim float64 `gorm:"column:sim"` + } + if err := r.db.WithContext(ctx). + Table("image_similar_pairs"). + Select("self_id, other_id, sim"). + Where("kind = ? AND self_id IN ?", kind, imageIDs). + Scan(&pairs).Error; err != nil { + return nil, err + } + if len(pairs) == 0 { + return nil, nil + } + + otherIDs := make([]uint32, 0, len(pairs)) + seen := make(map[uint32]bool, len(pairs)) + for _, p := range pairs { + if !seen[p.OtherID] { + seen[p.OtherID] = true + otherIDs = append(otherIDs, p.OtherID) + } + } + ph := make([]string, len(otherIDs)) + args := make([]any, len(otherIDs)) + for i, id := range otherIDs { + ph[i] = "?" + args[i] = id + } + var metas []struct { + ID uint32 `gorm:"column:id"` + Image string `gorm:"column:image"` + Name string `gorm:"column:name"` + Owner uint32 `gorm:"column:owner"` + OwnerTitle string `gorm:"column:owner_title"` + } + if err := r.db.WithContext(ctx).Raw( + "SELECT i.id AS id, i.image AS image, i.name AS name, "+ + "i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+ + "FROM "+owner.table+" AS i "+ + "JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+ + "WHERE i.id IN ("+strings.Join(ph, ",")+") AND i.is_deleted = 0", + args...).Scan(&metas).Error; err != nil { + return nil, err + } + metaByID := make(map[uint32]SimilarNeighbor, len(metas)) + for _, m := range metas { + metaByID[m.ID] = SimilarNeighbor{ + OtherID: m.ID, OwnerID: m.Owner, + Image: m.Image, Name: m.Name, OwnerTitle: m.OwnerTitle, + } + } + out := make(map[uint32][]SimilarNeighbor, len(pairs)) + for _, p := range pairs { + m, ok := metaByID[p.OtherID] + if !ok { + continue // 对方已软删或不存在,不作为提示 + } + m.Similar = p.Sim + out[p.SelfID] = append(out[p.SelfID], m) + } + return out, nil +} + +// DupStatsByOwner 按图集汇总跨图集重复情况,一次查完供列表页使用。 +// +// 用 LEFT JOIN 同时拿到两件事:每组存活图片总数(做分母)与其中有重复的图片数。 +// 分母刻意实时统计而非读 brand_runways.image_count —— 那列是**只记主图**的口径(见 AdminRecordStats)。 +// +// 必须同时校验「对端那张图还活着」:image_similar_pairs 是**预计算表**,删除图集只会软删 +// 图片行、不会删近邻对,于是对端已删的陈旧近邻会继续留在表里。编辑页的提示路径(ListSimilarPairs) +// 会过滤掉这些死链,列表汇总若不过滤就会长期显示已不存在的重复(实测:图集 391 的 88 个近邻 +// 对端全部已删,列表仍显示「88 张 · 37%」)。恢复图集(is_deleted 置回 0)后会自动重新计入。 +// +// 表名/列名来自 ownerOf 常量,kind 走参数绑定,无注入面。 +func (r *embeddingRepository) DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error) { + owner, ok := ownerOf(kind) + if !ok { + return nil, nil + } + var rows []struct { + Owner uint32 `gorm:"column:owner"` + Total int `gorm:"column:total"` + Images int `gorm:"column:images"` + FirstImg uint32 `gorm:"column:first_img"` + } + if err := r.db.WithContext(ctx).Raw( + "SELECT i."+owner.fk+" AS owner, "+ + "count(DISTINCT i.id) AS total, "+ + "count(DISTINCT p.self_id) AS images, "+ + "coalesce(min(p.self_id), 0) AS first_img "+ + "FROM "+owner.table+" AS i "+ + "LEFT JOIN image_similar_pairs AS p ON p.kind = ? AND p.self_id = i.id "+ + " AND EXISTS (SELECT 1 FROM "+owner.table+" AS o WHERE o.id = p.other_id AND o.is_deleted = 0) "+ + "WHERE i.is_deleted = 0 "+ + "GROUP BY i."+owner.fk, + kind).Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint32]DupStat, len(rows)) + for _, r := range rows { + out[r.Owner] = DupStat{Total: r.Total, Images: r.Images, FirstImageID: r.FirstImg} + } + return out, nil +} diff --git a/internal/repository/embedding_repository_test.go b/internal/repository/embedding_repository_test.go new file mode 100644 index 0000000..81e05da --- /dev/null +++ b/internal/repository/embedding_repository_test.go @@ -0,0 +1,81 @@ +package repository + +import ( + "math" + "strings" + "testing" +) + +// TestFormatEmbeddingVector 向量串格式化是写入正确性的关键: +// 维度必须精确、float32 精度不能丢(截断会让相似度漂移)。 +func TestFormatEmbeddingVector(t *testing.T) { + v := make([]float32, 768) + v[0] = 0.5 + v[767] = -0.25 + + s := FormatEmbeddingVector(v) + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { + t.Fatalf("应输出 [..] 形式的向量串,得到 %q", truncForTest(s)) + } + if got := strings.Count(s, ",") + 1; got != 768 { + t.Fatalf("维度应为 768,得到 %d", got) + } + if !strings.Contains(s, "0.5") || !strings.Contains(s, "-0.25") { + t.Fatalf("首尾元素未按 float32 精度输出: %s", truncForTest(s)) + } +} + +// TestFormatEmbeddingVectorEmpty 空向量必须能安全格式化,不能 panic。 +func TestFormatEmbeddingVectorEmpty(t *testing.T) { + if got := FormatEmbeddingVector(nil); got != "[]" { + t.Fatalf("nil 应格式化为 [],得到 %q", got) + } +} + +// TestParseEmbeddingVectorRoundTrip 格式化与解析必须能精确往返, +// 否则「写进去的向量」与「读出来算相似度的向量」不是同一个,阈值校准会失真。 +func TestParseEmbeddingVectorRoundTrip(t *testing.T) { + v := make([]float32, 768) + for i := range v { + v[i] = float32(i%17-8) * 0.0173 + } + got := ParseEmbeddingVector(FormatEmbeddingVector(v)) + if len(got) != len(v) { + t.Fatalf("往返后长度应一致:%d != %d", len(got), len(v)) + } + for i := range v { + if got[i] != v[i] { + t.Fatalf("第 %d 维往返后不一致:%v != %v", i, got[i], v[i]) + } + } +} + +// TestParseEmbeddingVectorInvalid 非法输入返回 nil(调用方据此跳过该行,不阻断渲染)。 +func TestParseEmbeddingVectorInvalid(t *testing.T) { + for _, bad := range []string{"", "[]", "0.1,0.2", "[0.1,abc]", "[", "null"} { + if got := ParseEmbeddingVector(bad); got != nil { + t.Fatalf("非法输入 %q 应返回 nil,得到 %v", bad, got) + } + } +} + +// TestFormatEmbeddingVectorNormalized 归一化向量点积等于余弦相似度: +// 用同一向量的自点积验证模长≈1(服务端已归一化的前提)。 +func TestFormatEmbeddingVectorNormalized(t *testing.T) { + v := []float32{0.6, 0.8} // 模长为 1 + got := ParseEmbeddingVector(FormatEmbeddingVector(v)) + var dot float64 + for _, f := range got { + dot += float64(f) * float64(f) + } + if math.Abs(dot-1) > 1e-6 { + t.Fatalf("模长应≈1,得到 %f", dot) + } +} + +func truncForTest(s string) string { + if len(s) > 80 { + return s[:80] + "..." + } + return s +} diff --git a/internal/repository/entity_key_unique_integration_test.go b/internal/repository/entity_key_unique_integration_test.go new file mode 100644 index 0000000..2966a19 --- /dev/null +++ b/internal/repository/entity_key_unique_integration_test.go @@ -0,0 +1,180 @@ +//go:build integration + +// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。 +// +// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」; +// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体, +// 从而达成「删除即永久」。本测试只验证索引这一层的行为。 +package repository + +import ( + "context" + "testing" + + "fashionapi/internal/model" +) + +// TestRunwayEntityKeyUnique 同一实体键的第二行应被拒绝;软删后可以再插入。 +func TestRunwayEntityKeyUnique(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const season = "SS95" + rw := newRunwayForIngest(1, 1, season, "rtw", "uniq-a.jpg") + id, err := repo.CreateRunwayWithImages(ctx, rw, newRunwayImages("uniq-a.jpg")) + if err != nil { + t.Fatalf("首行应能插入: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) + db.Exec("DELETE FROM brand_runways WHERE id = ?", id) + }) + + dup := newRunwayForIngest(1, 1, season, "rtw", "uniq-b.jpg") + if _, err := repo.CreateRunwayWithImages(ctx, dup, newRunwayImages("uniq-b.jpg")); err == nil { + t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功") + } + + // 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。 + // 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。 + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil { + t.Fatalf("软删失败: %v", err) + } + again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg") + newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg")) + if err != nil { + t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", newID) + }) +} + +// TestStreetEntityKeyUnique 街拍实体键 = city + year + title(2026-09-22-04 细化后)。 +// +// 为什么键里要有 title:同城同年可以有多个专题(开发库实例 London 2027 Day 2 / Day 3), +// title 是数据里唯一能区分它们的字段。因此: +// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性); +// ② 同城同年**不同标题**的第二行必须允许(对应「Day 2 / Day 3 分成两个文章」)。 +func TestStreetEntityKeyUnique(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql") + // 04 是替换索引:先 DROP 掉 03 建的 (city, year),再按 (city, year, title) 重建同名索引。 + // 两个都执行才真实还原「迁移链走完」的库结构,也才能验证旧键确实不再生效。 + applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const city = "UniqTestCity" + const year = 1994 + const titleDay2 = "UniqTest Spring 1994 Day 2" + const titleDay3 = "UniqTest Spring 1994 Day 3" + + snap := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} + id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.jpg", SortOrder: 1}}) + if err != nil { + t.Fatalf("首行应能插入: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id) + db.Exec("DELETE FROM street_snaps WHERE id = ?", id) + }) + + // ① 同城同年同标题:实体键命中,唯一索引必须拒绝。 + dup := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} + if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil { + t.Fatalf("同城同年同标题的第二行应被唯一索引拒绝,实际插入成功") + } + + // ② 同城同年不同标题:不同专题,必须允许共存。 + // 这一条是本任务的核心:若还原成 (city, year) 的旧键,这里会插入失败。 + other := &model.StreetSnap{JobID: 1, Title: titleDay3, Year: year, City: city, Status: model.StatusPending} + otherID, err := repo.CreateStreetSnapWithImages(ctx, other, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}}) + if err != nil { + t.Fatalf("同城同年不同标题应可插入,实际失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", otherID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", otherID) + }) + + // 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。 + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil { + t.Fatalf("软删失败: %v", err) + } + again := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending} + newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-d.jpg", SortOrder: 1}}) + if err != nil { + t.Fatalf("软删后应可再插入同实体键: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", newID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", newID) + }) +} + +// TestStreetEntityStateMatchesIndexKey 覆盖 StreetSnapEntityState 的查重口径: +// 它必须与唯一索引 uq_ss_entity 的表达式 COALESCE(title, '') 完全一致, +// 否则查重会比索引更严(漏判 → 写入注定被索引拒绝的行)或更松(重复内容被当成新实体)。 +func TestStreetEntityStateMatchesIndexKey(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql") + applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const city = "EntityStateTestCity" + const year = 1996 + + // 种子行:title = Day 2(经 ORM 写入,title 为普通空串语义)。 + snap := &model.StreetSnap{JobID: 1, Title: "EntityState Day 2", Year: year, City: city, Status: model.StatusPending} + id, err := repo.CreateStreetSnapWithImages(ctx, snap, nil) + if err != nil { + t.Fatalf("种子行应能插入: %v", err) + } + t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) }) + + // ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。 + if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil { + t.Fatalf("查重出错: %v", err) + } else if found { + t.Fatalf("不同 title 不应命中既有行,实际命中") + } + + // ② 同城同年**同 title** → 命中,且带出正确 id / status。 + hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2") + if err != nil { + t.Fatalf("查重出错: %v", err) + } + if !found || hitID != id || status != model.StatusPending { + t.Fatalf("同 title 应命中 id=%d status=pending,实际 found=%v id=%d status=%q", id, found, hitID, status) + } + + // ③ title 为 NULL 的历史行 + 传入空串 title → 必须命中。 + // 这是 COALESCE(title, '') 与裸 title = ? 唯一的分歧点:裸写法下 NULL 永不等于空串, + // 这里会误判「未命中」并插入一行与 NULL 行同键(COALESCE 后同为 '')的记录,被索引拒绝。 + // 用原生 SQL 插入,因为 GORM 的 string 字段会写空串而非 NULL。 + var nullID uint32 + if err := db.Raw( + "INSERT INTO street_snaps (title, year, city, is_deleted, status, created_at, updated_at) "+ + "VALUES (NULL, ?, ?, 0, ?, 0, 0) RETURNING id", + year, city, model.StatusPending, + ).Scan(&nullID).Error; err != nil { + t.Fatalf("插入 NULL title 行失败: %v", err) + } + t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) }) + + nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "") + if err != nil { + t.Fatalf("查重出错: %v", err) + } + if !nfound || nid != nullID { + t.Fatalf("NULL title 行 + 空串应命中 id=%d,实际 found=%v id=%d", nullID, nfound, nid) + } +} diff --git a/internal/repository/errors.go b/internal/repository/errors.go index af955e2..098396b 100644 --- a/internal/repository/errors.go +++ b/internal/repository/errors.go @@ -10,3 +10,6 @@ import "errors" // ErrNotFound 记录不存在。 var ErrNotFound = errors.New("record not found") + +// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该草稿 / 自身不是主图 / 空集合。 +var ErrInvalidImageGroup = errors.New("invalid image group") diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go index b002f10..ee7262c 100644 --- a/internal/repository/history_repository.go +++ b/internal/repository/history_repository.go @@ -47,7 +47,7 @@ func (r *historyRepository) Upsert(ctx context.Context, h *model.History) error if err != nil { return err } - // 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避 MySQL「同表不可直接子查删除」限制)。 + // 超出用户上限则 FIFO 裁剪最旧若干(子查询双嵌套规避数据库「同表不可直接子查删除」限制)。 var n int64 if err := r.db.WithContext(ctx).Model(&model.History{}).Where("user_id = ?", h.UserID).Count(&n).Error; err != nil { return err diff --git a/internal/repository/image_id_stability_integration_test.go b/internal/repository/image_id_stability_integration_test.go new file mode 100644 index 0000000..a378c8f --- /dev/null +++ b/internal/repository/image_id_stability_integration_test.go @@ -0,0 +1,140 @@ +//go:build integration + +// 集成测试:单表发布模型的头号收益 —— 审核通过不再重建图片,**图片行 id 终身不变**。 +// +// 旧流程(草稿表 → 晋升)会在通过时把草稿图片重新插入正式图片表,图片行 id 必然改变; +// 任何按 id 引用的下游(CDN 缓存键、分享链接、埋点)都会随之失效。改造把「通过」降级为 +// 只改 status,本文件用「id 集合一字不差」把这条收益钉死。 +// +// 为什么现有测试看不到它: +// - router/backstage_test.go 的桩只断言 op == "approve",根本看不到图片行 id; +// - 公开视图测试都是直接 Update("status"),绕过了 SetRecordStatus / SetStreetRecordStatus。 +// +// 反证(本断言为何能区分「重建图片」的假实现):若把 SetRecordStatus 换成任何会重建图片的实现 +// (先删旧图再插入、或从别处拷贝重建),通过前记下的 id 与通过后必然不同(旧 id 消失、出现新 id), +// 本断言立即变红。断言只比对「同一记录存活图片行的 id 序列」,不依赖计数或其它副作用, +// 因此不会被与重建无关的改动误伤,也不会因无关改动静默通过。 +// +// 运行:go test -tags integration ./internal/repository/ -run TestApproveKeepsImageRowIDs -v +package repository + +import ( + "context" + "testing" + + "fashionapi/internal/model" +) + +// sameUint32IDs 逐元素比较两个 id 序列(长度 + 顺序)。 +func sameUint32IDs(a, b []uint32) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// TestApproveKeepsImageRowIDs 走秀:pending 记录(3 张图)经 SetRecordStatus(published) 通过后, +// status 变为 published,且图片行 id 集合与通过前完全一致。 +func TestApproveKeepsImageRowIDs(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewReviewRepository(db) + ctx := context.Background() + + id := seedRunway(t, db, model.StatusPending, 1992) // seedRunway 已插 1 张主图 + now := uint32(1_800_000_000) + for i, name := range []string{"second", "third"} { + img := model.BrandRunwayImage{ + RunwayID: id, BrandID: 1, Image: name + ".jpg", Name: name, + SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入走秀图片失败: %v", err) + } + } + + imageIDs := func() []uint32 { + var ids []uint32 + if err := db.Model(&model.BrandRunwayImage{}). + Where("runway_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil { + t.Fatalf("读图片行 id 失败: %v", err) + } + return ids + } + + before := imageIDs() + if len(before) != 3 { + t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before)) + } + + if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil { + t.Fatalf("SetRecordStatus(published) 出错: %v", err) + } + + var rw model.BrandRunway + if err := db.Where("id = ?", id).First(&rw).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if rw.Status != model.StatusPublished { + t.Fatalf("status 应为 published,实际 %s", rw.Status) + } + + if after := imageIDs(); !sameUint32IDs(before, after) { + t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after) + } +} + +// TestApproveKeepsSnapImageRowIDs 街拍:与走秀同形,覆盖另一组记录/图片表与方法。 +func TestApproveKeepsSnapImageRowIDs(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewReviewRepository(db) + ctx := context.Background() + + id := seedStreetSnap(t, db, model.StatusPending, 1986) // 已插 1 张图 + now := uint32(1_800_000_000) + for i, name := range []string{"second", "third"} { + img := model.StreetSnapImage{ + SnapID: id, Image: name + ".jpg", Name: name, + SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入街拍图片失败: %v", err) + } + } + + imageIDs := func() []uint32 { + var ids []uint32 + if err := db.Model(&model.StreetSnapImage{}). + Where("snap_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil { + t.Fatalf("读图片行 id 失败: %v", err) + } + return ids + } + + before := imageIDs() + if len(before) != 3 { + t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before)) + } + + if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil { + t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err) + } + + var snap model.StreetSnap + if err := db.Where("id = ?", id).First(&snap).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if snap.Status != model.StatusPublished { + t.Fatalf("status 应为 published,实际 %s", snap.Status) + } + + if after := imageIDs(); !sameUint32IDs(before, after) { + t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after) + } +} diff --git a/internal/repository/ingest_repository.go b/internal/repository/ingest_repository.go index 23819f3..ded5799 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -3,6 +3,8 @@ package repository import ( "context" "errors" + "fmt" + "strings" "time" "fashionapi/internal/model" @@ -11,9 +13,14 @@ import ( ) // IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放 -// (ingest_nces)+ 走秀正式表写入(brand_runway / brand_runway_images,按 source_url 去重)。 +// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。 // -// 入队与领取用同一张 ingest_jobs 表,领取靠 MySQL 的 FOR UPDATE SKIP LOCKED +// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title +// (同城同年可有多个专题,title 是数据里区分专题的字段)。 +// 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支: +// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。 +// +// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED // 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。 type IngestRepository interface { // Enqueue 写入一条待处理任务(payload 为原始 JSON)。 @@ -27,53 +34,46 @@ type IngestRepository interface { MarkFailed(ctx context.Context, id uint32, errMsg string) error // ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。 ReserveNonce(ctx context.Context, nonce string) (ok bool, err error) - // RunwayIDBySourceURL 按来源链接查是否已存在走秀;返回 (id, found)。 - RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) - // CreateRunway 插入走秀正式行,返回自增主键。 - CreateRunway(ctx context.Context, r *model.BrandRunway) (uint32, error) - // CreateRunwayImages 批量插入走秀图片行。 - CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error - // CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。 - CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) - // CreateRunwayDraftImages 批量插入草稿图片行。 - CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error - // DraftIDBySourceURL 按来源链接查是否已有 pending 草稿;返回 (id, found)。 - DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) - // StreetSnapIDBySourceURL 按来源链接查是否已存在街拍正式表;返回 (id, found)。 - StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) - // DraftStreetIDBySourceURL 按来源链接查是否已有 pending 街拍草稿;返回 (id, found)。 - DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) - // RunwayIDByUnique 按实体键(品牌+季节码+系列)查是否已存在正式走秀;返回 (id, found)。 - // 多来源爬同品牌同季时应合并为一条,故去重键是实体而非 source_url。 - RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) - // DraftRunwayIDByUnique 按实体键查是否已有 pending 走秀草稿;返回 (id, found)。 - DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) - // StreetSnapIDByUnique 按实体键(城市+年份)查是否已存在街拍正式表;返回 (id, found)。 - StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) - // DraftStreetSnapIDByUnique 按实体键查是否已有 pending 街拍草稿;返回 (id, found)。 - DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) - // CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。 - CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) - // CreateStreetSnapDraftImages 批量插入街拍草稿图片行。 - CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error - // ListJobs 按 id 倒序列出最近的入库任务(用于后台监控页)。 - ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) + // RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行, + // 返回其状态与是否已被软删(isDeleted)。worker 据此分四支: + // - 命中 pending/published → 直接放弃本次入库(ingestSkip) + // - 命中 rejected → 复用该行重审(ingestReuseRejected) + // - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活) + // - 未命中 → 新建(ingestCreate) + // 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」 + // 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。 + RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error) + // CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。 + CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) + // ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。 + // 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。 + ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error + // BackfillRunwayPublishedAt 临时回填:给已存在走秀补 published_at,并在标题缺年份时 + // 用发布日期年份补全 year / season_code(不重下图片)。回填为一次性操作,按主键 id 更新。 + // publishedAt 为 Unix 时间戳(秒),0 表示未抽取。 + BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error + // RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。 + RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error + // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。 + // 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。 + // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。 + StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error) + // CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。 + CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) + // ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。 + ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error + // ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。 + ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error) + // CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。 + CountJobs(ctx context.Context) (int64, error) // RetryJob 把一条 failed 任务重置回 pending,清 last_error/locked_at,等待 worker 重新处理。 RetryJob(ctx context.Context, id uint32) error - // EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(payload 为待清理 key 的 JSON)。 - // 由删除图集的服务调用,把同步的七牛删除改为异步队列,避免阻塞删除请求。 + // EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(payload 为待清理 key 的 JSON)。 + // 由删除图集的服务调用,把同步的S4删除改为异步队列,避免阻塞删除请求。 EnqueueMediaCleanup(ctx context.Context, payload string) error // ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。 // 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。 ScheduleRetry(ctx context.Context, id uint32, errMsg string) error - // ExistsSourceURLs 批量判断一批 source_url 是否已爬取过,返回命中集合(true = 已存在,无需再抓)。 - // - // 语义必须与 worker 判重(processRunway / processStreet)严格一致,否则会出现 - // 「预检说没有、worker 又判重命中」的重复抓取,或「预检说有、实际已被拒/已删」的漏抓: - // - 正式表(brand_runway / street_snap):source_url 命中且 is_deleted = 0 - // - 草稿表(brand_runway_draft / street_snap_draft):status = pending 且 is_deleted = 0 - // —— 被审核拒绝(rejected)的草稿不算已存在,允许重新抓取。 - ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) } type ingestRepository struct { @@ -132,7 +132,7 @@ func (r *ingestRepository) Claim(ctx context.Context, limit int) ([]model.Ingest } // 2) 领取 pending 且已到重试时间的任务(含刚回收的 + 新入队的 + 退避已到期的)。 - // 优先处理 media_cleanup(删除图集时异步清理七牛),避免被大批 crawl 任务排到后面、清理迟迟不触发。 + // 优先处理 media_cleanup(删除图集时异步清理S4),避免被大批 crawl 任务排到后面、清理迟迟不触发。 var ids []uint32 if err := tx.Raw( "SELECT id FROM ingest_jobs WHERE status = ? AND next_attempt_at <= ? "+ @@ -223,7 +223,7 @@ func (r *ingestRepository) ScheduleRetry(ctx context.Context, id uint32, errMsg }).Error } -// EnqueueMediaCleanup 写入一条「清理七牛孤儿图」任务(kind=media_cleanup), +// EnqueueMediaCleanup 写入一条「清理S4孤儿图」任务(kind=media_cleanup), // payload 为待清理 key 的 JSON,由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。 func (r *ingestRepository) EnqueueMediaCleanup(ctx context.Context, payload string) error { now := uint32(time.Now().Unix()) @@ -254,262 +254,306 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool return true, nil } -func (r *ingestRepository) RunwayIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) { - if sourceURL == "" { - return 0, false, nil - } +// ---- 入库直写正式表(单表发布模型)---- + +// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行。 +// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。 +// 优先取活着行(is_deleted ASC),保证「活着行 + 软删行同键共存」的存量数据下也返回活行。 +func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) { var row struct { - ID uint32 `gorm:"column:id"` + ID uint32 `gorm:"column:id"` + Status string `gorm:"column:status"` + IsDeleted uint8 `gorm:"column:is_deleted"` } err := r.db.WithContext(ctx). Model(&model.BrandRunway{}). - Select("id"). - Where("source_url = ? AND is_deleted = 0", sourceURL). + Select("id, status, is_deleted"). + Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType). + Order("is_deleted ASC, id DESC"). Limit(1). Scan(&row).Error if err != nil { - return 0, false, err + return 0, "", false, false, err } if row.ID == 0 { - return 0, false, nil + return 0, "", false, false, nil } - return row.ID, true, nil + return row.ID, row.Status, true, row.IsDeleted == 1, nil } -func (r *ingestRepository) CreateRunway(ctx context.Context, rw *model.BrandRunway) (uint32, error) { +func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) { now := uint32(time.Now().Unix()) - rw.CreatedAt = now - rw.UpdatedAt = now - if rw.IsDeleted == 0 { - rw.IsDeleted = 0 + rw.CreatedAt, rw.UpdatedAt = now, now + if rw.Status == "" { + rw.Status = model.StatusPending } - if err := r.db.WithContext(ctx).Create(rw).Error; err != nil { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if cErr := tx.Create(rw).Error; cErr != nil { + return cErr + } + if len(imgs) == 0 { + return nil + } + for i := range imgs { + imgs[i].RunwayID = rw.ID + imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now + } + return tx.Create(&imgs).Error + }) + if err != nil { return 0, err } return rw.ID, nil } -func (r *ingestRepository) CreateRunwayImages(ctx context.Context, imgs []model.BrandRunwayImage) error { - if len(imgs) == 0 { - return nil - } - return r.db.WithContext(ctx).Create(&imgs).Error -} - -func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) { +func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error { now := uint32(time.Now().Unix()) - d.CreatedAt = now - d.UpdatedAt = now - if d.Status == "" { - d.Status = model.DraftStatusPending - } - if err := r.db.WithContext(ctx).Create(d).Error; err != nil { - return 0, err - } - return d.ID, nil + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // WHERE 带 status=rejected 守卫:状态是读阶段拿到的,而写库发生在整批图片下载/上传之后 + // (可能数十秒窗口)。若无条件覆盖,期间已被改成 published 的行会被置回 pending, + // 等于把已发布内容从公开视图上撤下。 + upd := tx.Model(&model.BrandRunway{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected) + if uErr := upd.Updates(map[string]any{ + "title_en": rw.TitleEn, + "title_cn": rw.TitleCn, + "description_en": rw.DescriptionEn, + "description_cn": rw.DescriptionCn, + "year": rw.Year, + "season": rw.Season, + "collection_type": rw.CollectionType, + "season_code": rw.SeasonCode, + "cover": rw.Cover, + "image_count": rw.ImageCount, + "job_id": rw.JobID, + "status": model.StatusPending, + "reviewer": "", + "reject_reason": "", + "updated_at": now, + }).Error; uErr != nil { + return uErr + } + if upd.RowsAffected == 0 { + return fmt.Errorf("runway %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id) + } + if dErr := tx.Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0", id). + Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { + return dErr + } + if len(imgs) == 0 { + return nil + } + for i := range imgs { + imgs[i].RunwayID = id + imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now + } + return tx.Create(&imgs).Error + }) } -func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error { - if len(imgs) == 0 { - return nil +// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id: +// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。 +// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。 +func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error { + var imgs []model.BrandRunwayImage + if err := r.db.WithContext(ctx). + Where("runway_id = ? AND is_deleted = 0", runwayID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error; err != nil { + return err } - now := uint32(time.Now().Unix()) - for i := range imgs { - imgs[i].CreatedAt = now - imgs[i].UpdatedAt = now + lastMain := uint32(0) + type fix struct{ id, parent uint32 } + fixes := make([]fix, 0) + for _, im := range imgs { + if im.IsDetail == 0 { + lastMain = im.ID + continue + } + if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 { + fixes = append(fixes, fix{im.ID, lastMain}) + } } - return r.db.WithContext(ctx).Create(&imgs).Error + for _, f := range fixes { + if err := r.db.WithContext(ctx). + Model(&model.BrandRunwayImage{}). + Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID). + Update("parent_image_id", f.parent).Error; err != nil { + return err + } + } + return nil } -func (r *ingestRepository) DraftIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) { - if sourceURL == "" { - return 0, false, nil - } - var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). - Model(&model.BrandRunwayDraft{}). - Select("id"). - Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err - } - if row.ID == 0 { - return 0, false, nil - } - return row.ID, true, nil -} - -func (r *ingestRepository) StreetSnapIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) { - if sourceURL == "" { - return 0, false, nil - } - var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). - Model(&model.StreetSnap{}). - Select("id"). - Where("source_url = ? AND is_deleted = 0", sourceURL). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err - } - if row.ID == 0 { - return 0, false, nil - } - return row.ID, true, nil -} - -func (r *ingestRepository) DraftStreetIDBySourceURL(ctx context.Context, sourceURL string) (uint32, bool, error) { - if sourceURL == "" { - return 0, false, nil - } - var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). - Model(&model.StreetSnapDraft{}). - Select("id"). - Where("source_url = ? AND status = ? AND is_deleted = 0", sourceURL, model.DraftStatusPending). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err - } - if row.ID == 0 { - return 0, false, nil - } - return row.ID, true, nil -} - -// RunwayIDByUnique 按实体键(品牌+季节码+系列)查正式走秀,多来源同实体合并为一条。 -func (r *ingestRepository) RunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) { - var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). +// BackfillRunwayPublishedAt 临时回填(一次性):按主键 id 给已存在走秀补 published_at; +// 并在标题缺年份(year=0)时,用发布日期年份补全 year / season_code。 +// +// 设计要点: +// - published_at 总是写入(按 id,幂等,不会触犯唯一索引)。 +// - year / season_code 仅在「当前 year=0」且「目标 season_code 不与同品牌其它行冲突」时补全, +// 避免触犯 (brand_id, season_code, collection_type) 唯一索引;冲突时只补 published_at,留待人工处理。 +// - 不重下图片。 +// - publishedAt 为 Unix 时间戳(秒),0 表示未抽取。 +func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error { + now := time.Now().Unix() + if err := r.db.WithContext(ctx). Model(&model.BrandRunway{}). - Select("id"). - Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err + Where("id = ? AND is_deleted = 0", runwayID). + Updates(map[string]any{"published_at": publishedAt, "updated_at": now}).Error; err != nil { + return err } - if row.ID == 0 { - return 0, false, nil + if year == 0 { + return nil } - return row.ID, true, nil + // 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。 + // 注意:占位符顺序必须是 (runwayID, targetSeasonCode) —— 本 Where 有两个 ?, + // 漏传 runwayID 会让 season_code 落到 id 槽位,生成 `WHERE id = 'FW27'` + // 这种必然语法错误的 SQL(该缺陷曾导致「重爬已存在走秀」的任务无限重试)。 + return r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Where("id = ? AND year = 0 AND is_deleted = 0 "+ + "AND NOT EXISTS (SELECT 1 FROM brand_runways b "+ + "WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+ + "AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)", + runwayID, targetSeasonCode). + Updates(map[string]any{ + "year": year, + "season_code": targetSeasonCode, + "updated_at": now, + }).Error } -// DraftRunwayIDByUnique 按实体键查 pending 走秀草稿。 -func (r *ingestRepository) DraftRunwayIDByUnique(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) { +// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。 +// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突, +// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、 +// 进而插入一行注定被索引拒绝的记录。 +// +// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致, +// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。 +// +// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。 +// 与 RunwayEntityState 同源,优先取活着行(ORDER BY is_deleted ASC, id DESC)。 +func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, bool, error) { + title = NormalizeStreetTitle(title) var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). - Model(&model.BrandRunwayDraft{}). - Select("id"). - Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0", brandID, seasonCode, collectionType, model.DraftStatusPending). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err - } - if row.ID == 0 { - return 0, false, nil - } - return row.ID, true, nil -} - -// StreetSnapIDByUnique 按实体键(城市+年份)查街拍正式表;城市为空则不去重(避免空城市互并)。 -func (r *ingestRepository) StreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) { - if city == "" { - return 0, false, nil - } - var row struct { - ID uint32 `gorm:"column:id"` + ID uint32 `gorm:"column:id"` + Status string `gorm:"column:status"` + IsDeleted uint8 `gorm:"column:is_deleted"` } err := r.db.WithContext(ctx). Model(&model.StreetSnap{}). - Select("id"). - Where("city = ? AND year = ? AND is_deleted = 0", city, year). + Select("id, status, is_deleted"). + Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title). + Order("is_deleted ASC, id DESC"). Limit(1). Scan(&row).Error if err != nil { - return 0, false, err + return 0, "", false, false, err } if row.ID == 0 { - return 0, false, nil + return 0, "", false, false, nil } - return row.ID, true, nil + return row.ID, row.Status, true, row.IsDeleted == 1, nil } -// DraftStreetSnapIDByUnique 按实体键查 pending 街拍草稿;城市为空则不去重。 -func (r *ingestRepository) DraftStreetSnapIDByUnique(ctx context.Context, city string, year uint16) (uint32, bool, error) { - if city == "" { - return 0, false, nil - } - var row struct { - ID uint32 `gorm:"column:id"` - } - err := r.db.WithContext(ctx). - Model(&model.StreetSnapDraft{}). - Select("id"). - Where("city = ? AND year = ? AND status = ? AND is_deleted = 0", city, year, model.DraftStatusPending). - Limit(1). - Scan(&row).Error - if err != nil { - return 0, false, err - } - if row.ID == 0 { - return 0, false, nil - } - return row.ID, true, nil +// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行) +// 连续段折叠为单个空格。 +// +// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞 +// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。 +// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数, +// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。 +func NormalizeStreetTitle(title string) string { + return strings.Join(strings.Fields(title), " ") } -func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) { +func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) { now := uint32(time.Now().Unix()) - d.CreatedAt = now - d.UpdatedAt = now - if d.Status == "" { - d.Status = model.DraftStatusPending + snap.CreatedAt, snap.UpdatedAt = now, now + if snap.Status == "" { + snap.Status = model.StatusPending } - if err := r.db.WithContext(ctx).Create(d).Error; err != nil { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if cErr := tx.Create(snap).Error; cErr != nil { + return cErr + } + if len(imgs) == 0 { + return nil + } + for i := range imgs { + imgs[i].SnapID = snap.ID + imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now + } + return tx.Create(&imgs).Error + }) + if err != nil { return 0, err } - return d.ID, nil + return snap.ID, nil } -func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error { - if len(imgs) == 0 { - return nil - } +func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error { now := uint32(time.Now().Unix()) - for i := range imgs { - imgs[i].CreatedAt = now - imgs[i].UpdatedAt = now - } - return r.db.WithContext(ctx).Create(&imgs).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 同 runway:带 status=rejected 守卫 + 行数校验,避免把期间已发布的记录撤下公开视图。 + upd := tx.Model(&model.StreetSnap{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected) + if uErr := upd.Updates(map[string]any{ + "title": snap.Title, + "year": snap.Year, + "city": snap.City, + "cover": snap.Cover, + "image_count": snap.ImageCount, + "job_id": snap.JobID, + "status": model.StatusPending, + "reviewer": "", + "reject_reason": "", + "updated_at": now, + }).Error; uErr != nil { + return uErr + } + if upd.RowsAffected == 0 { + return fmt.Errorf("street snap %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id) + } + if dErr := tx.Model(&model.StreetSnapImage{}). + Where("snap_id = ? AND is_deleted = 0", id). + Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { + return dErr + } + if len(imgs) == 0 { + return nil + } + for i := range imgs { + imgs[i].SnapID = id + imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now + } + return tx.Create(&imgs).Error + }) } -// ListJobs 按 id 倒序列出最近的入库任务(后台监控页用)。 -func (r *ingestRepository) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) { +// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。 +func (r *ingestRepository) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error) { if limit < 1 { - limit = 200 + limit = 50 + } + if offset < 0 { + offset = 0 } var jobs []model.IngestJob - if err := r.db.WithContext(ctx).Order("id DESC").Limit(limit).Find(&jobs).Error; err != nil { + if err := r.db.WithContext(ctx).Order("id DESC").Offset(offset).Limit(limit).Find(&jobs).Error; err != nil { return nil, err } return jobs, nil } +// CountJobs 返回 ingest_jobs 总条数(后台监控页分页用)。 +func (r *ingestRepository) CountJobs(ctx context.Context) (int64, error) { + var n int64 + if err := r.db.WithContext(ctx).Model(&model.IngestJob{}).Count(&n).Error; err != nil { + return 0, err + } + return n, nil +} + // RetryJob 把一条 failed 任务重置回 pending,清掉 last_error / locked_at / next_attempt_at, // 让 worker(每 3s 扫一次 pending)立即重新拉起处理。仅对 failed 生效,其它状态原样不动。 func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error { @@ -527,13 +571,13 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error { } // isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致, -// 直接命中 MySQL 1062 错误号更稳。 +// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。 func isDuplicateKey(err error) bool { if err == nil { return false } msg := err.Error() - return containsAny(msg, "Duplicate entry", "1062", "UNIQUE constraint failed") + return containsAny(msg, "Duplicate entry", "1062", "duplicate key", "violates unique constraint", "UNIQUE constraint failed") } func containsAny(s string, subs ...string) bool { @@ -553,70 +597,3 @@ func indexOf(s, sub string) int { } return -1 } - -// ExistsSourceURLs 批量判断 source_url 是否已爬取过(预检接口用)。 -// -// 走秀与街拍的正式表、草稿表各查一次 IN,命中即标记。与 worker 判重共用同一套条件, -// 保证「爬虫预检跳过」与「worker 判重跳过」判定结果一致。 -func (r *ingestRepository) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) { - out := make(map[string]bool) - if len(urls) == 0 { - return out, nil - } - - // 去重 + 去空:同一批 URL 可能重复出现,避免无谓的返回行与 SQL 长度。 - uniq := make([]string, 0, len(urls)) - seen := make(map[string]struct{}, len(urls)) - for _, u := range urls { - if u == "" { - continue - } - if _, ok := seen[u]; ok { - continue - } - seen[u] = struct{}{} - uniq = append(uniq, u) - } - if len(uniq) == 0 { - return out, nil - } - // 单次请求上限,避免超长 IN 拖慢数据库(超出部分按「未抓过」处理,最多多抓几个,不会漏判已存在的)。 - const maxBatch = 500 - if len(uniq) > maxBatch { - uniq = uniq[:maxBatch] - } - - // 四张表:正式表只看未删除,草稿表只认 pending(被拒草稿允许重抓)。 - queries := []struct { - dest any - where string - args []any - }{ - {dest: &model.BrandRunway{}, where: "source_url IN ? AND is_deleted = 0"}, - {dest: &model.StreetSnap{}, where: "source_url IN ? AND is_deleted = 0"}, - {dest: &model.BrandRunwayDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}}, - {dest: &model.StreetSnapDraft{}, where: "source_url IN ? AND status = ? AND is_deleted = 0", args: []any{model.DraftStatusPending}}, - } - - type hit struct { - SourceURL string `gorm:"column:source_url"` - } - for i := range queries { - var rows []hit - q := r.db.WithContext(ctx). - Model(queries[i].dest). - Select("source_url"). - Where(queries[i].where, append([]any{uniq}, queries[i].args...)...) - if err := q.Scan(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - out[row.SourceURL] = true - } - // 全部命中就无需再查后面的表。 - if len(out) == len(uniq) { - break - } - } - return out, nil -} diff --git a/internal/repository/ingest_repository_test.go b/internal/repository/ingest_repository_test.go index 3a5fa44..8bfa4e4 100644 --- a/internal/repository/ingest_repository_test.go +++ b/internal/repository/ingest_repository_test.go @@ -2,10 +2,10 @@ package repository import "testing" -// 仅覆盖纯函数助手(DB 相关方法需 MySQL,本环境不跑)。 +// 仅覆盖纯函数助手(DB 相关方法需连库,本环境不跑)。 func TestContainsAny(t *testing.T) { if !containsAny("Error 1062: Duplicate entry", "1062") { - t.Fatal("should detect mysql 1062") + t.Fatal("should detect duplicate key (1062)") } if !containsAny("UNIQUE constraint failed", "UNIQUE constraint failed") { t.Fatal("should detect sqlite unique") diff --git a/internal/repository/ingest_single_table_integration_test.go b/internal/repository/ingest_single_table_integration_test.go new file mode 100644 index 0000000..c57b992 --- /dev/null +++ b/internal/repository/ingest_single_table_integration_test.go @@ -0,0 +1,223 @@ +//go:build integration + +// 集成测试:入库直写正式表的三条分支(新建 / 放弃 / 复用驳回行)。 +package repository + +import ( + "context" + "testing" + + "fashionapi/internal/model" +) + +func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway { + return &model.BrandRunway{ + JobID: jobID, BrandID: brandID, TitleEn: "ingest-test", + SeasonCode: seasonCode, CollectionType: collectionType, + Cover: cover, ImageCount: 1, Status: model.StatusPending, + } +} + +func newRunwayImages(cover string) []model.BrandRunwayImage { + return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}} +} + +// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。 +func TestIngestCreatesPendingRecord(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const season = "SS97" + _, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw") + if err != nil { + t.Fatalf("RunwayEntityState 出错: %v", err) + } + if found { + t.Fatalf("测试前置:实体键 %s 不应已存在", season) + } + + id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(101, 1, season, "rtw", "ingest-ss97.jpg"), newRunwayImages("ingest-ss97.jpg")) + if err != nil { + t.Fatalf("CreateRunwayWithImages 出错: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) + db.Exec("DELETE FROM brand_runways WHERE id = ?", id) + }) + + var rw model.BrandRunway + if err := db.Where("id = ?", id).First(&rw).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if rw.Status != model.StatusPending { + t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status) + } + + gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw") + if err != nil { + t.Fatalf("RunwayEntityState 出错: %v", err) + } + if !found || gotID != id || status != model.StatusPending { + t.Fatalf("实体键应命中刚建的行,实际 found=%v id=%d status=%s", found, gotID, status) + } + + publicCount := int64(0) + db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount) + if publicCount != 0 { + t.Fatalf("pending 记录不应出现在公开视图,实际 %d", publicCount) + } +} + +// TestIngestReusesRejectedRecord 复用分支:实体键命中 rejected → 覆盖内容、软删旧图、写新图、置回 pending。 +func TestIngestReusesRejectedRecord(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const season = "FW96" + oldID, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(90, 1, season, "rtw", "old.jpg"), newRunwayImages("old.jpg")) + if err != nil { + t.Fatalf("预置记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", oldID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID) + }) + if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID). + Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil { + t.Fatalf("置为已驳回失败: %v", err) + } + + _, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw") + if err != nil || !found || status != model.StatusRejected { + t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err) + } + + newRW := newRunwayForIngest(91, 1, season, "rtw", "new.jpg") + newRW.TitleEn = "ingest-test-reused" + if err := repo.ReuseRejectedRunway(ctx, oldID, newRW, newRunwayImages("new.jpg")); err != nil { + t.Fatalf("ReuseRejectedRunway 出错: %v", err) + } + + var rw model.BrandRunway + if err := db.Where("id = ?", oldID).First(&rw).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if rw.Status != model.StatusPending { + t.Fatalf("复用后应回到 pending,实际 %s", rw.Status) + } + if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" { + t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover) + } + if rw.Reviewer != "" { + t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer) + } + + var alive []model.BrandRunwayImage + if err := db.Where("runway_id = ? AND is_deleted = 0", oldID).Find(&alive).Error; err != nil { + t.Fatalf("读图片失败: %v", err) + } + if len(alive) != 1 || alive[0].Image != "new.jpg" { + t.Fatalf("旧图应被软删、只留新图,实际 %d 张", len(alive)) + } + var oldAlive int64 + db.Model(&model.BrandRunwayImage{}).Where("runway_id = ? AND image = ? AND is_deleted = 0", oldID, "old.jpg").Count(&oldAlive) + if oldAlive != 0 { + t.Fatalf("旧图应被软删") + } +} + +// TestIngestReuseRejectedRefusesPublished 复用守卫:实体键状态是读阶段拿到的,而写库发生在整批 +// 图片下载/上传之后(可能数十秒窗口)。若该行在这段窗口内已被改成 published,复用必须失败, +// 绝不能把它置回 pending——那等于把已发布内容从公开视图上撤下。 +func TestIngestReuseRejectedRefusesPublished(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const season = "SS95" + id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(80, 1, season, "rtw", "pub.jpg"), newRunwayImages("pub.jpg")) + if err != nil { + t.Fatalf("预置记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) + db.Exec("DELETE FROM brand_runways WHERE id = ?", id) + }) + // 模拟「读阶段之后、写库之前」该行被审核通过。 + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id). + Update("status", model.StatusPublished).Error; err != nil { + t.Fatalf("置为已发布失败: %v", err) + } + + newRW := newRunwayForIngest(81, 1, season, "rtw", "pub2.jpg") + newRW.TitleEn = "ingest-test-should-not-apply" + if err := repo.ReuseRejectedRunway(ctx, id, newRW, newRunwayImages("pub2.jpg")); err == nil { + t.Fatalf("对已发布行复用应报错,实际返回 nil") + } + + var rw model.BrandRunway + if err := db.Where("id = ?", id).First(&rw).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if rw.Status != model.StatusPublished { + t.Fatalf("已发布行不得被复用改回 pending,实际 %s", rw.Status) + } + if rw.TitleEn != "ingest-test" || rw.Cover != "pub.jpg" { + t.Fatalf("复用失败时不应覆盖内容,实际 title=%s cover=%s", rw.TitleEn, rw.Cover) + } + + publicCount := int64(0) + db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount) + if publicCount != 1 { + t.Fatalf("已发布行应仍在公开视图,实际 %d", publicCount) + } +} + + +// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。 +// +// 与 service.processRunway 的 imageCount = countMainImages(images) 同口径,且与审核期 +// SoftDeleteRunwayImage 的重算口径一致:三处必须同源,否则公开卡片「N 张」角标与 +// FeaturedIDs 的 SUM(image_count) 热度会漂移(单表发布删掉了晋升重算的自愈路径)。 +func TestIngestImageCountMatchesAliveMainRows(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewIngestRepository(db) + ctx := context.Background() + + const season = "IC71" + rw := newRunwayForIngest(70, 1, season, "rtw", "ic-main-a.jpg") + rw.ImageCount = 2 // 2 张主图 + imgs := []model.BrandRunwayImage{ + {BrandID: 1, Image: "ic-main-a.jpg", Name: "Look 1", SortOrder: 1, IsDetail: 0}, + {BrandID: 1, Image: "ic-detail-a1.jpg", Name: "Look 1 — Detail 1", SortOrder: 2, IsDetail: 1}, + {BrandID: 1, Image: "ic-main-b.jpg", Name: "Look 2", SortOrder: 3, IsDetail: 0}, + } + id, err := repo.CreateRunwayWithImages(ctx, rw, imgs) + if err != nil { + t.Fatalf("CreateRunwayWithImages 出错: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) + db.Exec("DELETE FROM brand_runways WHERE id = ?", id) + }) + + var got struct { + ImageCount uint16 `gorm:"column:image_count"` + AliveMain int64 `gorm:"column:alive_main"` + } + if err := db.Raw(`SELECT r.image_count, + (SELECT COUNT(*) FROM brand_runway_images i + WHERE i.runway_id = r.id AND i.is_deleted = 0 AND i.is_detail = 0) AS alive_main + FROM brand_runways r WHERE r.id = ?`, id).Scan(&got).Error; err != nil { + t.Fatalf("读回计数失败: %v", err) + } + if int64(got.ImageCount) != got.AliveMain { + t.Fatalf("入库后 image_count=%d 必须等于存活主图行数 %d", got.ImageCount, got.AliveMain) + } +} diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index b0f7821..bad67fb 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -6,14 +6,14 @@ import ( "gorm.io/gorm" ) -// MediaRepository 跨表图片引用计数(清理七牛孤儿文件的判定依据)。 +// MediaRepository 跨表图片引用计数(清理S4孤儿文件的判定依据)。 // -// 七牛 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。 -// 因此删除图集时绝不能「删库即删七牛」——必须先确认该 key 在全库已无任何引用, +// S4 key 为 sha1 内容寻址:同一张图若出现在多个图集/草稿里,在库中是同一个 key。 +// 因此删除图集时绝不能「删库即删S4」——必须先确认该 key 在全库已无任何引用, // 否则会误删被其他图集仍在用的图片。本接口提供唯一权威的引用计数查询。 type MediaRepository interface { - // CountImageKeyRefs 统计某七牛 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。 - // 返回 0 表示该 key 已成孤儿,可安全删除七牛对象。 + // CountImageKeyRefs 统计某S4 key 在全部图片明细表中仍被引用的数量(is_deleted=0)。 + // 返回 0 表示该 key 已成孤儿,可安全删除S4对象。 CountImageKeyRefs(ctx context.Context, key string) (int, error) } @@ -26,16 +26,14 @@ func NewMediaRepository(db *gorm.DB) MediaRepository { return &mediaRepository{db: db} } -// imageTables 保存七牛 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。 -// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。 +// imageTables 保存S4 key 的全部图片明细表(单表发布模型下即两张正式图片表)。 +// 引用计数必须覆盖全部图片表,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。 var imageTables = []string{ "brand_runway_images", - "brand_runway_draft_images", "street_snap_images", - "street_snap_draft_images", } -// CountImageKeyRefs 跨 4 张表统计 key 的存活引用数(is_deleted=0)。 +// CountImageKeyRefs 跨全部图片明细表统计 key 的存活引用数(is_deleted=0)。 func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) { if key == "" { return 0, nil diff --git a/internal/repository/public_read_integration_test.go b/internal/repository/public_read_integration_test.go new file mode 100644 index 0000000..5c50157 --- /dev/null +++ b/internal/repository/public_read_integration_test.go @@ -0,0 +1,201 @@ +//go:build integration + +// 集成测试:公开仓储方法必须读视图(读不到 pending),后台方法必须读基表(读得到 pending)。 +package repository + +import ( + "context" + "errors" + "strconv" + "testing" + + "fashionapi/internal/dto" + "fashionapi/internal/model" +) + +// TestPublicReadHidesUnpublishedRunway 走秀公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。 +func TestPublicReadHidesUnpublishedRunway(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + ctx := context.Background() + repo := NewArticleRepository(db) + + const year = 1997 + id := seedRunway(t, db, model.StatusPending, year) + idStr := strconv.FormatUint(uint64(id), 10) + + if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) { + t.Fatalf("pending 走秀详情应 ErrNotFound,实际 %v", err) + } + imgs, err := repo.ListImages(ctx, idStr) + if err != nil { + t.Fatalf("ListImages 出错: %v", err) + } + if len(imgs) != 0 { + t.Fatalf("pending 走秀的图片不应公开,实际 %d 张", len(imgs)) + } + _, total, err := repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20}) + if err != nil { + t.Fatalf("List 出错: %v", err) + } + if total != 0 { + t.Fatalf("pending 走秀不应出现在公开列表,实际 total=%d", total) + } + + // 后台读基表:必须能看到这条待审记录与其图片,否则审核页是空的。 + rw, baseImgs, err := repo.GetForEdit(ctx, id) + if err != nil { + t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err) + } + if rw == nil || len(baseImgs) == 0 { + t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs)) + } + + // 后台详情通路:FindByIDBase 也必须读得到 pending(后台详情页据此渲染; + // 若它读视图,后台列表里的待审行点进去就 404)。 + baseRow, err := repo.FindByIDBase(ctx, idStr) + if err != nil { + t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err) + } + if baseRow == nil || baseRow.ID != id { + t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseRow) + } + + // 置为已发布后,公开侧才可见。 + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil { + t.Fatalf("置为已发布失败: %v", err) + } + if _, err := repo.FindByID(ctx, idStr); err != nil { + t.Fatalf("已发布走秀详情应可读: %v", err) + } + _, total, err = repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20}) + if err != nil { + t.Fatalf("List 出错: %v", err) + } + if total != 1 { + t.Fatalf("已发布走秀应出现在公开列表,实际 total=%d", total) + } +} + +// TestPublicReadHidesUnpublishedStreet 街拍公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。 +func TestPublicReadHidesUnpublishedStreet(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + ctx := context.Background() + repo := NewStreetSnapRepository(db) + + const year = 1996 + id := seedStreetSnap(t, db, model.StatusPending, year) + idStr := strconv.FormatUint(uint64(id), 10) + + if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) { + t.Fatalf("pending 街拍详情应 ErrNotFound,实际 %v", err) + } + imgs, err := repo.ListImages(ctx, idStr) + if err != nil { + t.Fatalf("ListImages 出错: %v", err) + } + if len(imgs) != 0 { + t.Fatalf("pending 街拍的图片不应公开,实际 %d 张", len(imgs)) + } + _, total, err := repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20}) + if err != nil { + t.Fatalf("List 出错: %v", err) + } + if total != 0 { + t.Fatalf("pending 街拍不应出现在公开列表,实际 total=%d", total) + } + + snap, baseImgs, err := repo.GetForEdit(ctx, id) + if err != nil { + t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err) + } + if snap == nil || len(baseImgs) == 0 { + t.Fatalf("后台应读到 pending 行与其图片,实际 snap=%v imgs=%d", snap != nil, len(baseImgs)) + } + + // 后台列表 ListAdmin 读基表:必须能看到这条待审记录,否则审核员无从下手。 + _, adminTotal, err := repo.ListAdmin(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20}) + if err != nil { + t.Fatalf("ListAdmin 出错: %v", err) + } + if adminTotal != 1 { + t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal) + } + + // 后台详情通路:FindByIDBase 也必须读得到 pending(否则后台详情页 404)。 + baseSnap, err := repo.FindByIDBase(ctx, idStr) + if err != nil { + t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err) + } + if baseSnap == nil || baseSnap.ID != id { + t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseSnap) + } + + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil { + t.Fatalf("置为已发布失败: %v", err) + } + _, total, err = repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20}) + if err != nil { + t.Fatalf("List 出错: %v", err) + } + if total != 1 { + t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total) + } +} + +// hasBrandID 判断品牌列表里是否含指定 id(按 id 判定,避免受库里既有同名/同首字母品牌干扰)。 +func hasBrandID(items []model.Brand, id uint32) bool { + for _, it := range items { + if it.ID == id { + return true + } + } + return false +} + +// TestBrandIndexSplitsPublicAndAdmin 品牌 A-Z 索引的公开/后台分野: +// 只有待审走秀的品牌不得出现在公开索引(否则未发布内容从索引泄露), +// 但必须出现在后台列表(否则该品牌从后台「消失」,审核员无从下手)。 +func TestBrandIndexSplitsPublicAndAdmin(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + ctx := context.Background() + repo := NewBrandRepository(db) + + now := uint32(1_800_000_000) + brand := model.Brand{NameEn: "ViewTestBrand", NameCn: "视图测试品牌", CreatedAt: now, UpdatedAt: now} + if err := db.Create(&brand).Error; err != nil { + t.Fatalf("插入品牌失败: %v", err) + } + rw := model.BrandRunway{ + BrandID: brand.ID, TitleEn: "view-test-brand", Year: 1995, + Status: model.StatusPending, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&rw).Error; err != nil { + t.Fatalf("插入走秀失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID) + db.Exec("DELETE FROM brands WHERE id = ?", brand.ID) + }) + + // 字母桶 V + 大页,确保命中「必须有走秀档案」的子查询分支、且不会被分页截断。 + q := dto.BrandQuery{Letter: "V", Page: 1, Size: 400} + + pubItems, _, err := repo.List(ctx, q, nil) + if err != nil { + t.Fatalf("公开 List 出错: %v", err) + } + if hasBrandID(pubItems, brand.ID) { + t.Fatalf("只有待审走秀的品牌不应出现在公开索引,实际出现在结果里(id=%d)", brand.ID) + } + + admItems, _, err := repo.ListAdmin(ctx, q, nil) + if err != nil { + t.Fatalf("后台 ListAdmin 出错: %v", err) + } + if !hasBrandID(admItems, brand.ID) { + t.Fatalf("后台列表应看到只有待审走秀的品牌(id=%d)", brand.ID) + } +} diff --git a/internal/repository/public_view_columns_integration_test.go b/internal/repository/public_view_columns_integration_test.go new file mode 100644 index 0000000..6cc0367 --- /dev/null +++ b/internal/repository/public_view_columns_integration_test.go @@ -0,0 +1,66 @@ +//go:build integration + +// 集成测试:公开只读视图(public_brand_runways / public_street_snaps)不得暴露审核/溯源列 +// (job_id / reviewer / reject_reason)。设计规格 I2:视图列集已显式枚举,刻意排除这些列, +// 使基表加列时敏感列不会无意泄露到公开读路径。 +// +// 运行:go test -tags integration ./internal/repository/ -run TestPublicViewsExcludeSensitiveColumns -v +package repository + +import ( + "testing" +) + +// TestPublicViewsExcludeSensitiveColumns 断言两个主表公开视图不含敏感列。 +func TestPublicViewsExcludeSensitiveColumns(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + + type v struct { + view, col string + } + cases := []v{} + for _, view := range []string{"public_brand_runways", "public_street_snaps"} { + for _, col := range []string{"job_id", "reviewer", "reject_reason"} { + cases = append(cases, v{view, col}) + } + } + + for _, c := range cases { + var cnt int64 + if err := db.Raw( + `SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, + c.view, c.col, + ).Scan(&cnt).Error; err != nil { + t.Fatalf("查 information_schema 失败: %v", err) + } + if cnt != 0 { + t.Errorf("公开视图 %s 不应暴露敏感列 %s", c.view, c.col) + } + } +} + +// TestPublicViewsKeepNeededColumns 断言公开视图仍含对外必需的列(回归保护,避免收窄时误删)。 +func TestPublicViewsKeepNeededColumns(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + + keep := map[string][]string{ + "public_brand_runways": {"id", "brand_id", "title_en", "cover", "image_count", "year", "season_code", "status"}, + "public_street_snaps": {"id", "title", "title_cn", "city", "cover", "image_count", "year", "status"}, + } + for view, cols := range keep { + for _, col := range cols { + var cnt int64 + if err := db.Raw( + `SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, + view, col, + ).Scan(&cnt).Error; err != nil { + t.Fatalf("查 information_schema 失败: %v", err) + } + if cnt != 1 { + t.Errorf("公开视图 %s 必须含列 %s(收窄时误删)", view, col) + } + } + } +} diff --git a/internal/repository/public_views.go b/internal/repository/public_views.go new file mode 100644 index 0000000..8df7afb --- /dev/null +++ b/internal/repository/public_views.go @@ -0,0 +1,19 @@ +package repository + +// 公开只读视图的表名。 +// +// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql), +// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。 +// +// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用 +// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。 +// +// publicStreetSnaps 同样带 `AS street_snaps` 别名:street_snaps 的排序子句 snapOrderBy +// 使用 `street_snaps.xxx` 限定列,不带别名会报 missing FROM-clause entry。 +// 后台读基表时**不要**使用这些常量。 +const ( + publicRunways = "public_brand_runways AS brand_runways" + publicRunwayImages = "public_brand_runway_images" + publicStreetSnaps = "public_street_snaps AS street_snaps" + publicStreetSnapImages = "public_street_snap_images" +) diff --git a/internal/repository/publish_status_integration_test.go b/internal/repository/publish_status_integration_test.go new file mode 100644 index 0000000..c62a44f --- /dev/null +++ b/internal/repository/publish_status_integration_test.go @@ -0,0 +1,140 @@ +//go:build integration + +// 集成测试:公开视图必须屏蔽未发布内容 —— 这是「单表发布模型」的核心保证。 +// +// 运行(库需先就绪,见 internal/repository/dedup_integration_test.go 顶部说明): +// +// go test -tags integration ./internal/repository/ -run TestPublicViews -v +package repository + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// applyMigration 读取并执行 db/migrations 下的迁移文件。 +// 迁移 SQL 因此每次测试都被真实跑一遍:语法错、视图列集漂移会立刻暴露。 +// 迁移文件必须幂等,重复调用安全。 +func applyMigration(t *testing.T, db *gorm.DB, name string) { + t.Helper() + path := filepath.Join("..", "..", "db", "migrations", name) + sqlBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取迁移失败 %s: %v", path, err) + } + if err := db.Exec(string(sqlBytes)).Error; err != nil { + t.Fatalf("执行迁移失败 %s: %v", name, err) + } +} + +// seedRunway 插入一条指定状态的走秀记录 + 一张主图,返回记录 id。 +// year 用测试专用值(入参),便于列表按年份隔离断言;created_at 取未来时间戳, +// 保证不会被迁移里「存量行置已发布」的时间戳护栏误刷。 +func seedRunway(t *testing.T, db *gorm.DB, status string, year uint16) uint32 { + t.Helper() + now := uint32(1_800_000_000) + // 实体键必须非空且按 year 派生:2026-09-22-03 的唯一索引落库后,brand_id=0 且 + // season_code / collection_type 均为空会让同一次运行里的多条种子行(乃至上次运行 + // 未清理的遗留行)被当成「同一实体」而互相冲突,失败点还会伪装成迁移体检。 + rw := model.BrandRunway{ + TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now, + SeasonCode: fmt.Sprintf("T%04d", year), CollectionType: "t", + } + if err := db.Create(&rw).Error; err != nil { + t.Fatalf("插入走秀失败: %v", err) + } + img := model.BrandRunwayImage{ + RunwayID: rw.ID, BrandID: 1, Image: "view-test.jpg", Name: "look", + SortOrder: 1, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入走秀图片失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID) + }) + return rw.ID +} + +// seedStreetSnap 插入一条指定状态的街拍记录 + 一张图,返回记录 id。 +func seedStreetSnap(t *testing.T, db *gorm.DB, status string, year uint16) uint32 { + t.Helper() + now := uint32(1_800_000_000) + snap := model.StreetSnap{Title: "view-test", City: "ViewTestCity", Year: year, Status: status, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&snap).Error; err != nil { + t.Fatalf("插入街拍失败: %v", err) + } + img := model.StreetSnapImage{SnapID: snap.ID, Image: "view-test.jpg", Name: "look", SortOrder: 1, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&img).Error; err != nil { + t.Fatalf("插入街拍图片失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID) + }) + return snap.ID +} + +// TestPublicViewsHideUnpublished 走秀:pending / rejected 的记录与图片都不得出现在公开视图,published 才可见。 +func TestPublicViewsHideUnpublished(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + ctx := context.Background() + + id := seedRunway(t, db, model.StatusPending, 1999) + + count := func() (records, images int64) { + db.WithContext(ctx).Table("public_brand_runways").Where("id = ?", id).Count(&records) + db.WithContext(ctx).Table("public_brand_runway_images").Where("runway_id = ?", id).Count(&images) + return + } + + if r, i := count(); r != 0 || i != 0 { + t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i) + } + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil { + t.Fatalf("置为已发布失败: %v", err) + } + if r, i := count(); r != 1 || i != 1 { + t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i) + } + if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusRejected).Error; err != nil { + t.Fatalf("置为已驳回失败: %v", err) + } + if r, i := count(); r != 0 || i != 0 { + t.Fatalf("rejected 不应出现在公开视图,实际 records=%d images=%d", r, i) + } +} + +// TestPublicViewsHideUnpublishedStreet 街拍:同上,覆盖另外两个视图。 +func TestPublicViewsHideUnpublishedStreet(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + ctx := context.Background() + + id := seedStreetSnap(t, db, model.StatusPending, 1998) + + count := func() (records, images int64) { + db.WithContext(ctx).Table("public_street_snaps").Where("id = ?", id).Count(&records) + db.WithContext(ctx).Table("public_street_snap_images").Where("snap_id = ?", id).Count(&images) + return + } + + if r, i := count(); r != 0 || i != 0 { + t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i) + } + if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil { + t.Fatalf("置为已发布失败: %v", err) + } + if r, i := count(); r != 1 || i != 1 { + t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i) + } +} diff --git a/internal/repository/review_repository.go b/internal/repository/review_repository.go index e196930..35ed97f 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -11,42 +11,50 @@ import ( "gorm.io/gorm" ) -// ReviewRepository 审核草稿的数据访问接口。 +// ReviewRepository 审核数据访问接口。 // -// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」: -// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。 +// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表): +// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。 type ReviewRepository interface { - // ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。 - // 排序:pending 优先,其次按 id 倒序(最新在前)。 - ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) - // DraftDetail 查单条草稿(不含图片)。 - DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) - // DraftImages 查草稿的全部图片,按排序值升序。 - DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) - // UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。 - UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error - // SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。 - SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error - // SaveRunwayFromDraft 晋升:按 source_url 幂等 upsert 到 brand_runway 正式表并替换图片,返回正式表主键。 - SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) - // 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_draft / street_snap)。 - ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) - StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) - StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) - // SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。 - SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error - // SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 - SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error - // GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。 - GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) - UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error - SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error - SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) - // CountPending 统计 pending 草稿数(合并 runway + street,仪表盘卡片用)。 - CountPending(ctx context.Context) (int64, error) - // CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。 - // 供各 ReviewModule 复用,新增模块无需再单独写计数方法。 - CountDraftPending(ctx context.Context, status string, dest any) (int64, error) + // ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。 + ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) + // RecordDetail 查单条走秀记录(不含图片)。 + RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) + // RecordImages 查走秀记录的全部未删图片,按排序值升序。 + RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) + // UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。 + UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error + // SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。 + SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error + + // 街拍镜像方法(对象为 street_snaps / street_snap_images)。 + ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) + StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) + StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) + UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error + // SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。 + SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error + + // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。 + SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error + // SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。 + SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error + // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 + // mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 + // 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。 + // 幂等:已是 mainImgID 副图的行重复并入不报错。 + AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error + // DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。 + DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error + // AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。 + // 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。 + AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error + // DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。 + DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error + // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。 + GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) + // CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。 + CountByStatus(ctx context.Context, status string, dest any) (int64, error) } type reviewRepository struct { @@ -58,27 +66,40 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository { return &reviewRepository{db: db} } -func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) { +// reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。 +// +// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用 +// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患 +//(与 article_repository.filterScope 同一理由)。 +func reviewListScope(status string) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + db = db.Where("is_deleted = 0") + if status != "" { + db = db.Where("status = ?", status) + } + return db + } +} + +func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } - q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0") - if status != "" { - q = q.Where("status = ?", status) - } + scope := reviewListScope(status) + var total int64 - if err := q.Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { - return []model.BrandRunwayDraft{}, 0, nil + return []model.BrandRunway{}, 0, nil } - var rows []model.BrandRunwayDraft - if err := q. - Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). + var rows []model.BrandRunway + if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope). + Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Offset((page - 1) * size). Limit(size). Find(&rows).Error; err != nil { @@ -87,8 +108,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, return rows, total, nil } -func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) { - var d model.BrandRunwayDraft +func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) { + var d model.BrandRunway if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound @@ -98,17 +119,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B return &d, nil } -func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) { - var imgs []model.BrandRunwayDraftImage +func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) { + var imgs []model.BrandRunwayImage err := r.db.WithContext(ctx). - Where("draft_id = ? AND is_deleted = 0", draftID). + Where("runway_id = ? AND is_deleted = 0", recordID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } -// draftEditable 审核前可微调的字段白名单(键即列名)。 -var draftEditable = map[string]bool{ +// recordEditable 审核前可微调的字段白名单(键即列名)。 +var recordEditable = map[string]bool{ "title_en": true, "title_cn": true, "description_en": true, @@ -119,11 +140,11 @@ var draftEditable = map[string]bool{ "season_code": true, } -// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。 -func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { +// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。 +func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { - if draftEditable[k] { + if recordEditable[k] { clean[k] = v } } @@ -132,154 +153,39 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). - Model(&model.BrandRunwayDraft{}). + Model(&model.BrandRunway{}). Where("id = ?", id). Updates(clean).Error } -func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { - return r.db.WithContext(ctx). - Model(&model.BrandRunwayDraft{}). +// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。 +// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL, +// 竞态窗口(记录刚被删除)下会给出假成功 302。 +// +// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。 +// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍 +//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做, +// 而不是悄悄在这个仓储方法里加限制。 +func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error { + res := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). Where("id = ?", id). Updates(map[string]any{ - "status": status, - "reviewer": reviewer, - "reject_reason": reason, - "updated_at": uint32(time.Now().Unix()), - }).Error + "status": status, + "reviewer": reviewer, + "updated_at": uint32(time.Now().Unix()), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil } -// SaveRunwayFromDraft 晋升草稿到正式表: -// 1. 按 source_url 查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行); -// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建; -// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建; -// 4. 返回正式表主键。 -// -// 在事务内完成,保证草稿与正式表一致性。 -func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) { - var draft model.BrandRunwayDraft - if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, ErrNotFound - } - return 0, err - } - // 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重, - // 七牛 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。 - curImgs, err := r.DraftImages(ctx, draftID) - if err != nil { - return 0, err - } - sibImgs, err := r.runwayApprovedSiblingImages(ctx, draftID, draft.BrandID, draft.SeasonCode, draft.CollectionType) - if err != nil { - return 0, err - } - imgs := unionRunwayDraftImages(curImgs, sibImgs) - - now := uint32(time.Now().Unix()) - var runwayID uint32 - err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 1) 按 source_url 查找既有正式走秀 - var existing model.BrandRunway - if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType). - Limit(1).Find(&existing).Error; eErr != nil { - return eErr - } - - common := map[string]any{ - "title_en": draft.TitleEn, - "title_cn": draft.TitleCn, - "description_en": draft.DescriptionEn, - "description_cn": draft.DescriptionCn, - "year": draft.Year, - "season": draft.Season, - "collection_type": draft.CollectionType, - "season_code": draft.SeasonCode, - "cover": draft.Cover, - "source_url": draft.SourceURL, - "image_count": uint16(len(imgs)), - "updated_at": now, - } - - if existing.ID != 0 { - runwayID = existing.ID - common["brand_id"] = existing.BrandID // 保留正式表的品牌归属 - if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", runwayID).Updates(common).Error; uErr != nil { - return uErr - } - } else { - rw := &model.BrandRunway{ - BrandID: draft.BrandID, - TitleEn: draft.TitleEn, - TitleCn: draft.TitleCn, - DescriptionEn: draft.DescriptionEn, - DescriptionCn: draft.DescriptionCn, - Year: draft.Year, - Season: draft.Season, - CollectionType: draft.CollectionType, - SeasonCode: draft.SeasonCode, - Cover: draft.Cover, - SourceURL: draft.SourceURL, - ImageCount: uint16(len(imgs)), - CreatedAt: now, - UpdatedAt: now, - } - if cErr := tx.Create(rw).Error; cErr != nil { - return cErr - } - runwayID = rw.ID - } - - // 2) 替换图片:软删旧 + 重建 - if dErr := tx.Model(&model.BrandRunwayImage{}). - Where("runway_id = ? AND is_deleted = 0", runwayID). - Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { - return dErr - } - if len(imgs) > 0 { - rows := make([]model.BrandRunwayImage, 0, len(imgs)) - for i, im := range imgs { - rows = append(rows, model.BrandRunwayImage{ - RunwayID: runwayID, - BrandID: draft.BrandID, - Image: im.Image, - Name: im.Name, - SortOrder: uint32(i + 1), - CreatedAt: now, - UpdatedAt: now, - }) - } - if cErr := tx.Create(&rows).Error; cErr != nil { - return cErr - } - } - return nil - }) - if err != nil { - return 0, err - } - return runwayID, nil -} - -func (r *reviewRepository) CountPending(ctx context.Context) (int64, error) { - var n1, n2 int64 - if err := r.db.WithContext(ctx). - Model(&model.BrandRunwayDraft{}). - Where("status = ? AND is_deleted = 0", model.DraftStatusPending). - Count(&n1).Error; err != nil { - return 0, err - } - if err := r.db.WithContext(ctx). - Model(&model.StreetSnapDraft{}). - Where("status = ? AND is_deleted = 0", model.DraftStatusPending). - Count(&n2).Error; err != nil { - return 0, err - } - return n1 + n2, nil -} - -// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。 -func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) { +// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。 +func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) { var n int64 if err := r.db.WithContext(ctx). Model(dest). @@ -290,29 +196,27 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, return n, nil } -// ---- 街拍草稿镜像实现(与 runway 同构) ---- +// ---- 街拍镜像实现(与 runway 同构) ---- -func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) { +func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } - q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0") - if status != "" { - q = q.Where("status = ?", status) - } + scope := reviewListScope(status) + var total int64 - if err := q.Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { - return []model.StreetSnapDraft{}, 0, nil + return []model.StreetSnap{}, 0, nil } - var rows []model.StreetSnapDraft - if err := q. - Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). + var rows []model.StreetSnap + if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope). + Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Offset((page - 1) * size). Limit(size). Find(&rows).Error; err != nil { @@ -321,8 +225,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, return rows, total, nil } -func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) { - var d model.StreetSnapDraft +func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) { + var d model.StreetSnap if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound @@ -332,26 +236,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m return &d, nil } -func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) { - var imgs []model.StreetSnapDraftImage +func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) { + var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). - Where("draft_id = ? AND is_deleted = 0", draftID). + Where("snap_id = ? AND is_deleted = 0", recordID). Order("sort_order ASC, id ASC"). Find(&imgs).Error return imgs, err } -// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。 -var streetDraftEditable = map[string]bool{ +// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。 +var streetRecordEditable = map[string]bool{ "title": true, "year": true, "city": true, } -func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error { +func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error { clean := map[string]any{} for k, v := range fields { - if streetDraftEditable[k] { + if streetRecordEditable[k] { clean[k] = v } } @@ -360,230 +264,82 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie } clean["updated_at"] = uint32(time.Now().Unix()) return r.db.WithContext(ctx). - Model(&model.StreetSnapDraft{}). + Model(&model.StreetSnap{}). Where("id = ?", id). Updates(clean).Error } -func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { - return r.db.WithContext(ctx). - Model(&model.StreetSnapDraft{}). +// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。 +// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。 +func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error { + res := r.db.WithContext(ctx). + Model(&model.StreetSnap{}). Where("id = ?", id). Updates(map[string]any{ - "status": status, - "reviewer": reviewer, - "reject_reason": reason, - "updated_at": uint32(time.Now().Unix()), - }).Error + "status": status, + "reviewer": reviewer, + "updated_at": uint32(time.Now().Unix()), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil } -// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snap(按 source_url 幂等 upsert,替换图片)。 -// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。 -// 在事务内完成,保证草稿与正式表一致性。 -func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) { - var draft model.StreetSnapDraft - if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, ErrNotFound - } - return 0, err - } - curImgs, err := r.StreetDraftImages(ctx, draftID) - if err != nil { - return 0, err - } - sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year) - if err != nil { - return 0, err - } - imgs := unionStreetDraftImages(curImgs, sibImgs) +// ── 图片软删 ──────────────────────────────────────────────────────────────── - now := uint32(time.Now().Unix()) - var snapID uint32 - err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.StreetSnap - if eErr := tx.Where("city = ? AND year = ? AND is_deleted = 0", draft.City, draft.Year). - Limit(1).Find(&existing).Error; eErr != nil { - return eErr - } - - common := map[string]any{ - "title": draft.Title, - "year": draft.Year, - "city": draft.City, - "cover": draft.Cover, - "source_url": draft.SourceURL, - "image_count": uint16(len(imgs)), - "updated_at": now, - } - - if existing.ID != 0 { - snapID = existing.ID - if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", snapID).Updates(common).Error; uErr != nil { - return uErr - } - } else { - snap := &model.StreetSnap{ - Title: draft.Title, - Year: draft.Year, - City: draft.City, - Cover: draft.Cover, - SourceURL: draft.SourceURL, - ImageCount: uint16(len(imgs)), - CreatedAt: now, - UpdatedAt: now, - } - if cErr := tx.Create(snap).Error; cErr != nil { - return cErr - } - snapID = snap.ID - } - - // 替换图片:软删旧 + 重建 - if dErr := tx.Model(&model.StreetSnapImage{}). - Where("snap_id = ? AND is_deleted = 0", snapID). - Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil { - return dErr - } - if len(imgs) > 0 { - rows := make([]model.StreetSnapImage, 0, len(imgs)) - for i, im := range imgs { - rows = append(rows, model.StreetSnapImage{ - SnapID: snapID, - Image: im.Image, - Name: im.Name, - SortOrder: uint32(i + 1), - CreatedAt: now, - UpdatedAt: now, - }) - } - if cErr := tx.Create(&rows).Error; cErr != nil { - return cErr - } - } - return nil - }) - if err != nil { - return 0, err - } - return snapID, nil -} - -// runwayApprovedSiblingImages 返回同实体(品牌+季节码+系列)且已 approved 的其他草稿的图片, -// 用于多来源聚合:当前正被审核的草稿强制纳入,其余仅合并已审核来源,避免未审图泄露进正式表。 -func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excludeDraftID, brandID uint32, seasonCode, collectionType string) ([]model.BrandRunwayDraftImage, error) { - var drafts []model.BrandRunwayDraft +// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。 +// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。 +// +// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」, +// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行, +// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。 +// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建, +// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。 +// +// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks), +// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。 +func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). - Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID). - Find(&drafts).Error; err != nil { - return nil, err - } - out := make([]model.BrandRunwayDraftImage, 0) - for _, d := range drafts { - imgs, err := r.DraftImages(ctx, d.ID) - if err != nil { - return nil, err - } - out = append(out, imgs...) - } - return out, nil -} - -// unionRunwayDraftImages 按 image URL 去重合并(七牛 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。 -func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage { - seen := make(map[string]struct{}, len(a)+len(b)) - out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b)) - for _, im := range a { - if _, ok := seen[im.Image]; ok { - continue - } - seen[im.Image] = struct{}{} - out = append(out, im) - } - for _, im := range b { - if _, ok := seen[im.Image]; ok { - continue - } - seen[im.Image] = struct{}{} - out = append(out, im) - } - return out -} - -// streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。 -func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) { - var drafts []model.StreetSnapDraft - if err := r.db.WithContext(ctx). - Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID). - Find(&drafts).Error; err != nil { - return nil, err - } - out := make([]model.StreetSnapDraftImage, 0) - for _, d := range drafts { - imgs, err := r.StreetDraftImages(ctx, d.ID) - if err != nil { - return nil, err - } - out = append(out, imgs...) - } - return out, nil -} - -// unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。 -func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage { - seen := make(map[string]struct{}, len(a)+len(b)) - out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b)) - for _, im := range a { - if _, ok := seen[im.Image]; ok { - continue - } - seen[im.Image] = struct{}{} - out = append(out, im) - } - for _, im := range b { - if _, ok := seen[im.Image]; ok { - continue - } - seen[im.Image] = struct{}{} - out = append(out, im) - } - return out -} - -// ── 草稿图片删除 ──────────────────────────────────────────────────────────────── - -// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。 -// 不删七牛文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。 -func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error { - if err := r.db.WithContext(ctx). - Model(&model.BrandRunwayDraftImage{}). - Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). + Model(&model.BrandRunwayImage{}). + Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } return r.db.WithContext(ctx). - Exec("UPDATE brand_runway_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error + Exec(`UPDATE brand_runways SET image_count = + (SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0) + WHERE id = ?`, recordID, recordID).Error } -// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 -func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { +// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。 +// +// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图), +// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的, +// 与走秀「is_detail 是入库时的细节图」语义不同。 +func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error { if err := r.db.WithContext(ctx). - Model(&model.StreetSnapDraftImage{}). - Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). + Model(&model.StreetSnapImage{}). + Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID). Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return err } return r.db.WithContext(ctx). - Exec("UPDATE street_snap_draft SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error + Exec(`UPDATE street_snaps SET image_count = + (SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0) + WHERE id = ?`, recordID, recordID).Error } -// GetDraftImageKey 取单张草稿图片的七牛 key(删除前清理用);不存在返回空串。 -// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。 -func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) { +// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。 +// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。 +func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) { switch kind { case dto.IngestKindRunway: - var im model.BrandRunwayDraftImage - err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error + var im model.BrandRunwayImage + err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } @@ -592,8 +348,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr } return im.Image, nil case dto.IngestKindStreet: - var im model.StreetSnapDraftImage - err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error + var im model.StreetSnapImage + err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } @@ -605,3 +361,143 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr return "", ErrNotFound } } + +// ── 街拍主副图分组 ────────────────────────────────────────────────────────── + +// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 +// +// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图 +// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。 +func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error { + ids := make([]uint32, 0, len(imgIDs)) + for _, id := range imgIDs { + if id != 0 && id != mainImgID { + ids = append(ids, id) + } + } + if mainImgID == 0 || len(imgIDs) == 0 { + // 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。 + return ErrInvalidImageGroup + } + if len(ids) == 0 { + // 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。 + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var main model.StreetSnapImage + if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID). + First(&main).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidImageGroup + } + return err + } + + now := uint32(time.Now().Unix()) + if err := tx.Model(&model.StreetSnapImage{}). + Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids). + Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { + return err + } + return tx.Model(&model.StreetSnapImage{}). + Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids). + Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error + }) +} + +// DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。 +// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 +func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error { + return r.db.WithContext(ctx). + Model(&model.StreetSnapImage{}). + Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID). + Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). + Error +} + +// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。 +// +// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」 +// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。 +func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error { + ids := make([]uint32, 0, len(imgIDs)) + for _, id := range imgIDs { + if id != 0 && id != mainImgID { + ids = append(ids, id) + } + } + if mainImgID == 0 || len(imgIDs) == 0 { + return ErrInvalidImageGroup + } + if len(ids) == 0 { + return nil // 集合里只有主图自身:无操作成功 + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var main model.BrandRunwayImage + if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID). + First(&main).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidImageGroup + } + return err + } + now := uint32(time.Now().Unix()) + // 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。 + if err := tx.Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids). + Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { + return err + } + // 再把被并入图自身标为副图并指向新主图。 + return tx.Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids). + Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error + }) +} + +// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。 +// 同主图下其余明细图仍挂在该主图之下,不变量成立。 +func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error { + return r.db.WithContext(ctx). + Model(&model.BrandRunwayImage{}). + Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID). + Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). + Error +} + +// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id: +// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。 +// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。 +// 供入库后调用,使新入库的走秀自动成组,无需人工合并。 +func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error { + var imgs []model.BrandRunwayImage + if err := r.db.WithContext(ctx). + Where("runway_id = ? AND is_deleted = 0", runwayID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error; err != nil { + return err + } + lastMain := uint32(0) + type fix struct{ id, parent uint32 } + fixes := make([]fix, 0) + for _, im := range imgs { + if im.IsDetail == 0 { + lastMain = im.ID + continue + } + if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 { + fixes = append(fixes, fix{im.ID, lastMain}) + } + } + for _, f := range fixes { + if err := r.db.WithContext(ctx). + Model(&model.BrandRunwayImage{}). + Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID). + Update("parent_image_id", f.parent).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/repository/review_status_transition_integration_test.go b/internal/repository/review_status_transition_integration_test.go new file mode 100644 index 0000000..887d971 --- /dev/null +++ b/internal/repository/review_status_transition_integration_test.go @@ -0,0 +1,103 @@ +//go:build integration + +// 集成测试:「通过 → 公开可见」这条单表发布主链路的**仓储级**证据。 +// +// 背景:公开视图测试(TestPublicViewsHideUnpublished*)都是直接 `db.Model(...).Update("status", ...)` +// 改库、绕过了 `Approve` 走的 SetRecordStatus / SetStreetRecordStatus,这两个仓储方法在全仓零直接引用。 +// 一旦它们改错列 / 漏写 status,那些测试仍会全绿、线上却无法通过审核。本文件补上这一段。 +// +// 运行:go test -tags integration ./internal/repository/ -run TestSetRecordStatus -v +package repository + +import ( + "context" + "errors" + "testing" + + "fashionapi/internal/model" +) + +// TestSetRecordStatusDrivesPublicVisibility 走秀:pending 经仓储置 published 后在公开视图可见; +// 再置 rejected 后不可见;对不存在的 id 返回 ErrNotFound。 +func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewReviewRepository(db) + ctx := context.Background() + + id := seedRunway(t, db, model.StatusPending, 1991) + publicCount := func() int64 { + var n int64 + db.Table("public_brand_runways").Where("id = ?", id).Count(&n) + return n + } + + if got := publicCount(); got != 0 { + t.Fatalf("pending 不应出现在公开视图,实际 %d", got) + } + + if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil { + t.Fatalf("SetRecordStatus(published) 出错: %v", err) + } + if got := publicCount(); got != 1 { + t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got) + } + + if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil { + t.Fatalf("SetRecordStatus(rejected) 出错: %v", err) + } + if got := publicCount(); got != 0 { + t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got) + } + + // 审核痕迹应落库,便于列表页展示。 + var rw model.BrandRunway + if err := db.Where("id = ?", id).First(&rw).Error; err != nil { + t.Fatalf("读回记录失败: %v", err) + } + if rw.Status != model.StatusRejected || rw.Reviewer != "admin" { + t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer) + } + + // 不存在的 id:必须返回 ErrNotFound,而不是假成功。 + if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) { + t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err) + } +} + +// TestSetStreetRecordStatusDrivesPublicVisibility 街拍:与走秀同形,覆盖另一组方法与视图。 +func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + repo := NewReviewRepository(db) + ctx := context.Background() + + id := seedStreetSnap(t, db, model.StatusPending, 1990) + publicCount := func() int64 { + var n int64 + db.Table("public_street_snaps").Where("id = ?", id).Count(&n) + return n + } + + if got := publicCount(); got != 0 { + t.Fatalf("pending 不应出现在公开视图,实际 %d", got) + } + + if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil { + t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err) + } + if got := publicCount(); got != 1 { + t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got) + } + + if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil { + t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err) + } + if got := publicCount(); got != 0 { + t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got) + } + + if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) { + t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err) + } +} diff --git a/internal/repository/street_main_detail_integration_test.go b/internal/repository/street_main_detail_integration_test.go new file mode 100644 index 0000000..ebf3545 --- /dev/null +++ b/internal/repository/street_main_detail_integration_test.go @@ -0,0 +1,323 @@ +//go:build integration + +// 集成测试:街拍主副图分组需要真实 PostgreSQL。 +// +// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images), +// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。 +// +// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节): +// +// docker compose -f scripts/pgvector/docker-compose.yml up -d +// go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v +// +// 默认 `go test ./...` 不会编译本文件(build tag 隔离)。 +package repository + +import ( + "context" + "errors" + "fmt" + "testing" + + "fashionapi/internal/model" +) + +// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。 +// 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, 空串)) WHERE is_deleted = 0, +// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。 +var mainDetailSeedSeq int + +// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。 +// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。 +func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 { + t.Helper() + ctx := context.Background() + db := repo.(*reviewRepository).db + mainDetailSeedSeq++ + snap := model.StreetSnap{ + Title: "主副图测试", + Year: 2026, + City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq), + Status: model.StatusPending, + } + if err := db.WithContext(ctx).Create(&snap).Error; err != nil { + t.Fatalf("插入街拍记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID) + db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID) + }) + return snap.ID +} + +// seedMainDetailImage 插入一张正式街拍图并返回其行 id。 +func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 { + t.Helper() + img := model.StreetSnapImage{SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order} + if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { + t.Fatalf("插入街拍图失败: %v", err) + } + return img.ID +} + +// reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。 +func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage { + t.Helper() + imgs, err := repo.StreetRecordImages(context.Background(), snapID) + if err != nil { + t.Fatalf("读街拍图失败: %v", err) + } + return imgs +} + +// TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。 +func TestStreetMainDetail(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + snapID := seedMainDetailSnap(t, repo) + m1 := seedMainDetailImage(t, repo, snapID, "a", 1) + d2 := seedMainDetailImage(t, repo, snapID, "b", 2) + d3 := seedMainDetailImage(t, repo, snapID, "c", 3) + + if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil { + t.Fatalf("并入失败: %v", err) + } + for _, im := range reloadSnapImages(t, repo, snapID) { + switch im.ID { + case m1: + if im.IsDetail != 0 || im.ParentImageID != 0 { + t.Fatalf("主图应保持 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + case d2, d3: + if im.IsDetail != 1 || im.ParentImageID != m1 { + t.Fatalf("副图应指向 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID) + } + } + } + + // 幂等:重复并入同一批不报错,结果不变。 + if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil { + t.Fatalf("重复并入应幂等,实际报错: %v", err) + } + + // 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。 + if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil { + t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err) + } + for _, im := range reloadSnapImages(t, repo, snapID) { + if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) { + t.Fatalf("主图被自身并入后不应变化") + } + } + + // 拆出:把 d3 恢复为主图。 + if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil { + t.Fatalf("拆出失败: %v", err) + } + for _, im := range reloadSnapImages(t, repo, snapID) { + if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) { + t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + } + if err := repo.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功 + t.Fatalf("对主图拆出应幂等成功: %v", err) + } + + // 跨记录拦截:空集合 + 跨记录主图。 + if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { + t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) + } + otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1) + if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) { + t.Fatalf("跨记录主图应被拦截,实际 %v", err) + } +} + +// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。 +func TestStreetMainDetailReparentGrandchildren(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + snapID := seedMainDetailSnap(t, repo) + m1 := seedMainDetailImage(t, repo, snapID, "m1", 1) + m2 := seedMainDetailImage(t, repo, snapID, "m2", 2) + child := seedMainDetailImage(t, repo, snapID, "child", 3) + + if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil { + t.Fatalf("预置副图失败: %v", err) + } + if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil { + t.Fatalf("并入整组失败: %v", err) + } + for _, im := range reloadSnapImages(t, repo, snapID) { + if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) { + t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + if im.ID == child && (im.IsDetail != 1 || im.ParentImageID != m1) { + t.Fatalf("child 应改挂到 m1,实际 %d/%d", im.IsDetail, im.ParentImageID) + } + } +} + +// ── image_count 重算(软删不漂移) ───────────────────────────────────────────── + +// snapImageCount 读某街拍记录当前的 image_count。 +func snapImageCount(t *testing.T, repo ReviewRepository, snapID uint32) uint16 { + t.Helper() + var row struct { + ImageCount uint16 `gorm:"column:image_count"` + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()). + Table("street_snaps").Select("image_count").Where("id = ?", snapID).Scan(&row).Error; err != nil { + t.Fatalf("读街拍 image_count 失败: %v", err) + } + return row.ImageCount +} + +// runwayImageCount 读某走秀记录当前的 image_count。 +func runwayImageCount(t *testing.T, repo ReviewRepository, runwayID uint32) uint16 { + t.Helper() + var row struct { + ImageCount uint16 `gorm:"column:image_count"` + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()). + Table("brand_runways").Select("image_count").Where("id = ?", runwayID).Scan(&row).Error; err != nil { + t.Fatalf("读走秀 image_count 失败: %v", err) + } + return row.ImageCount +} + +// seedMainDetailRunway 插入一条走秀正式记录(status=pending)并返回其 id。 +// SeasonCode 用递增序号,避开 uq_br_entity (brand_id, season_code, collection_type) 部分唯一索引。 +func seedMainDetailRunway(t *testing.T, repo ReviewRepository) uint32 { + t.Helper() + db := repo.(*reviewRepository).db + mainDetailSeedSeq++ + rw := model.BrandRunway{ + BrandID: 1, + TitleEn: "主副图计数测试", + Year: 2026, + SeasonCode: fmt.Sprintf("MD%d", mainDetailSeedSeq), + CollectionType: "rtw", + Status: model.StatusPending, + } + if err := db.WithContext(context.Background()).Create(&rw).Error; err != nil { + t.Fatalf("插入走秀记录失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID) + db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID) + }) + return rw.ID +} + +// seedMainDetailRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回其行 id。 +func seedMainDetailRunwayImage(t *testing.T, repo ReviewRepository, runwayID uint32, name string, order uint32, isDetail uint8) uint32 { + t.Helper() + img := model.BrandRunwayImage{ + RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name, + SortOrder: order, IsDetail: isDetail, + } + if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { + t.Fatalf("插入走秀图失败: %v", err) + } + return img.ID +} + +// TestSoftDeleteStreetImageRecomputesCount 软删街拍图片后 image_count 必须按实际存活行**重算**。 +// +// 对「已被软删」或「不属于该记录」的 imgID 重放一次,计数必须不变: +// 旧实现是 `image_count - 1`(GREATEST 钳到 0),命中 0 行时仍会递减且永不回补; +// 单表发布模型删掉了晋升时按实际行重算的自愈路径,漂移会永久留在公开列表卡片的「N 张」上。 +func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + snapID := seedMainDetailSnap(t, repo) + keep := seedMainDetailImage(t, repo, snapID, "keep", 1) + drop := seedMainDetailImage(t, repo, snapID, "drop", 2) + + if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil { + t.Fatalf("软删失败: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("软删一张后 image_count 应为 1,实际 %d", got) + } + + // 重放同一张(已被软删):命中 0 行,计数不得变化。 + if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil { + t.Fatalf("重复软删应幂等,实际报错: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("重复软删不应改变 image_count,实际 %d(旧递减实现会掉到 0)", got) + } + + // 不属于该记录的 imgID:同样不得影响本记录计数。 + otherImg := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "other", 1) + if err := repo.SoftDeleteStreetImage(ctx, snapID, otherImg); err != nil { + t.Fatalf("跨记录软删不应报错: %v", err) + } + if got := snapImageCount(t, repo, snapID); got != 1 { + t.Fatalf("跨记录 imgID 不应改变 image_count,实际 %d", got) + } + + imgs, err := repo.StreetRecordImages(ctx, snapID) + if err != nil { + t.Fatalf("读存活图失败: %v", err) + } + if len(imgs) != 1 || imgs[0].ID != keep { + t.Fatalf("应只剩 keep 一行,实际 %+v", imgs) + } +} + +// TestSoftDeleteRunwayImage 走秀 image_count 口径是「存活主图行数」(与入库 countMainImages 一致)。 +// +// 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 is_detail=1 的行。 +// 若先删细节图再删主图,过滤与否结果相同(旧写法就锁不住口径),故这里把顺序倒过来。 +func TestSoftDeleteRunwayImage(t *testing.T) { + repo := NewReviewRepository(testDB(t)) + ctx := context.Background() + + runwayID := seedMainDetailRunway(t, repo) + main1 := seedMainDetailRunwayImage(t, repo, runwayID, "main1", 1, 0) + main2 := seedMainDetailRunwayImage(t, repo, runwayID, "main2", 2, 0) + detail := seedMainDetailRunwayImage(t, repo, runwayID, "detail1", 3, 1) + + // 先软删一张主图,细节图此刻仍存活:只计主图 → 1(仅 main1)。 + // 若重算用 COUNT(*) 不排除 is_detail,会把存活的 detail 一并算进来得 2 —— 本断言锁得住。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil { + t.Fatalf("软删主图失败: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 1 { + t.Fatalf("image_count 只计存活主图,软删一张主图后应为 1(须排除仍存活的细节图),实际 %d", got) + } + + // 再软删细节图:主图数不变(细节图本就不计入)。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil { + t.Fatalf("软删细节图失败: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 1 { + t.Fatalf("软删细节图不应改变主图计数,实际 %d", got) + } + + // 重放(已软删)+ 跨记录:计数均不得变化。 + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil { + t.Fatalf("重复软删应幂等: %v", err) + } + otherMain := seedMainDetailRunwayImage(t, repo, seedMainDetailRunway(t, repo), "x", 1, 0) + if err := repo.SoftDeleteRunwayImage(ctx, runwayID, otherMain); err != nil { + t.Fatalf("跨记录软删不应报错: %v", err) + } + if got := runwayImageCount(t, repo, runwayID); got != 1 { + t.Fatalf("重放 / 跨记录不应改变 image_count,实际 %d", got) + } + + imgs, err := repo.RecordImages(ctx, runwayID) + if err != nil { + t.Fatalf("读存活主图失败: %v", err) + } + if len(imgs) != 1 || imgs[0].ID != main1 { + t.Fatalf("应只剩 main1 一行,实际 %+v", imgs) + } +} diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go index 1175c36..96e577f 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -16,10 +16,17 @@ import ( type StreetSnapRepository interface { // List 按筛选条件分页查询街拍,同时返回符合条件的总数。 List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) - // FindByID 查询单条街拍;不存在时返回 ErrNotFound。 + // ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。 + ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) + // FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。 FindByID(ctx context.Context, id string) (*model.StreetSnap, error) - // ListImages 查询某条街拍的全部图片,按排序值升序。 + // FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。 + // 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。 + FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) + // ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。 ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) + // ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。 + ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) // ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。 ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) // Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。 @@ -28,9 +35,9 @@ type StreetSnapRepository interface { GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) // UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。 UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error - // DeleteSnapImage 软删单张街拍图片。 + // DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。 DeleteSnapImage(ctx context.Context, imageID uint32) error - // GetSnapImageKey 取单张街拍图片的七牛 key(删除前清理用);不存在返回空串。 + // GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。 GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) // SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。 SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) @@ -38,6 +45,8 @@ type StreetSnapRepository interface { SetSnapCover(ctx context.Context, snapID uint32, image string) error // SetSnapDeleted 上下架:deleted=1 下架,0 上架。 SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, snapID uint32) (int64, error) } type streetSnapRepository struct { @@ -67,17 +76,47 @@ func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB { func snapOrderBy(sort string) string { switch sort { case "year_asc": - return "street_snap.year ASC, street_snap.id ASC" + return "street_snaps.year ASC, street_snaps.id ASC" case "year_desc": - return "street_snap.year DESC, street_snap.id DESC" + return "street_snaps.year DESC, street_snaps.id DESC" default: // image_count - return "street_snap.image_count DESC, street_snap.id DESC" + return "street_snaps.image_count DESC, street_snaps.id DESC" } } +// List 公开读:读公开视图,pending / rejected 街拍不可见。 func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { scope := snapFilterScope(q) + var total int64 + if err := r.db.WithContext(ctx). + Table(publicStreetSnaps). + Scopes(scope). + Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []model.StreetSnap{}, 0, nil + } + + var rows []model.StreetSnap + if err := r.db.WithContext(ctx). + Table(publicStreetSnaps). + Scopes(scope). + Order(snapOrderBy(q.Sort)). + Offset(q.Offset()). + Limit(q.Size). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected, +// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。 +func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { + scope := snapFilterScope(q) + var total int64 if err := r.db.WithContext(ctx). Model(&model.StreetSnap{}). @@ -102,11 +141,20 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) return rows, total, nil } +// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。 func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) { + return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id) +} + +// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。 +func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) { + return r.findByID(r.db.WithContext(ctx), id) +} + +// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。 +func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) { var snap model.StreetSnap - err := r.db.WithContext(ctx). - Where("id = ? AND is_deleted = 0", id). - First(&snap).Error + err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound @@ -116,7 +164,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model. return &snap, nil } +// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。 func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { + var imgs []model.StreetSnapImage + err := r.db.WithContext(ctx). + Table(publicStreetSnapImages). + Where("snap_id = ?", snapID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + return imgs, err +} + +// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。 +func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). Where("snap_id = ? AND is_deleted = 0", snapID). @@ -133,8 +193,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32 var imgs []model.StreetSnapImage err := r.db.WithContext(ctx). + Table(publicStreetSnapImages). Select("snap_id, image, name, sort_order"). - Where("snap_id IN ? AND is_deleted = 0", ids). + Where("snap_id IN ?", ids). Order("sort_order ASC, id ASC"). Find(&imgs).Error if err != nil { @@ -146,11 +207,11 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32 return result, nil } -// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。 // Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。 func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) { var rows []model.StreetSnap if err := r.db.WithContext(ctx). + Table(publicStreetSnaps). Where("is_deleted = 0"). Order("image_count DESC, id DESC"). Limit(limit). @@ -164,10 +225,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model. // snapEditable 后台可编辑的街拍字段白名单(键即列名)。 var snapEditable = map[string]bool{ - "title": true, - "year": true, - "city": true, - "cover": true, + "title": true, + "title_cn": true, + "year": true, + "city": true, + "cover": true, } func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { @@ -178,7 +240,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode } return nil, nil, err } - imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10)) + imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10)) if err != nil { return nil, nil, err } @@ -202,11 +264,63 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields Updates(clean).Error } +// DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。 +// +// 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算 +// (`is_deleted = 0`,主图 + 副图全部计入)。 +// +// 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型 +// 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。 +// +// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。 +// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。 func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error { - return r.db.WithContext(ctx). - Model(&model.StreetSnapImage{}). - Where("id = ? AND is_deleted = 0", imageID). - Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var img model.StreetSnapImage + err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + // 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。 + var snap model.StreetSnap + if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil { + return err + } + if err := tx.Model(&model.StreetSnapImage{}). + Where("id = ? AND is_deleted = 0", imageID). + Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return err + } + if err := tx.Exec(`UPDATE street_snaps SET image_count = + (SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0) + WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil { + return err + } + // 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。 + if snap.Cover == img.Image { + cover := "" + var survivor struct { + Image string `gorm:"column:image"` + } + serr := tx.Raw( + "SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1", + img.SnapID, + ).Scan(&survivor).Error + if serr == nil { + cover = survivor.Image + } else if !errors.Is(serr, gorm.ErrRecordNotFound) { + return serr + } + if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID). + Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return err + } + } + return nil + }) } func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) { @@ -222,7 +336,7 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint } // SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。 -// 先取非删除图片的 key 列表返回给调用方(用于后续七牛孤儿清理),再统一置 is_deleted=1。 +// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。 func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) { var imgs []model.StreetSnapImage if err := r.db.WithContext(ctx). @@ -243,6 +357,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { return nil, err } + // 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。 + if err := r.db.WithContext(ctx). + Model(&model.StreetSnap{}). + Where("id = ?", snapID). + Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil { + return nil, err + } return keys, nil } @@ -259,3 +380,14 @@ func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, de Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error } + +// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(image_size 由 worker 下载时写入源文件字节数)。 +func (r *streetSnapRepository) SumImageSize(ctx context.Context, snapID uint32) (int64, error) { + var total int64 + err := r.db.WithContext(ctx). + Model(&model.StreetSnapImage{}). + Where("snap_id = ? AND is_deleted = 0", snapID). + Select("COALESCE(SUM(image_size), 0)"). + Scan(&total).Error + return total, err +} diff --git a/internal/repository/street_title_normalize_integration_test.go b/internal/repository/street_title_normalize_integration_test.go new file mode 100644 index 0000000..4cd5ac5 --- /dev/null +++ b/internal/repository/street_title_normalize_integration_test.go @@ -0,0 +1,48 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + "fashionapi/internal/model" +) + +// TestStreetTitleNormalizationUnifiesEntity 验证街拍实体键标题归一化: +// 落库标题带多余空白(模拟爬虫原始上报)经 NormalizeStreetTitle 折叠后写入, +// 查重侧用未归一化的原始标题(多空格)也应命中同一实体,不会被判成新实体。 +// +// 设计规格 §12.4:纯空白微调的同一专题若被当作新实体,会多出重复行。 +func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) { + db := testDB(t) + applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") + applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql") + applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql") + + repo := NewIngestRepository(db) + ctx := context.Background() + city, year := "NormCity", uint16(2099) + + // 落库口径:与 processStreet 一致,先归一化再写。 + stored := NormalizeStreetTitle("Norm Show 2026") + id, err := repo.CreateStreetSnapWithImages(ctx, &model.StreetSnap{ + JobID: 1, Title: stored, Year: year, City: city, Status: model.StatusPending, + }, []model.StreetSnapImage{{Image: "norm.jpg", SortOrder: 1}}) + if err != nil { + t.Fatalf("插入归一化标题失败: %v", err) + } + t.Cleanup(func() { + db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id) + db.Exec("DELETE FROM street_snaps WHERE id = ?", id) + }) + + // 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。 + hid, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026") + if err != nil { + t.Fatalf("查重出错: %v", err) + } + if !found || hid != id { + t.Fatalf("归一化后应命中同一实体 id=%d,实际 found=%v hid=%d", id, found, hid) + } +} diff --git a/internal/repository/testhelper_integration_test.go b/internal/repository/testhelper_integration_test.go new file mode 100644 index 0000000..4a83677 --- /dev/null +++ b/internal/repository/testhelper_integration_test.go @@ -0,0 +1,46 @@ +//go:build integration + +// 集成测试的公共辅助:连接真实 PostgreSQL + pgvector。 +// +// 运行方式(先起库,见 scripts/pgvector): +// +// docker compose -f scripts/pgvector/docker-compose.yml up -d +// go test -tags integration ./internal/repository/ -run -v +// +// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。 +package repository + +import ( + "testing" + + "fashionapi/internal/config" + "fashionapi/internal/database" + + "gorm.io/gorm" +) + +// testDB 连真实库并校验所需结构已就绪。 +// +// 结构不再由 AutoMigrate 自动创建(已移除),须先自行准备好库,例如: +// +// ./bin/dbtool dump -out db_dump.sql +// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql +// +// 这里只做存在性校验并给出明确提示,避免后续查询报出难懂的错。 +func testDB(t *testing.T) *gorm.DB { + t.Helper() + cfg, err := config.Load("") + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + db, err := database.New(cfg.Database) + if err != nil { + t.Fatalf("连接数据库失败: %v", err) + } + t.Cleanup(func() { _ = database.Close(db) }) + + if err := db.Exec("SELECT 1 FROM brand_runway_images LIMIT 1").Error; err != nil { + t.Fatalf("brand_runway_images 不可用(库结构未就绪?先 dbtool dump 再 psql -f 灌库): %v", err) + } + return db +} diff --git a/internal/repository/translate_repository.go b/internal/repository/translate_repository.go new file mode 100644 index 0000000..a230bed --- /dev/null +++ b/internal/repository/translate_repository.go @@ -0,0 +1,57 @@ +package repository + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" +) + +// TitleCnMemory 翻译记忆表行(title_cn_memory)。 +type TitleCnMemory struct { + TitleEn string `gorm:"primaryKey;column:title_en"` + TitleCn string `gorm:"column:title_cn"` + UpdatedAt uint32 `gorm:"column:updated_at"` +} + +// TableName 指定记忆表名。 +func (TitleCnMemory) TableName() string { return "title_cn_memory" } + +// TranslateRepository 标题翻译记忆的读写接口。 +type TranslateRepository interface { + // GetTitleCnMemory 取某英文标题的人工中文翻译;不存在返回 (_, false, nil)。 + GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) + // UpsertTitleCnMemory 幂等写入/更新一条映射。 + UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error +} + +type translateRepository struct { + db *gorm.DB +} + +// NewTranslateRepository 创建翻译记忆仓储。 +func NewTranslateRepository(db *gorm.DB) TranslateRepository { + return &translateRepository{db: db} +} + +func (r *translateRepository) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) { + var row TitleCnMemory + err := r.db.WithContext(ctx).Where("title_en = ?", titleEn).First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", false, nil + } + return "", false, err + } + return row.TitleCn, true, nil +} + +func (r *translateRepository) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error { + now := uint32(time.Now().Unix()) + row := TitleCnMemory{TitleEn: titleEn, TitleCn: titleCn, UpdatedAt: now} + return r.db.WithContext(ctx). + Where("title_en = ?", titleEn). + Assign(TitleCnMemory{TitleCn: titleCn, UpdatedAt: now}). + FirstOrCreate(&row).Error +} diff --git a/internal/router/backstage.go b/internal/router/backstage.go index 5891136..3bd8d6e 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { r.Static(opt.UploadURLPrefix, opt.UploadDir) } + // 后台自有静态资源(admin.css / admin.js):embed 进二进制,无 CDN、无构建产物依赖; + // 给一段缓存时间,避免每翻一页都重新下载。 + assets := r.Group("/admin/assets") + assets.Use(func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=600") + c.Next() + }) + assets.StaticFS("/", http.FS(handler.BackstageAssetFS())) + // 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验) r.GET("/admin/login", opt.Backstage.LoginPage) r.POST("/admin/login", opt.Backstage.Login) @@ -56,8 +65,6 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { internal.Use(middleware.IngestAuth(*opt.IngestAuth)) internal.POST("/ingest", opt.Ingest.Submit) internal.GET("/crawl/brands", opt.Ingest.CrawlBrands) - // 图集预检:爬虫抓详情页前批量问「这些 source_url 是否已爬取过」,跳过已存在的。 - internal.POST("/crawl/exists", opt.Ingest.CrawlExists) } else { r.POST("/admin/internal/ingest", func(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingest disabled"}) @@ -67,35 +74,50 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { // 受保护:后台页面,统一走 cookie 鉴权中间件 admin := r.Group("/admin") admin.Use(requireBackstageLogin(opt.JWT)) + // 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度 + admin.Use(opt.Backstage.NavStats()) { + // 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 + // 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。 + admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch) + admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI) + admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI) + // 编辑页实时翻译建议(已登录后台):英文标题 → 建议中文标题。 + admin.GET("/api/suggest-title-cn", opt.Backstage.SuggestTitleCn) + admin.GET("/", opt.Backstage.Dashboard) admin.GET("/brands", opt.Backstage.Brands) admin.GET("/brands/new", opt.Backstage.BrandNew) admin.POST("/brands", opt.Backstage.BrandCreate) admin.GET("/brands/:id", opt.Backstage.BrandEdit) admin.POST("/brands/:id", opt.Backstage.BrandUpdate) - admin.GET("/runways", opt.Backstage.Runways) + // 软删品牌(is_deleted=1)。有未删除图集时 service 会拒绝,不做隐式级联删除。 + admin.POST("/brands/:id/delete", opt.Backstage.BrandDelete) + // 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。 + admin.GET("/articles", opt.Backstage.Articles) + // 手工新建文章:表单 + 提交(multipart:元数据 + 多张图片,一步到位)。 + admin.GET("/articles/new", opt.Backstage.ArticleNew) + admin.POST("/articles", opt.Backstage.ArticleCreate) + admin.POST("/articles/batch", opt.Backstage.ArticleBatch) + admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove) + // 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。 + admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages) + admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage) + + // 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。 + // 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。 admin.GET("/runways/:id", opt.Backstage.RunwayDetail) admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit) admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate) admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete) admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover) admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted) - admin.GET("/street-snaps", opt.Backstage.StreetSnaps) admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail) admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit) admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate) admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete) admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover) admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted) - // 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。 - // 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。 - admin.GET("/reviews", opt.Backstage.ReviewList) - admin.GET("/reviews/:kind", opt.Backstage.ReviewList) - admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail) - admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove) - admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject) - admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete) // 爬虫入库任务监控:列出处理进度 + 失败重试 admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry) diff --git a/internal/router/backstage_test.go b/internal/router/backstage_test.go index b392e24..c7fc5ae 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -2,6 +2,7 @@ package router import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -12,8 +13,12 @@ import ( "fashionapi/internal/dto" "fashionapi/internal/handler" "fashionapi/internal/model" + "fashionapi/internal/pkg/hashid" "fashionapi/internal/pkg/jwt" + "fashionapi/internal/repository" "fashionapi/internal/service" + + "github.com/gin-gonic/gin" ) // ── 测试用假实现(不连库)────────────────────────────────────────────── @@ -47,6 +52,11 @@ type fakeBrand struct{} func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) { return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil } + +// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。 +func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) { + return f.List(ctx, q) +} func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) { return nil, nil } @@ -65,6 +75,12 @@ func (f *fakeBrand) Update(ctx context.Context, uid, nameEn, nameCn string) erro } return nil } +func (f *fakeBrand) Delete(ctx context.Context, uid string) error { + if uid != "001DESke" { + return service.ErrBrandNotFound + } + return nil +} func (f *fakeBrand) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) { return []dto.CrawlBrand{{BrandUID: "001DESke", Name: "Acne Studios"}}, nil } @@ -99,16 +115,22 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto. CollectionType: "rtw", SeasonCode: "SS24", ImageCount: 12, - SourceURL: "https://www.vogue.com/fashion-shows", }}, 1, nil } -func (f *fakeArticle) Detail(ctx context.Context, id, locale string, hd bool) (*dto.PublicArticleDetail, error) { + +// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到, +// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。 +func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) { + return nil, service.ErrArticleNotFound +} + +// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。 +func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) { return &dto.PublicArticleDetail{ UID: id, BrandUID: "001DESke", - Title: "Acne Studios Spring 2024", + Title: "Acne Studios Spring 2024(待审)", BrandName: "Acne Studios", - SourceURL: "https://www.vogue.com/fashion-shows", Images: []dto.PublicArticleImage{ {ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"}, {ID: "i2", Image: "/uploads/runway/2.jpg", Name: "Look 2"}, @@ -131,6 +153,9 @@ func (f *fakeArticle) SetDeleted(ctx context.Context, id uint32, deleted uint8) func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error { return nil } +func (f *fakeArticle) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) { + return 0, nil +} type fakeStreet struct{} @@ -142,20 +167,38 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub ImageCount: 8, }}, 1, nil } -func (f *fakeStreet) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) { + +// AdminList 后台列表读基表;假实现里与 List 同源即可(本假实现不区分可见性)。 +func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + return f.List(ctx, q) +} + +// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到, +// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。 +func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) { + return nil, service.ErrStreetSnapNotFound +} + +// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。 +func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) { return &dto.PublicStreetSnapDetail{ UID: id, - Title: "Paris Fashion Week Street", + Title: "Paris Fashion Week Street(待审)", Images: []dto.PublicArticleImage{ {ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"}, }, }, nil } -func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { +func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) { return nil, nil } func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { - return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil + imgs := []model.StreetSnapImage{ + {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0}, + {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, + {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, + } + return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil } func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error { return nil @@ -170,14 +213,70 @@ func (f *fakeStreet) SetDeleted(ctx context.Context, id uint32, deleted uint8) e func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error { return nil } +func (f *fakeStreet) SumImageSize(ctx context.Context, snapID uint32) (int64, error) { + return 0, nil +} +// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。 +type fakeAdminRepo struct{} + +func (f *fakeAdminRepo) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]repository.AdminRecordStats, error) { + return map[uint32]repository.AdminRecordStats{}, nil +} + +func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) { + all := []repository.AdminRecordCard{ + {Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"}, + {Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"}, + } + if kind != "" { + kept := all[:0] + for _, c := range all { + if c.Kind == kind { + kept = append(kept, c) + } + } + all = kept + } + if status != "" { + kept := all[:0] + for _, c := range all { + if c.Status == status { + kept = append(kept, c) + } + } + all = kept + } + return all, int64(len(all)), nil +} +func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) { + if kind == "runway" { + if status == "" || status == "published" { + return 1, nil + } + return 0, nil + } + if kind == "street" { + if status == "" || status == "pending" { + return 1, nil + } + return 0, nil + } + if status == "" { + return 2, nil + } + if status == "pending" || status == "published" { + return 1, nil + } + return 0, nil +} // ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)───────────────── func TestBackstage(t *testing.T) { m := jwt.NewManager("test-secret", 168) auth := &fakeAuth{jwt: m} brand := &fakeBrand{} - h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil) + h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{}, nil, nil) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h}) // 1) 登录页可访问 @@ -254,39 +353,381 @@ func TestBackstage(t *testing.T) { t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location")) } - // 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源) + // 6) 文章管理混合列表渲染(走秀 + 街拍合并) w = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil) + req = httptest.NewRequest(http.MethodGet, "/admin/articles", nil) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) r.ServeHTTP(w, req) - if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") { - t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String()) + body := w.Body.String() + if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") { + t.Fatalf("文章管理列表异常: %d %s", w.Code, body) + } + if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") { + t.Fatalf("文章管理列表应含状态 tab: %s", body) } - // 7) 走秀详情渲染图集 + // 7) 后台走秀详情渲染图集。 + // fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found, + // 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。 w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) r.ServeHTTP(w, req) - if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") { - t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String()) + if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") { + t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String()) } - // 8) 街拍列表渲染 + // 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀 w = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil) + req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", nil) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) r.ServeHTTP(w, req) - if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") { - t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String()) + body = w.Body.String() + if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") { + t.Fatalf("待审核筛选异常: %d %s", w.Code, body) } - // 9) 街拍详情渲染图集 + // 9) 后台街拍详情渲染图集。 + // 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。 w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) r.ServeHTTP(w, req) - if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") { - t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String()) + if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") { + t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String()) + } +} + +// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)───────────────── + +// fakeReview 实现审核服务接口; +// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 —— +// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。 +// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。 +type fakeReview struct { + service.ReviewService + attachOK bool + approveErr error + last struct { + kind string + recordID uint32 + mainID uint32 + imgIDs []uint32 + imgID uint32 + op string + } + // calls 记录批量审核的逐条调用,供断言。 + calls []struct { + Action string + Kind string + ID uint32 + } + view *service.RecordDetailView +} + +var _ service.ReviewService = (*fakeReview)(nil) + +func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) { + if f.view != nil { + return f.view, nil + } + return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil +} + +// UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。 +func (f *fakeReview) UpdateRecord(_ context.Context, _ string, _ uint32, _ map[string]any) error { + return nil +} + +func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error { + f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach" + if !f.attachOK { + return errors.New("主图无效") + } + return nil +} +func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error { + f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach" + if !f.attachOK { + return errors.New("主图无效") + } + return nil +} + +// KindTabs 是导航中间件在每个后台页面上都会调用的方法(用于显示待审计数角标), +// 必须显式实现,否则会落到嵌入的 nil 接口上 panic。 +func (f *fakeReview) KindTabs(_ context.Context) []service.KindTab { + return []service.KindTab{ + {Kind: "", Label: "全部", Pending: 2}, + {Kind: "street", Label: "街拍", Pending: 1}, + {Kind: "runway", Label: "走秀", Pending: 1}, + } +} + +func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer string) (uint32, error) { + f.calls = append(f.calls, struct { + Action string + Kind string + ID uint32 + }{"approve", kind, id}) + // last 供「表单式通过」用例断言(批量接口用 calls)。 + f.last.kind, f.last.recordID, f.last.op = kind, id, "approve" + if f.approveErr != nil { + return 0, f.approveErr + } + return id, nil +} + +func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) { + t.Helper() + m := jwt.NewManager("test-secret", 168) + auth := &fakeAuth{jwt: m} + brand := &fakeBrand{} + rev := &fakeReview{attachOK: true} + h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{}, nil, nil) + r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h}) + return r, rev +} + +func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=secret")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.ServeHTTP(w, req) + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("登录未下发会话 cookie") + } + return cookies[0] +} + +// TestBackstageStreetAttachRedirect 合并选中应 302 跳回详情页,且参数透传到 service。 +func TestBackstageStreetAttachRedirect(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("main=1&img=2&img=3")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(tok) + r.ServeHTTP(w, req) + if w.Code != http.StatusFound { + t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") { + t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location")) + } + if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 { + t.Fatalf("参数透传错误: %+v", rev.last) + } +} + +// TestBackstageStreetAttachNeedsMain 未选主图时应 400,而不是静默失败。 +func TestBackstageStreetAttachNeedsMain(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(tok) + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("未选主图应 400,实际 %d", w.Code) + } + if rev.last.op == "attach" { + t.Fatalf("未选主图不应调用 service") + } +} + +// TestBackstageStreetDetach 拆出应 302 跳回详情页。 +func TestBackstageStreetDetach(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/2/detach", nil) + req.AddCookie(tok) + r.ServeHTTP(w, req) + if w.Code != http.StatusFound { + t.Fatalf("拆出应 302,实际 %d", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") { + t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location")) + } + if rev.last.op != "detach" || rev.last.imgID != 2 { + t.Fatalf("参数透传错误: %+v", rev.last) + } +} + +// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。 +func TestBackstageStreetRenderGroups(t *testing.T) { + hashid.Init("test-salt") + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + _ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务 + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil) + req.AddCookie(tok) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "detail-a") { + t.Fatalf("应渲染副图 detail-a") + } + // 副图徽标显示所属主图。 + if !strings.Contains(body, "副图 → #1") { + t.Fatalf("副图应显示所属主图") + } + // 统一网格 + 勾选 + 主图单选 + 底部提交按钮,全部用 form="batch" 关联(零 JS、不嵌套表单)。 + if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) { + t.Fatalf("应有勾选合并的表单") + } + if !strings.Contains(body, `name="main"`) { + t.Fatalf("应有主图单选(name=main)") + } + if !strings.Contains(body, "合并选中为一组") { + t.Fatalf("底部应有「合并选中为一组」按钮") + } + // 「并入上一张」已废弃:不应再出现在页面或表单里。 + if strings.Contains(body, "并入上一张") || strings.Contains(body, "attach-prev") { + t.Fatalf("不应再出现「并入上一张」") + } +} + +// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。 +// 断言表单式通过路径确实走到了 service.Approve(而不是别的方法)。 +func TestBackstageApprovePublishes(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(tok) + r.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Fatalf("通过应 302,实际 %d %s", w.Code, w.Body.String()) + } + if rev.last.op != "approve" { + t.Fatalf("应调用 Approve,实际 op=%s", rev.last.op) + } +} + +// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ─────────────────────────── + +// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。 +func TestBackstageAssets(t *testing.T) { + r, _ := newReviewRouter(t) + + for _, tc := range []struct{ path, want string }{ + {"/admin/assets/admin.css", ".bar-nav"}, + {"/admin/assets/admin.js", "data-admin-js"}, + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if w.Code != 200 { + t.Fatalf("%s 应 200,实际 %d", tc.path, w.Code) + } + if !strings.Contains(w.Body.String(), tc.want) { + t.Fatalf("%s 内容缺少 %q", tc.path, tc.want) + } + } +} + +// TestBackstageNavStatsOnEveryPage 后台任意页面都带待审计数角标。 +// 该角标由 NavStats 中间件提供;它同时是「所有页面都会调用 ReviewService.KindTabs」的证据。 +func TestBackstageNavStatsOnEveryPage(t *testing.T) { + r, _ := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/brands", nil) + req.AddCookie(tok) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("品牌页应 200: %d", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "bar-badge") || !strings.Contains(body, ">2<") { + t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body) + } + // 导航已在共享 partial 里,各模块入口都应在 + for _, label := range []string{"文章", "品牌", "入库任务", "用户"} { + if !strings.Contains(body, ">"+label) { + t.Fatalf("导航应包含入口 %q", label) + } + } +} + +// TestBackstageReviewBatchApprove 批量通过:一次请求处理多条,返回 ok 计数。 +func TestBackstageReviewBatchApprove(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(tok) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("批量通过应 200: %d %s", w.Code, w.Body.String()) + } + var res struct { + OK int `json:"ok"` + Pending int64 `json:"pending"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("响应不是合法 JSON: %v", err) + } + if res.OK != 2 { + t.Fatalf("应通过 2 条,实际 %d", res.OK) + } + if res.Pending != 2 { + t.Fatalf("应回带最新待审计数 2,实际 %d", res.Pending) + } + if len(rev.calls) != 2 || rev.calls[0].ID != 11 || rev.calls[1].Kind != "runway" { + t.Fatalf("逐条调用未透传到 service: %+v", rev.calls) + } +} + +// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。 +func TestBackstageReviewBatchPartialFailure(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}` + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(tok) + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("部分失败仍应 200: %d", w.Code) + } + var res struct { + OK int `json:"ok"` + Failed []struct { + ID uint32 `json:"id"` + Error string `json:"error"` + } `json:"failed"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("响应不是合法 JSON: %v", err) + } + if res.OK != 1 || len(res.Failed) != 1 || res.Failed[0].ID != 12 { + t.Fatalf("应 1 成功 1 失败(id=12): ok=%d failed=%+v", res.OK, res.Failed) + } + if len(rev.calls) != 1 { + t.Fatalf("未注册类型不应调用 service: %+v", rev.calls) } } diff --git a/internal/router/router.go b/internal/router/router.go index d058cb3..8663194 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -40,8 +40,11 @@ type Options struct { // 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上, // 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。 func New(opt Options) *gin.Engine { - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.GinLogger()) r.Use(middleware.CORS(opt.Config.CORS)) + r.Use(middleware.RateLimit(opt.Config.RateLimit)) // 上传图片的静态服务。 // 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg), @@ -141,7 +144,6 @@ func New(opt Options) *gin.Engine { legacy.GET("/favorites", opt.Favorite.List) legacy.POST("/favorites", opt.Favorite.Add) legacy.DELETE("/favorites/:uid", opt.Favorite.Remove) - legacy.POST("/favorites/check", opt.Favorite.Check) legacy.GET("/history", opt.History.List) legacy.POST("/history", opt.History.Record) diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 4c44d89..5c9baea 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -14,8 +14,8 @@ import ( "fashionapi/internal/repository" ) -// MediaCleanupEnqueuer 是「清理七牛孤儿图」任务的入队入口;由 IngestService 实现, -// 图集服务在删除图集时调用它,把同步的七牛删除改为异步队列,避免阻塞删除请求。 +// MediaCleanupEnqueuer 是「清理S4孤儿图」任务的入队入口;由 IngestService 实现, +// 图集服务在删除图集时调用它,把同步的S4删除改为异步队列,避免阻塞删除请求。 type MediaCleanupEnqueuer interface { EnqueueMediaCleanup(ctx context.Context, keys []string) error } @@ -23,7 +23,9 @@ type MediaCleanupEnqueuer interface { // ArticleService 文章业务接口。 type ArticleService interface { List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) - Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) + Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) + // DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。 + DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) // AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。 AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) // GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。 @@ -36,7 +38,9 @@ type ArticleService interface { SetCover(ctx context.Context, runwayID uint32, image string) error // SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error - // PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。 + // SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, runwayID uint32) (int64, error) + // PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } @@ -45,13 +49,13 @@ type articleService struct { media repository.MediaRepository img *imgurl.Composer deleter storage.Deleter - cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口 + cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口 } // NewArticleService 创建文章服务。 -// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清); -// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删); -// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 +// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); +// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); +// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 func NewArticleService(articles repository.ArticleRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) ArticleService { return &articleService{articles: articles, media: media, img: img, deleter: deleter, cleaner: cleaner} } @@ -70,10 +74,10 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicArticle{ - UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), - Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图 + Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), // 品牌名强制优先英文,保持英文观感 ImageCount: r.ImageCount, }) @@ -87,8 +91,8 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu return list, total, nil } -// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / -// 来源链接的管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 +// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态 +// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) { q.Normalize() rows, total, err := s.articles.ListAdmin(ctx, q) @@ -98,7 +102,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d list := make([]dto.AdminRunway, 0, len(rows)) for _, r := range rows { list = append(list, dto.AdminRunway{ - UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), @@ -107,7 +111,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d CollectionType: r.CollectionType, SeasonCode: r.SeasonCode, ImageCount: r.ImageCount, - SourceURL: r.SourceURL, + Status: r.Status, }) } return list, total, nil @@ -125,8 +129,9 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti if !ok { continue } - // 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 - imgs := s.toPublicImages(raw, false) + // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); + // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 + imgs := s.toThumbImages(raw) imgs = excludeCover(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] @@ -151,39 +156,119 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic return kept } -// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言; -// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。 -func (s *articleService) Detail(ctx context.Context, id string, locale string, hd bool) (*dto.PublicArticleDetail, error) { +// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。 +// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。 +func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { row, err := s.articles.FindByID(ctx, id) if err != nil { - if errors.Is(err, repository.ErrNotFound) { - return nil, ErrArticleNotFound - } - return nil, NewError(http.StatusInternalServerError, err.Error()) + return nil, s.detailErr(err) } - imgs, err := s.articles.ListImages(ctx, id) if err != nil { return nil, internalErr(err.Error()) } + return s.buildDetail(row, imgs, locale), nil +} + +// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。 +// +// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口; +// 若后台详情也走公开视图,待审项会 404、审核无从下手。 +func (s *articleService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { + row, err := s.articles.FindByIDBase(ctx, id) + if err != nil { + return nil, s.detailErr(err) + } + imgs, err := s.articles.ListImagesBase(ctx, id) + if err != nil { + return nil, internalErr(err.Error()) + } + return s.buildDetail(row, imgs, locale), nil +} + +// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。 +func (s *articleService) detailErr(err error) error { + if errors.Is(err, repository.ErrNotFound) { + return ErrArticleNotFound + } + return NewError(http.StatusInternalServerError, err.Error()) +} + +// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 +func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail { + // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id + // 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。 + mains := make([]model.BrandRunwayImage, 0, len(imgs)) + detailsByParent := make(map[uint32][]model.BrandRunwayImage) + for _, im := range imgs { + if im.IsDetail == 0 || im.ParentImageID == 0 { + mains = append(mains, im) + } else { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) + } + } + pubMains := s.toPublicImages(mains) + for i := range pubMains { + // 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。 + if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 { + pubMains[i].Detail = s.toPublicImages(kids) + } + } return &dto.PublicArticleDetail{ - UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID), + UID: hashid.EncodeWithType(row.ID, hashid.TypeRunway), BrandUID: hashid.Encode(row.BrandID), Title: pickLocale(row.TitleEn, row.TitleCn, locale), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), - Cover: s.img.Compose(row.Cover, hd), + Cover: s.img.Compose(row.Cover), // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), - SourceURL: row.SourceURL, - Images: s.toPublicImages(imgs, hd), - }, nil + Images: pubMains, + } } // ── 后台编辑支持 ────────────────────────────────────────────────────────────── func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) { - return s.articles.GetForEdit(ctx, id) + rw, imgs, err := s.articles.GetForEdit(ctx, id) + if err != nil { + return nil, nil, err + } + // 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排, + // 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。 + return rw, orderRunwayImagesForEdit(imgs), nil +} + +// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。 +// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。 +func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage { + mains := make([]model.BrandRunwayImage, 0, len(imgs)) + mainSeen := make(map[uint32]bool, len(imgs)) + for _, im := range imgs { + isMain := im.IsDetail == 0 || im.ParentImageID == 0 + if isMain && !mainSeen[im.ID] { + mainSeen[im.ID] = true + mains = append(mains, im) + } + } + detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs)) + for _, im := range imgs { + if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) + } + } + out := make([]model.BrandRunwayImage, 0, len(imgs)) + for _, main := range mains { + out = append(out, main) + out = append(out, detailsByParent[main.ID]...) + } + // 孤儿细节图(parent 缺失):追加末尾,保证不丢图。 + for _, im := range imgs { + if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] { + out = append(out, im) + } + } + return out } func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error { @@ -191,7 +276,7 @@ func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map } func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error { - // 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。 + // 先取 key(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。 key, err := s.articles.GetRunwayImageKey(ctx, imageID) if err != nil { return err @@ -209,22 +294,27 @@ func (s *articleService) SetCover(ctx context.Context, runwayID uint32, image st return s.articles.SetRunwayCover(ctx, runwayID, image) } +// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 +func (s *articleService) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) { + return s.articles.SumImageSize(ctx, runwayID) +} + func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { - // 下架图集:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。 + // 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 return s.PurgeGallery(ctx, id) } // 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。 return s.articles.SetRunwayDeleted(ctx, id, 0) } -// PurgeGallery 下架图集并异步清理七牛孤儿图: +// PurgeGallery 下架图集并异步清理S4孤儿图: // 1. 级联软删其全部图片(使其退出引用计数),拿到这些图片的 key; // 2. 把图集自身置为下架(对外不可见); -// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删七牛文件(异步、不阻塞本请求)。 +// 3. 把这批 key 入队,由 worker 按引用计数判定是否为真孤儿再删S4文件(异步、不阻塞本请求)。 // // 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警, -// 图集已下架、图片已软删,七牛文件留待后续手动清理或周期性兜底扫描。 +// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。 func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error { keys, err := s.articles.SoftDeleteRunwayImages(ctx, id) if err != nil { @@ -235,26 +325,47 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error { } if len(keys) > 0 && s.cleaner != nil { if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil { - log.Printf("[warn] 下架图集 %d 入队清理七牛失败(文件将暂留): %v", id, err) + log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err) } } return nil } -// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // toPublicImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 // ID 用 TypeRunwayImage 前缀编码,与文章 uid(r 前缀)区分开,供单图收藏定位。 -// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。 -func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage, hd bool) []dto.PublicArticleImage { +// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条与右下角 +// 细节缩略图等小尺寸场景使用——那些位置只显示几十到一百多像素,用大图纯属浪费带宽。 +// 注意:列表用的 toThumbImages 不填 Thumb(列表的 Image 本身就是缩略图)。 +func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID), - Image: s.img.Compose(im.Image, hd), - Name: im.Name, + ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage), + Image: s.img.Compose(im.Image), + Thumb: s.img.ComposeThumb(im.Image), + Name: im.Name, + IsDetail: im.IsDetail, + }) + } + return out +} + +// toThumbImages 与 toPublicImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb), +// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicImages(展示样式)。 +func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage { + if len(imgs) == 0 { + return nil + } + out := make([]dto.PublicArticleImage, 0, len(imgs)) + for _, im := range imgs { + out = append(out, dto.PublicArticleImage{ + ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage), + Image: s.img.ComposeThumb(im.Image), + Name: im.Name, + IsDetail: im.IsDetail, }) } return out diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index dc86c7e..d2fe10c 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -21,6 +21,7 @@ import ( // - Login 签发两者;access 用于日常请求,refresh 仅在续期时使用。 // - Refresh 用 refresh token 换发新 access(refresh 复用、不轮换,足够内部工具)。 // - Logout 吊销当前 refresh;RevokeAllByRefresh 吊销该用户全部 refresh(踢下线)。 +// // 由于 refresh 落库,服务端得以单方面作废会话——这是纯无状态 JWT 做不到的。 type AuthService interface { Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) diff --git a/internal/service/brand_service.go b/internal/service/brand_service.go index 2838efb..4043b9c 100644 --- a/internal/service/brand_service.go +++ b/internal/service/brand_service.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "net/http" "sort" "time" @@ -16,6 +17,9 @@ import ( // BrandService 品牌业务接口。 type BrandService interface { List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) + // AdminList 后台品牌列表:字母索引不要求走秀已发布, + // 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。 + AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) // Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。 // 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌; // 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。 @@ -26,6 +30,9 @@ type BrandService interface { Create(ctx context.Context, nameEn, nameCn string) (string, error) // Update 按编码 id 更新品牌名称。 Update(ctx context.Context, uid, nameEn, nameCn string) error + // Delete 软删品牌(is_deleted=1)。品牌下仍有未删除的走秀档案时拒绝删除, + // 避免留下 brand_id 指不到品牌的图集(无外键级联)。 + Delete(ctx context.Context, uid string) error // CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。 // brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。 CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) @@ -70,6 +77,18 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public return s.toPublicBrands(items, q.Locale), total, nil } +// AdminList 后台品牌列表:与 List 同形,但走 ListAdmin —— 字母索引不过滤走秀发布态, +// 只有待审 / 驳回走秀的品牌仍可见。后台不做 featured 收窄,故 restrictIDs 传 nil。 +func (s *brandService) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) { + q.Normalize() + + items, total, err := s.brands.ListAdmin(ctx, q, nil) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + return s.toPublicBrands(items, q.Locale), total, nil +} + // Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。 // // 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用—— @@ -155,6 +174,31 @@ func (s *brandService) Update(ctx context.Context, uid, nameEn, nameCn string) e return nil } +// Delete 软删品牌:先确认该品牌下没有未删除的走秀档案,再置 is_deleted=1。 +// +// 护栏的理由:brand_runways.brand_id 没有外键级联,直接删品牌会留下一批指不到品牌的图集, +// 前台品牌页、热门榜、图集列表都会出现异常数据。宁可让运营先处理档案,也不引入隐式级联删除。 +func (s *brandService) Delete(ctx context.Context, uid string) error { + id, err := hashid.Decode(uid) + if err != nil { + return ErrBrandNotFound + } + n, err := s.brands.CountRunwaysByBrand(ctx, id) + if err != nil { + return internalErr(err.Error()) + } + if n > 0 { + return NewError(http.StatusConflict, fmt.Sprintf("该品牌下还有 %d 个图集,请先删除或下架后再删品牌", n)) + } + if err := s.brands.SetDeleted(ctx, id, 1); err != nil { + if errors.Is(err, repository.ErrNotFound) { + return ErrBrandNotFound + } + return internalErr(err.Error()) + } + return nil +} + // CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。 // 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。 func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) { diff --git a/internal/service/brand_service_test.go b/internal/service/brand_service_test.go new file mode 100644 index 0000000..996e6b9 --- /dev/null +++ b/internal/service/brand_service_test.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "strings" + "testing" + + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/repository" +) + +// stubBrandRepo 只实现删除链路上用到的两个方法;其余方法通过嵌入的接口转发 +// (本测试不会调用它们,因此不必为 7 个无关方法写样板)。 +type stubBrandRepo struct { + repository.BrandRepository + runways int64 + deleted uint8 + setCalled bool +} + +func (s *stubBrandRepo) CountRunwaysByBrand(_ context.Context, _ uint32) (int64, error) { + return s.runways, nil +} + +func (s *stubBrandRepo) SetDeleted(_ context.Context, _ uint32, deleted uint8) error { + s.setCalled = true + s.deleted = deleted + return nil +} + +// TestBrandDeleteBlockedByRunways 品牌下还有图集时必须拒绝删除,且绝不能写 is_deleted。 +// 这是删除功能唯一的护栏:brand_runways.brand_id 没有外键级联,误删会留下指不到品牌的图集。 +func TestBrandDeleteBlockedByRunways(t *testing.T) { + repo := &stubBrandRepo{runways: 3} + svc := NewBrandService(repo) + + err := svc.Delete(context.Background(), hashid.Encode(1)) + if err == nil { + t.Fatal("品牌下还有图集时应拒绝删除") + } + if repo.setCalled { + t.Fatal("被拒绝时不得写入 is_deleted") + } + if !strings.Contains(err.Error(), "3") { + t.Fatalf("错误信息应带上图集数量,便于运营处理,实际: %v", err) + } +} + +// TestBrandDeleteAllowedWhenNoRunways 没有图集时可以软删,且写入的必须是 is_deleted=1。 +func TestBrandDeleteAllowedWhenNoRunways(t *testing.T) { + repo := &stubBrandRepo{} + svc := NewBrandService(repo) + + if err := svc.Delete(context.Background(), hashid.Encode(1)); err != nil { + t.Fatalf("无图集时应允许删除,实际: %v", err) + } + if !repo.setCalled || repo.deleted != 1 { + t.Fatalf("应写入 is_deleted=1,实际 called=%v deleted=%d", repo.setCalled, repo.deleted) + } +} + +// TestBrandDeleteInvalidUID 非法 id 直接报错,不得触达数据库。 +func TestBrandDeleteInvalidUID(t *testing.T) { + repo := &stubBrandRepo{} + svc := NewBrandService(repo) + + if err := svc.Delete(context.Background(), "!!!not-a-hashid"); err == nil { + t.Fatal("非法 id 应返回错误") + } + if repo.setCalled { + t.Fatal("非法 id 不应写库") + } +} diff --git a/internal/service/errors.go b/internal/service/errors.go index 9c12fb6..580aaeb 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -27,7 +27,6 @@ func NewError(status int, msg string) *Error { func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) } func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) } -func conflict(msg string) *Error { return NewError(http.StatusConflict, msg) } func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) } // ErrArticleNotFound 文章不存在,文案与原项目一致。 diff --git a/internal/service/index_service.go b/internal/service/index_service.go index c359377..285f96f 100644 --- a/internal/service/index_service.go +++ b/internal/service/index_service.go @@ -32,7 +32,7 @@ type indexService struct { } // NewIndexService 创建首页数据服务。 -// img 负责把库里存储的七牛 key 拼装为可访问 URL(首页统一普通图)。 +// img 负责把库里存储的S4 key 拼装为可访问 URL(首页统一普通图)。 func NewIndexService(brands repository.BrandRepository, img *imgurl.Composer) IndexService { return &indexService{brands: brands, img: img} } @@ -51,11 +51,11 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa brands := make([]dto.PopularBrand, 0, len(rows)) for _, b := range rows { brands = append(brands, dto.PopularBrand{ - UID: hashid.Encode(b.ID), - ArticleUID: hashid.Encode(b.ArticleID), + UID: hashid.Encode(b.ID), // 品牌无类型,继续用全局编码(brand handler 用 Decode 解码) + ArticleUID: hashid.EncodeWithType(b.ArticleID, hashid.TypeRunway), // 走秀详情链接用类型化编码 // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 Brand: pickLocale(b.BrandEn, b.BrandCn, "en"), - Cover: s.img.Compose(b.Cover, false), // 首页封面统一普通图 + Cover: s.img.Compose(b.Cover), // 首页热门卡片为大图展示,用高清样式(high) Title: pickLocale(b.TitleEn, b.TitleCn, locale), }) } diff --git a/internal/service/ingest_cleanup_test.go b/internal/service/ingest_cleanup_test.go index 5f48fd2..8a28853 100644 --- a/internal/service/ingest_cleanup_test.go +++ b/internal/service/ingest_cleanup_test.go @@ -25,12 +25,12 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) { return } w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("fakeimagebytes")) + _, _ = w.Write([]byte("fakeimagebytes" + r.URL.Path)) })) defer srv.Close() urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"} - _, out, keys, failed := s.fetchImages(context.Background(), urls, "runway") + _, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway") if !failed { t.Fatalf("expected failed=true when one image errors") } diff --git a/internal/service/ingest_image_count_test.go b/internal/service/ingest_image_count_test.go new file mode 100644 index 0000000..e087d60 --- /dev/null +++ b/internal/service/ingest_image_count_test.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/storage" + "fashionapi/internal/repository" +) + +// captureIngestRepo 捕获 processRunway 交给仓储的「待落库」记录与图片行。 +// 只实现被测路径会用到的方法,其余由内嵌 nil 接口兜底(不应被调用)。 +type captureIngestRepo struct { + repository.IngestRepository + createdRW *model.BrandRunway + createdImages []model.BrandRunwayImage + deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入) +} + +func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) { + if c.deletedEntity { + return 1, model.StatusPending, true, true, nil // 命中但已软删 + } + return 0, "", false, false, nil // 实体键未命中 → 新建分支 +} + +func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) { + c.createdRW = rw + c.createdImages = imgs + return 1, nil +} + +func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil } + +// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。 +func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error { + return nil +} + +// fakeBrandRepo 品牌校验恒通过。 +type fakeBrandRepo struct{ repository.BrandRepository } + +func (fakeBrandRepo) FindByID(ctx context.Context, id uint32) (*model.Brand, error) { + return &model.Brand{}, nil +} + +// TestProcessRunwayImageCountEqualsAliveMainRows 回归 image_count 漂移: +// +// 旧实现取 imageCount = len(p.Looks),但 fetchLookImages 会跳过 look.Main == "" 的 look +// 与 sha1 重复的行 —— 实际落库的主图行可能更少。旧流程靠晋升按实际行重算自愈,单表发布后 +// 该路径已删除,漂移会永久留在公开卡片的「N 张」角标与热度 SUM(image_count) 上。 +// +// 本用例断言:入库时交给仓储的 image_count == 实际存活主图行数(is_detail = 0), +// 且在数据触发跳过时**严格小于** len(Looks)。旧实现下 image_count 会是 4,确定性失败。 +func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) { + dir := t.TempDir() + local := storage.NewLocalUploader(dir, "/uploads") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + // 按路径返回不同内容:Content 不同 → sha1 不同;同路径 → sha1 相同(触发批次内去重)。 + _, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path)) + })) + defer srv.Close() + + repo := &captureIngestRepo{} + s := &IngestService{ + repo: repo, + brandRepo: fakeBrandRepo{}, + uploader: local, + local: local, + httpClient: srv.Client(), + } + + p := dto.RunwayIngest{ + Kind: dto.IngestKindRunway, + BrandUID: hashid.Encode(1), + TitleEn: "image-count-test", + Year: 2027, + Season: "spring", + CollectionType: "rtw", + Looks: []dto.RunwayLook{ + // look 1:主图 a + 细节图 a1 → 1 主图 + {Main: srv.URL + "/img/a.jpg", Details: []string{srv.URL + "/img/a1.jpg"}}, + // look 2:空主图,只有细节图 b1 → 0 主图(旧实现仍按 1 计) + {Details: []string{srv.URL + "/img/b1.jpg"}}, + // look 3:主图与 look 1 内容相同(sha1 重复)→ 被跳过,0 主图(旧实现仍按 1 计) + {Main: srv.URL + "/img/a.jpg"}, + // look 4:主图 c → 1 主图 + {Main: srv.URL + "/img/c.jpg"}, + }, + } + job := model.IngestJob{ID: 1, Kind: model.IngestKindCrawl} + s.processRunway(context.Background(), job, p) + + if repo.createdRW == nil { + t.Fatal("未写入走秀记录") + } + + // 实际落库主图行:a、c → 2 张;细节图 a1、b1 → 2 张。 + var actualMain uint16 + for _, im := range repo.createdImages { + if im.IsDetail == 0 { + actualMain++ + } + } + if actualMain != 2 { + t.Fatalf("前置:实际主图行应为 2,实际 %d(图片行=%+v)", actualMain, repo.createdImages) + } + if got := repo.createdRW.ImageCount; got != actualMain { + t.Fatalf("入库 image_count 应等于存活主图行数 %d,实际 %d", actualMain, got) + } + if repo.createdRW.ImageCount >= uint16(len(p.Looks)) { + t.Fatalf("本用例数据应让 image_count(%d) 严格小于 len(Looks)=%d,否则测不出漂移", + repo.createdRW.ImageCount, len(p.Looks)) + } +} + +// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」: +// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。 +func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) { + dir := t.TempDir() + local := storage.NewLocalUploader(dir, "/uploads") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path)) + })) + defer srv.Close() + + // deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。 + repo := &captureIngestRepo{deletedEntity: true} + s := &IngestService{ + repo: repo, + brandRepo: fakeBrandRepo{}, + uploader: local, + local: local, + httpClient: srv.Client(), + } + + p := dto.RunwayIngest{ + Kind: dto.IngestKindRunway, + BrandUID: hashid.Encode(1), + TitleEn: "deleted-entity-test", + Year: 2027, + Season: "spring", + CollectionType: "rtw", + Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}}, + } + job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl} + s.processRunway(context.Background(), job, p) + + if repo.createdRW != nil { + t.Fatal("已软删的实体不应被重新写入正式表") + } + if len(repo.createdImages) != 0 { + t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages)) + } +} diff --git a/internal/service/ingest_image_size_test.go b/internal/service/ingest_image_size_test.go new file mode 100644 index 0000000..9166ccd --- /dev/null +++ b/internal/service/ingest_image_size_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeSizeUploader 仅用于单测:返回固定 URL,不真正写存储。 +type fakeSizeUploader struct{} + +func (fakeSizeUploader) Upload(_ context.Context, key string, _ []byte, _ string) (string, error) { + return "http://example/" + key, nil +} +func (fakeSizeUploader) Enabled() bool { return true } + +// TestDownloadAndUploadRecordsOriginalSize 校验 worker 在下载阶段记录源文件字节数 +// (即 image_size 的来源:io.ReadAll 读到的原图响应体长度)。 +func TestDownloadAndUploadRecordsOriginalSize(t *testing.T) { + body := []byte("fake-original-image-bytes-payload-of-known-length-1234567890") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(body) + })) + defer srv.Close() + + s := &IngestService{ + uploader: fakeSizeUploader{}, + httpClient: &http.Client{}, + } + + res := s.downloadAndUpload(context.Background(), srv.URL, "runway") + if res.err != nil { + t.Fatalf("downloadAndUpload 失败: %v", res.err) + } + if res.size != int64(len(body)) { + t.Fatalf("res.size = %d, want %d(源文件字节数)", res.size, len(body)) + } + if res.url == "" { + t.Fatalf("res.url 不应为空(上传应成功)") + } +} diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index fa172cb..24c658f 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -9,7 +9,12 @@ import ( "io" "log" "net/http" + "net/url" + "os" "path" + "strconv" + "strings" + "sync" "time" "fashionapi/internal/dto" @@ -18,37 +23,82 @@ import ( "fashionapi/internal/pkg/season" "fashionapi/internal/pkg/storage" "fashionapi/internal/repository" + "fashionapi/internal/vision" ) // IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。 // // 队列复用一张 ingest_jobs 表,靠 kind 列分流两类任务: // - crawl:爬虫上报的走秀/街拍入库(下载图 → 写草稿) -// - media_cleanup:删除图集时异步清理七牛孤儿图(引用计数归零才真删) +// - media_cleanup:删除图集时异步清理S4孤儿图(引用计数归零才真删) type IngestService struct { repo repository.IngestRepository brandRepo repository.BrandRepository media repository.MediaRepository // 跨表图片引用计数(media_cleanup 删孤儿用) - uploader storage.Uploader // 主上传器(七牛启用时为七牛,否则本地) - del storage.Deleter // 删除器(七牛或本地,media_cleanup 真删用) - local *storage.LocalUploader // 七牛失败时的兜底落地 + uploader storage.Uploader // 主上传器(S4启用时为S4,否则本地) + del storage.Deleter // 删除器(S4或本地,media_cleanup 真删用) + local *storage.LocalUploader // S4失败时的兜底落地 httpClient *http.Client + translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全) + // 视觉服务与向量仓储(均可为 nil:未启用视觉服务时跳过 embed,不影响任何入库行为)。 + // 只服务于后台「疑似同款」提示,不参与去重。 + vision *vision.Client + embedRep repository.EmbeddingRepository + // similar 「疑似同款」预计算服务(可为 nil:装配期未注入或视觉服务未启用时跳过)。 + // 写完向量后顺手把该图集的跨图集近邻算好落库,使后台页面只需读一次小查询。 + // 注入用 SetSimilar,避免改动构造签名(该服务在主装配期晚于本服务创建)。 + similar *SimilarService } +// SetSimilar 注入「疑似同款」服务,供写向量后顺手预计算近邻。 +// 装配期调用;未注入(nil)时完全跳过预计算,不影响入库。 +func (s *IngestService) SetSimilar(sv *SimilarService) { s.similar = sv } + // NewIngestService 创建入库服务。 // -// uploader: 主上传器(七牛或本地) -// del: 删除器(七牛或本地),用于 media_cleanup 真删七牛/本地孤儿文件 -// local: 本地兜底上传器(七牛上传失败时回退,避免图片完全丢失) -func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService { +// uploader: 主上传器(S4或本地) +// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件 +// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失) +func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService, vis *vision.Client, embedRep repository.EmbeddingRepository) *IngestService { + // 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站; + // 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。 + // 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。 + dlTransport := &http.Transport{ + MaxIdleConns: imageFetchConcurrency * 4, + MaxIdleConnsPerHost: imageFetchConcurrency * 2, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + if downloadProxy != "" { + if pu, perr := url.Parse(downloadProxy); perr == nil { + dlTransport.Proxy = http.ProxyURL(pu) + log.Printf("[ingest] 图片下载走代理: %s", downloadProxy) + } else { + log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy) + dlTransport.Proxy = http.ProxyFromEnvironment + } + } else { + dlTransport.Proxy = http.ProxyFromEnvironment + } + return &IngestService{ - repo: repo, - brandRepo: brandRepo, - media: media, - uploader: uploader, - del: del, - local: local, - httpClient: &http.Client{Timeout: 30 * time.Second}, + repo: repo, + brandRepo: brandRepo, + media: media, + uploader: uploader, + del: del, + local: local, + translate: translateSvc, + vision: vis, + embedRep: embedRep, + // 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度: + // Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。 + httpClient: &http.Client{ + // 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。 + // 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。 + Timeout: 60 * time.Second, + Transport: dlTransport, + }, } } @@ -62,32 +112,28 @@ func (s *IngestService) Submit(ctx context.Context, payload dto.RunwayIngest) (u if err != nil { return 0, NewError(http.StatusBadRequest, "payload invalid: "+err.Error()) } - if payload.SourceURL == "" { - return 0, NewError(http.StatusBadRequest, "source_url required") - } // runway 必须带品牌;street 不关联品牌,brand_uid 可选 if payload.Kind == dto.IngestKindRunway && payload.BrandUID == "" { return 0, NewError(http.StatusBadRequest, "brand_uid required") } - job := &model.IngestJob{SourceURL: payload.SourceURL, Payload: string(raw), Kind: model.IngestKindCrawl} + job := &model.IngestJob{Payload: string(raw), Kind: model.IngestKindCrawl} if err := s.repo.Enqueue(ctx, job); err != nil { return 0, internalErr(err.Error()) } return job.ID, nil } -// ExistsSourceURLs 批量判断 source_url 是否已爬取过(爬虫抓取前的预检)。 -// -// 返回命中集合:key 为已存在的 source_url,value 恒为 true。爬虫拿到结果后应跳过这些图集, -// 不必再抓详情页、提取图片 URL 并上报——这一整套动作在判重命中时本来也会被 worker 丢弃。 -// 判定条件与 worker 判重完全一致(正式表未删除 / 草稿表 pending),因此不会漏抓也不会重复抓。 -func (s *IngestService) ExistsSourceURLs(ctx context.Context, urls []string) (map[string]bool, error) { - return s.repo.ExistsSourceURLs(ctx, urls) -} - -// ListJobs 列出最近的入库任务(后台监控页用)。 -func (s *IngestService) ListJobs(ctx context.Context, limit int) ([]model.IngestJob, error) { - return s.repo.ListJobs(ctx, limit) +// ListJobs 分页列出入库任务(后台监控页用)。返回本页任务、总条数与错误。 +func (s *IngestService) ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, int64, error) { + jobs, err := s.repo.ListJobs(ctx, offset, limit) + if err != nil { + return nil, 0, err + } + total, err := s.repo.CountJobs(ctx) + if err != nil { + return nil, 0, err + } + return jobs, total, nil } // RetryJob 把一条 failed 任务重新入队,等待 worker 重新处理。 @@ -130,6 +176,7 @@ func (s *IngestService) drain(ctx context.Context, batch int) { // process 处理单条任务:先按 job.Kind(DB 列)分流到 crawl / media_cleanup 两条管线。 // 旧爬虫任务(迁移前)kind 为空,回落 crawl 走秀/街拍入库管线。 func (s *IngestService) process(ctx context.Context, job model.IngestJob) { + start := time.Now() switch job.Kind { case model.IngestKindMediaCleanup: s.processMediaCleanup(ctx, job) @@ -150,6 +197,7 @@ func (s *IngestService) process(ctx context.Context, job model.IngestJob) { if p.Kind == "" { p.Kind = dto.IngestKindRunway } + log.Printf("[ingest] job=%d kind=%s 开始处理(图片=%d 张)", job.ID, p.Kind, len(p.Images)) switch p.Kind { case dto.IngestKindRunway: s.processRunway(ctx, job, p) @@ -159,15 +207,17 @@ func (s *IngestService) process(ctx context.Context, job model.IngestJob) { // 未知 kind 绝不静默当成走秀处理;所有爬虫数据都必须落到已注册的审核模块, // 否则标记任务失败,避免出现「未审核就入库」的脏数据。 _ = s.repo.MarkFailed(ctx, job.ID, "unknown ingest kind: "+p.Kind) + return } + log.Printf("[ingest] job=%d kind=%s 处理结束 总耗时=%v", job.ID, p.Kind, time.Since(start).Round(time.Millisecond)) } -// MediaCleanupPayload 是「清理七牛孤儿图」任务的 payload:待清理的图片 key 列表。 +// MediaCleanupPayload 是「清理S4孤儿图」任务的 payload:待清理的图片 key 列表。 type MediaCleanupPayload struct { Keys []string `json:"keys"` } -// EnqueueMediaCleanup 把一批待清理的七牛 key 异步入队;真正删除由 worker 的 +// EnqueueMediaCleanup 把一批待清理的S4 key 异步入队;真正删除由 worker 的 // processMediaCleanup 按引用计数判定,仅当 key 在所有图集/草稿表中引用归零才真删 // (内容寻址共享 key 不会被误删)。 func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) error { @@ -181,7 +231,7 @@ func (s *IngestService) EnqueueMediaCleanup(ctx context.Context, keys []string) return s.repo.EnqueueMediaCleanup(ctx, string(raw)) } -// processMediaCleanup 清理七牛孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。 +// processMediaCleanup 清理S4孤儿图任务:解析 key 列表,按跨表引用计数删除真孤儿。 // 删除/计数失败一律保守跳过(宁可留文件),因此本任务几乎总是成功置 done。 func (s *IngestService) processMediaCleanup(ctx context.Context, job model.IngestJob) { if s.media == nil || s.del == nil { @@ -212,7 +262,7 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin } } -// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_draft。 +// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。 func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { // 1) 品牌必须存在(爬虫负责先建/复用品牌) brandID, err := hashid.Decode(p.BrandUID) @@ -225,147 +275,435 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, return } - // 2) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同品牌同季时各建一条草稿, - // 晋升阶段(SaveRunwayFromDraft)按实体键(品牌+季节码+系列)合并为正式表一条并聚合图片。 - if _, found, err := s.repo.RunwayIDBySourceURL(ctx, p.SourceURL); err == nil && found { + // 2) 实体键查重并决定分支(单表模型) + // 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空, + // 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。 + effYear := p.Year + if effYear == 0 && p.PublishedAt != 0 { + effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year()) + } + seasonCode := season.Derive(effYear, p.CollectionType, p.Season) + reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType) + if err != nil { + // 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引 + //(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending) + // 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断, + // 故仍 fail-closed + 退避重试,不做无谓的写尝试。 + s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error()) + return + } + // 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。 + if exist && isDeleted { + log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID) _ = s.repo.MarkDone(ctx, job.ID) return } - if _, found, err := s.repo.DraftIDBySourceURL(ctx, p.SourceURL); err == nil && found { + if !exist && p.Year == 0 { + // 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次, + // 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。 + var rid uint32 + rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType) + if err != nil { + s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error()) + return + } + reuseID = rid + if exist && isDeleted { + log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID) + _ = s.repo.MarkDone(ctx, job.ID) + return + } + } + action := decideIngestAction(exist, existStatus) + if action == ingestSkip { + log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus) + // 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。 + if p.PublishedAt != 0 { + if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil { + s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error()) + return + } + } _ = s.repo.MarkDone(ctx, job.ID) return } - // 3) 补 season_code(vogue.go 历史漏填的 bug,统一在此兜底) - seasonCode := season.Derive(p.Year, p.CollectionType, p.Season) + // 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底) - // 4) 下载图片并上传到存储(七牛优先,失败兜底本地) - cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "runway") - if len(p.Images) > 0 && imgFailed { - // 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里, + // 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。 + // 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。 + var cover string + var images []model.BrandRunwayImage + // vecs 与 images 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。 + var vecs [][]float32 + var keys []string + var imgFailed bool + var imageCount uint16 + var timing *fetchTiming + if len(p.Looks) > 0 { + cover, images, vecs, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway") + // image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks): + // fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。 + // 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks), + // 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。 + imageCount = countMainImages(images) + } else { + var fimgs []fetchedImage + cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway") + imageCount = uint16(len(fimgs)) + for i, fi := range fimgs { + images = append(images, model.BrandRunwayImage{ + Image: fi.url, + Name: fmt.Sprintf("Look %d", i+1), + SortOrder: uint32(i + 1), + IsDetail: 0, + ImageSize: fi.size, + }) + vecs = append(vecs, fi.vec) + } + } + log.Printf("[ingest] job=%d runway 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing) + if imgFailed { + // 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里, // 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。 s.cleanupUploads(ctx, keys) s.failOrRetry(ctx, job.ID, "image download failed") return } - // 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表 - draft := &model.BrandRunwayDraft{ + // 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片) + writeStart := time.Now() + rw := &model.BrandRunway{ JobID: job.ID, BrandID: brandID, TitleEn: p.TitleEn, TitleCn: p.TitleCn, DescriptionEn: p.DescriptionEn, DescriptionCn: p.DescriptionCn, - Year: p.Year, + Year: effYear, Season: p.Season, CollectionType: p.CollectionType, SeasonCode: seasonCode, + PublishedAt: p.PublishedAt, Cover: cover, - SourceURL: p.SourceURL, - ImageCount: uint16(len(imgs)), - Status: model.DraftStatusPending, + ImageCount: imageCount, + Status: model.StatusPending, } - id, err := s.repo.CreateRunwayDraft(ctx, draft) - if err != nil { - s.failOrRetry(ctx, job.ID, "create draft: "+err.Error()) + + // 入库自动补全中文标题:spider 未给 title_cn 时,用翻译服务按「记忆优先、规则兜底」推导; + // 已给则保留(绝不覆盖爬虫提供/已有的翻译)。翻译服务未注入(nil)时跳过。 + if rw.TitleCn == "" && s.translate != nil { + if cn, terr := s.translate.SuggestTitleCn(ctx, rw.TitleEn); terr == nil { + rw.TitleCn = cn + } + } + + for i := range images { + images[i].BrandID = brandID + } + var saveErr error + if action == ingestReuseRejected { + saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images) + } else { + _, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images) + } + if saveErr != nil { + s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error()) return } - rows := make([]model.BrandRunwayDraftImage, 0, len(imgs)) - for i, img := range imgs { - rows = append(rows, model.BrandRunwayDraftImage{ - DraftID: id, - Image: img, - Name: fmt.Sprintf("Look %d", i+1), - SortOrder: uint32(i + 1), - }) + // 落库成功后写入语义向量(仅后台「疑似同款」用)。GORM 批量 Create 会把自增主键回填到 + // images,因此此处 images[i].ID 即新建图片 id;写失败只记日志,绝不影响入库结果。 + embedIDs := make([]uint32, 0, len(images)) + for i := range images { + embedIDs = append(embedIDs, images[i].ID) } - if err := s.repo.CreateRunwayDraftImages(ctx, rows); err != nil { - s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error()) - return + s.saveEmbeddings(ctx, dto.IngestKindRunway, embedIDs, vecs) + // 顺手把本场的「疑似同款」近邻算好落库(失败不影响入库)。 + s.precomputeSimilar(ctx, dto.IngestKindRunway, embedIDs) + // 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。 + runwayID := rw.ID + if runwayID == 0 { + runwayID = reuseID } + if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil { + log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr) + } + log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v", + job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected) _ = s.repo.MarkDone(ctx, job.ID) } -// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。 +// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的 +// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把 +// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。 +// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计 +//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。 +// +// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成 +// 「下载 → 上传」这段 IO 密集操作;随后再按下标顺序串行做批次内去重与组装。 +// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。 +func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, [][]float32, []string, bool, *fetchTiming) { + start := time.Now() + timing := &fetchTiming{} + cover := "" + rows := make([]model.BrandRunwayImage, 0) + // vecs 与 rows 下标一一对应(nil 表示该张未产出向量),供落库后写入 image_embeddings。 + vecs := make([][]float32, 0) + keys := make([]string, 0) + seen := make(map[string]bool) + failed := false + + // 摊平:顺序 = 逐个 look 先主图、再其细节图,与原串行遍历顺序完全一致。 + tasks := make([]lookImageTask, 0, len(looks)) + for li, look := range looks { + lookIdx := li + 1 + if look.Main != "" { + tasks = append(tasks, lookImageTask{url: look.Main, lookIdx: lookIdx}) + } + for di, d := range look.Details { + if d == "" { + continue + } + tasks = append(tasks, lookImageTask{url: d, lookIdx: lookIdx, isDetail: true, detailNo: di + 1}) + } + } + + // 并发段:下载 + 上传(耗时大头)。 + results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult { + return s.downloadAndUpload(c, tasks[i].url, prefix) + }) + + // 串行段:按原始顺序累计耗时、做批次内去重、组装行。 + order := 0 + for i, r := range results { + task := tasks[i] + timing.download += r.download + timing.upload += r.upload + timing.embed += r.embed + if r.err != nil { + failed = true + continue + } + keys = append(keys, r.key) + if seen[r.sha1] { + continue + } + seen[r.sha1] = true + order++ + if cover == "" && !task.isDetail { + cover = r.url + } + name := fmt.Sprintf("Look %d", task.lookIdx) + isDetail := uint8(0) + if task.isDetail { + name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo) + isDetail = 1 + } + rows = append(rows, model.BrandRunwayImage{ + Image: r.url, + Name: name, + SortOrder: uint32(order), + IsDetail: isDetail, + ImageSize: r.size, + }) + vecs = append(vecs, r.vec) + } + timing.images = len(rows) + timing.total = time.Since(start) + return cover, rows, vecs, keys, failed, timing +} + +// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。 +// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。 +func countMainImages(images []model.BrandRunwayImage) uint16 { + var n uint16 + for i := range images { + if images[i].IsDetail == 0 { + n++ + } + } + return n +} + +// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。 func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { - // 1) 按 source_url 去重(幂等):每来源一条草稿。多来源爬同城市同年时各建一条草稿, - // 晋升阶段(SaveStreetSnapFromDraft)按实体键(城市+年份)合并为正式表一条并聚合图片。 - if _, found, err := s.repo.StreetSnapIDBySourceURL(ctx, p.SourceURL); err == nil && found { + // 1) 实体键查重并决定分支(单表模型,同 runway)。 + // 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致, + // 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。 + // 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。 + title := repository.NormalizeStreetTitle(p.TitleEn) + reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title) + if err != nil { + // 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。 + s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error()) + return + } + // 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。 + if exist && isDeleted { + log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID) _ = s.repo.MarkDone(ctx, job.ID) return } - if _, found, err := s.repo.DraftStreetIDBySourceURL(ctx, p.SourceURL); err == nil && found { + action := decideIngestAction(exist, existStatus) + if action == ingestSkip { + log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus) _ = s.repo.MarkDone(ctx, job.ID) return } - // 2) 下载图片并上传到存储(七牛优先,失败兜底本地) - cover, imgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street") + // 2) 下载图片并上传到存储(S4优先,失败兜底本地)。 + cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street") + log.Printf("[ingest] job=%d street 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing) if len(p.Images) > 0 && imgFailed { - // 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里, + // 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里, // 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。 s.cleanupUploads(ctx, keys) s.failOrRetry(ctx, job.ID, "image download failed") return } - // 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snap 正式表 - draft := &model.StreetSnapDraft{ + // 3) 直写正式表(status=pending) + writeStart := time.Now() + + // 街拍入库自动补全中文标题(同 runway:spider 未给才补,且翻译服务需注入)。 + titleCn := p.TitleCn + if titleCn == "" && s.translate != nil { + if cn, terr := s.translate.SuggestTitleCn(ctx, title); terr == nil { + titleCn = cn + } + } + + snap := &model.StreetSnap{ JobID: job.ID, - Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en + Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致) + TitleCn: titleCn, Year: p.Year, City: p.City, Cover: cover, - SourceURL: p.SourceURL, - ImageCount: uint16(len(imgs)), - Status: model.DraftStatusPending, + ImageCount: uint16(len(fimgs)), + Status: model.StatusPending, } - id, err := s.repo.CreateStreetSnapDraft(ctx, draft) - if err != nil { - s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error()) - return - } - rows := make([]model.StreetSnapDraftImage, 0, len(imgs)) - for i, img := range imgs { - rows = append(rows, model.StreetSnapDraftImage{ - DraftID: id, - Image: img, + rows := make([]model.StreetSnapImage, 0, len(fimgs)) + // vecs 与 rows 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。 + vecs := make([][]float32, 0, len(fimgs)) + for i, fi := range fimgs { + rows = append(rows, model.StreetSnapImage{ + Image: fi.url, Name: fmt.Sprintf("Look %d", i+1), SortOrder: uint32(i + 1), + ImageSize: fi.size, }) + vecs = append(vecs, fi.vec) } - if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil { - s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error()) + var saveErr error + if action == ingestReuseRejected { + saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows) + } else { + _, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows) + } + if saveErr != nil { + s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error()) return } + // 落库成功后写入语义向量(同走秀:GORM 批量 Create 回填自增主键,故 rows[i].ID 即新建图片 id)。 + embedIDs := make([]uint32, 0, len(rows)) + for i := range rows { + embedIDs = append(embedIDs, rows[i].ID) + } + s.saveEmbeddings(ctx, dto.IngestKindStreet, embedIDs, vecs) + // 顺手把本专辑的「疑似同款」近邻算好落库(失败不影响入库)。 + s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs) + log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v", + job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected) _ = s.repo.MarkDone(ctx, job.ID) } -// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。 -// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true, -// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。 -func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []string, bool) { +// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。 +// prefix 为S4 key 前缀(runway/ 或 street/)。 +// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象, +// 符合「单图失败=整任务失败」策略。 +// +// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → 上传」,再按下标顺序串行 +// 做批次内去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。 +func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []fetchedImage, []string, bool, *fetchTiming) { + start := time.Now() + timing := &fetchTiming{} cover := "" - out := make([]string, 0, len(urls)) + out := make([]fetchedImage, 0, len(urls)) keys := make([]string, 0, len(urls)) + seen := make(map[string]bool) failed := false - for i, u := range urls { - url, key, err := s.downloadOne(ctx, u, i, prefix) - if err != nil { + + // 并发段:下载 + 上传(耗时大头)。 + results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult { + return s.downloadAndUpload(c, urls[i], prefix) + }) + + // 串行段:按原始顺序累计耗时、做批次内去重、组装结果。 + for _, r := range results { + timing.download += r.download + timing.upload += r.upload + timing.embed += r.embed + if r.err != nil { failed = true continue } - if i == 0 { - cover = url + keys = append(keys, r.key) + if seen[r.sha1] { + continue } - out = append(out, url) - keys = append(keys, key) + seen[r.sha1] = true + if cover == "" { + cover = r.url + } + out = append(out, fetchedImage{ + url: r.url, + key: r.key, + size: r.size, + vec: r.vec, + }) } - return cover, out, keys, failed + timing.images = len(out) + timing.total = time.Since(start) + return cover, out, keys, failed, timing } -// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚: +// precomputeSimilar 写完向量后顺手预计算「疑似同款」近邻并落库。 +// +// 为什么放在这里:原先是在**打开图集页时**对每张图实时做向量近邻检索,一个 116 张图的 +// 图集要发 116 次查询、每次约 20KB SQL;本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay +// 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计 5 秒。挪到入库时算一次(每个图集只付一次), +// 页面就退化成一次小查询。 +// +// 失败只记日志:预计算是增值数据,绝不能让「算不出同款」影响入库结果。 +// 注意:这里统计的是本次新入库的图片;它们与**其他图集存量图**的配对由预计算内部双向写入, +// 因此对方图集的页面也能立刻看到提示,无需重算对方。 +func (s *IngestService) precomputeSimilar(ctx context.Context, kind string, imageIDs []uint32) { + if s.similar == nil || len(imageIDs) == 0 { + return + } + start := time.Now() + if err := s.similar.PrecomputeForGallery(ctx, kind, imageIDs); err != nil { + log.Printf("[ingest] 「疑似同款」预计算失败 kind=%s 图=%d err=%v(不影响入库)", kind, len(imageIDs), err) + return + } + log.Printf("[ingest] 「疑似同款」预计算耗时 kind=%s 图=%d 耗时=%v", kind, len(imageIDs), time.Since(start).Round(time.Millisecond)) +} + +// saveEmbeddings 落库成功后写入一批图片的语义向量(仅后台「疑似同款」提示用)。 +// +// 向量是增值数据、不是入库的必要条件:仓储未注入(未启用视觉服务)、无向量、 +// 或写入失败,都只记日志并返回,绝不影响入库结果。ids[i] 与 vecs[i] 一一对应。 +func (s *IngestService) saveEmbeddings(ctx context.Context, kind string, ids []uint32, vecs [][]float32) { + if s.embedRep == nil || len(ids) == 0 || len(vecs) == 0 { + return + } + if err := s.embedRep.BulkUpsertEmbedding(ctx, kind, ids, vecs); err != nil { + log.Printf("[ingest] 写向量失败 kind=%s 数量=%d err=%v(不影响入库)", kind, len(ids), err) + } +} + +// cleanupUploads 删除一批本批次成功上传的对象(S4 + 本地兜底),用于任务失败回滚: // 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。 // 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。 // 注意:key 为 sha1 内容寻址,若恰与其他已晋升图集共享同一内容哈希会被一并移除,重试会重新上传补齐。 @@ -373,7 +711,7 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) { for _, k := range keys { if d, ok := s.uploader.(storage.Deleter); ok { if err := d.Delete(ctx, k); err != nil { - log.Printf("[warn] ingest cleanup: 七牛删除失败 key=%s err=%v", k, err) + log.Printf("[warn] ingest cleanup: S4删除失败 key=%s err=%v", k, err) } } if s.local != nil { @@ -384,42 +722,217 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) { } } -// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地), -// 返回可直接写入数据库的访问地址(七牛为完整 https URL,本地为相对 /uploads 路径)。 -func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, error) { +// fetchedImage 是单张已下载图片的暂存结构。 +type fetchedImage struct { + url string + key string + size int64 // 原图源文件字节数,随结果带回供落库写入 image_size + vec []float32 // FashionSigLIP 向量(nil = 未产出),供落库后写入 image_embeddings +} + +// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。 +const slowImageThreshold = 2 * time.Second + +// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。 +// +// 默认 1(等效串行),可用环境变量 INGEST_IMAGE_CONCURRENCY(1~16)覆盖。 +// +// 之所以默认 1:早期实测 theimpression.com(59 张图)显示源站对同 IP 并发有强限流—— +// 并发 1 时单张 ~3.7s / 整批 3m54s;并发 5 时单张涨到 ~23s,5m24s 只完成 12/59 张 +// 就因超时失败,吞吐从 ~0.27 张/秒掉到 ~0.04 张/秒。结论:并发会把任务拖垮,故取 1。 +// +// 但该结论**只对 theimpression 成立,不能外推到所有源站**。2026-10-13 对 assets.vogue.com +// 的冷图(未命中 CDN 边缘)实测:串行 1582ms/张,8 并发降到 661ms/张(2.4x),并发是有收益的。 +// 冷图慢是因为 CDN 要回源,属延迟受限而非带宽受限——正好是并发能掩盖的那一类。 +// +// 因此把它做成可配置项:换源站时先小批量实测再调(本文件顶部注释记录了两组实测数据)。 +var imageFetchConcurrency = func() int { + v := strings.TrimSpace(os.Getenv("INGEST_IMAGE_CONCURRENCY")) + if v == "" { + return 1 + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 16 { + log.Printf("[ingest] INGEST_IMAGE_CONCURRENCY=%q 非法(应为 1~16 的整数),回退并发 1", v) + return 1 + } + return n +}() + +// fetchTiming 汇总一次图集拉取各阶段的累计耗时,用于定位「慢在哪一步」。 +// 下载与上传是网络 IO,embed 是视觉服务调用(HTTP + 模型推理),分开计时才能对症下药。 +type fetchTiming struct { + images int // 成功入行的图片数 + total time.Duration // 整个拉取过程的墙钟耗时 + download time.Duration // 各张 HTTP 下载耗时之和 + upload time.Duration // 各张上传存储耗时之和 + embed time.Duration // 各张视觉服务编码耗时之和 +} + +// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。 +// 注意:下载 / 上传 / embed 是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时, +// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。 +func (t *fetchTiming) String() string { + if t == nil { + return "" + } + return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v embed=%v(各张累计值)", + t.images, + imageFetchConcurrency, + t.download.Round(time.Millisecond), + t.upload.Round(time.Millisecond), + t.embed.Round(time.Millisecond), + ) +} + +// downloadResult 是单张图「下载 → 上传」的结果,供并发执行后按下标串行组装。 +// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing), +// 这样并发路径上无需加锁即可保证计时准确。 +type downloadResult struct { + url string // 存储访问地址(失败为空) + key string // 对象 key(内容寻址) + sha1 string // 内容 sha1,用于批次内去重 + download time.Duration // 本张下载耗时 + upload time.Duration // 本张上传耗时 + size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度) + vec []float32 // FashionSigLIP 向量(nil = 未产出:服务未启用/不可用/失败) + embed time.Duration // 本张向量编码耗时 + err error // 下载或上传失败原因(非空即该张失败) +} + +// concurrentFetch 以 imageFetchConcurrency 路并发执行 fn(ctx, i)(i ∈ [0,n)),返回长度 n、 +// 下标与入参一一对齐的结果切片。 +// +// 下标对齐是刻意的:调用方随后按原始顺序串行组装,使 cover / sort_order / 批次内去重结果 +// 与串行实现完全一致,只把「下载+上传」这段 IO 并行化。 +// 每个下标只由一个 goroutine 写入,故无需加锁。 +func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) downloadResult) []downloadResult { + results := make([]downloadResult, n) + if n == 0 { + return results + } + sem := make(chan struct{}, imageFetchConcurrency) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + sem <- struct{}{} // 占坑:超过并发度即在此排队 + defer func() { <-sem }() // 释放坑位 + results[i] = fn(ctx, i) + }(i) + } + wg.Wait() + return results +} + +// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、 +// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。 +type lookImageTask struct { + url string + lookIdx int // 1-based look 序号 + isDetail bool // true=细节图 + detailNo int // 细节图序号(1-based),主图为 0 +} + +// downloadAndUpload 把单张远程图下载后上传到存储(S4优先,失败兜底本地)。 +// 内容 sha1 用于生成内容寻址的存储 key(重爬幂等)与批次内去重;访问地址可直接写入数据库 +// (S4为完整 https URL,本地为相对 /uploads 路径)。 +// +// 本函数是并发调用点:每次调用各自返回一份独立的 downloadResult,不共享可变状态; +// 耗时汇总与去重留痕由调用方在串行段统一完成。 +func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string) downloadResult { + res := downloadResult{} + + // ① 下载 + dlStart := time.Now() resp, err := s.httpClient.Get(u) if err != nil { - return "", "", err + res.err = err + return res } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("status %d", resp.StatusCode) + res.err = fmt.Errorf("status %d", resp.StatusCode) + return res } data, err := io.ReadAll(resp.Body) if err != nil { - return "", "", err + res.err = err + return res } + res.download = time.Since(dlStart) + res.size = int64(len(data)) + ext := path.Ext(u) if ext == "" || len(ext) > 5 { ext = ".jpg" } // 内容哈希作 key:同一张图(无论来自哪篇文章/job)永远得到相同 key, - // 七牛覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。 + // S4覆盖写即天然幂等,不会因重复采集、崩溃重试、reject 重爬而累积孤儿文件。 h := sha1.Sum(data) - hash := hex.EncodeToString(h[:]) - key := fmt.Sprintf("%s/%s%s", prefix, hash, ext) + res.sha1 = hex.EncodeToString(h[:]) + res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext) ct := resp.Header.Get("Content-Type") - // 主上传器(七牛) - if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil { - return url, key, nil - } else if s.local != nil { - // 兜底本地,避免图片完全丢失 - if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil { - return lurl, key, nil - } else { - return "", "", err + // ② 视觉服务编码(可选):产出后台「疑似同款」用的语义向量。 + // 未启用 / 不可用 / 超时 / 维度不符一律留空 vec —— 绝不阻断入库,也不在此重试 + // (重试会拖慢整批;熔断由客户端统一处理)。 + if s.vision != nil && s.vision.Enabled() { + embedStart := time.Now() + vecs, _, eerr := s.vision.Embed(ctx, [][]byte{data}) + res.embed = time.Since(embedStart) + if eerr == nil && len(vecs) > 0 { + res.vec = vecs[0] + } else if eerr != nil { + log.Printf("[ingest] embed 失败 url=%s err=%v(不写向量,继续入库)", u, eerr) } } - return "", "", err + + // ③ 上传(主存储 S4,失败兜底本地) + upStart := time.Now() + if url, uerr := s.uploader.Upload(ctx, res.key, data, ct); uerr == nil { + res.url = url + } else if s.local != nil { + // 兜底本地,避免图片完全丢失 + if lurl, lerr := s.local.Upload(ctx, res.key, data, ct); lerr == nil { + res.url = lurl + } else { + res.err = uerr + } + } else { + res.err = uerr + } + res.upload = time.Since(upStart) + + // 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。 + if total := res.download + res.embed + res.upload; total > slowImageThreshold { + log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v embed=%v 上传=%v", + u, total.Round(time.Millisecond), + res.download.Round(time.Millisecond), res.embed.Round(time.Millisecond), + res.upload.Round(time.Millisecond)) + } + return res +} + +// ingestActionKind 入库分支决策结果(单表发布模型)。 +type ingestActionKind int + +const ( + ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录 + ingestSkip // 命中 pending/published → 放弃本次入库 + ingestReuseRejected // 命中 rejected → 复用该行重审 +) + +// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。 +// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。 +func decideIngestAction(found bool, status string) ingestActionKind { + switch { + case !found: + return ingestCreate + case status == model.StatusRejected: + return ingestReuseRejected + default: + return ingestSkip + } } diff --git a/internal/service/ingest_service_test.go b/internal/service/ingest_service_test.go index 3ed68ea..a83dc4e 100644 --- a/internal/service/ingest_service_test.go +++ b/internal/service/ingest_service_test.go @@ -33,7 +33,7 @@ func (c *countingDeleter) Delete(ctx context.Context, key string) error { return c.LocalUploader.Delete(ctx, key) } -// TestProcessMediaCleanup 验证「清理七牛孤儿图」任务:引用计数为 0 的 key 会被真删。 +// TestProcessMediaCleanup 验证「清理S4孤儿图」任务:引用计数为 0 的 key 会被真删。 func TestProcessMediaCleanup(t *testing.T) { dir := t.TempDir() up := storage.NewLocalUploader(dir, "/uploads") @@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) { t.Fatalf("预置文件失败: %v", err) } - svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up) + svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil, nil, nil) job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`} svc.processMediaCleanup(context.Background(), job) @@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) { del := &countingDeleter{LocalUploader: up} // 引用计数恒为 1(仍被其他图集使用),不应真删。 - svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up) + svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil, nil, nil) job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`} svc.processMediaCleanup(context.Background(), job) @@ -84,3 +84,26 @@ func TestIngestRetryBackoff(t *testing.T) { } } } + +// TestDecideIngestAction 入库三分支决策:未命中→新建;命中 pending/published→放弃;命中 rejected→复用重审。 +// 这是「驳回不是永久黑名单」这条规则的直接证据。 +func TestDecideIngestAction(t *testing.T) { + cases := []struct { + name string + found bool + status string + want ingestActionKind + }{ + {"未命中→新建", false, "", ingestCreate}, + {"命中 pending→放弃", true, model.StatusPending, ingestSkip}, + {"命中 published→放弃", true, model.StatusPublished, ingestSkip}, + {"命中 rejected→复用重审", true, model.StatusRejected, ingestReuseRejected}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := decideIngestAction(c.found, c.status); got != c.want { + t.Fatalf("decideIngestAction(%v, %q) = %v, want %v", c.found, c.status, got, c.want) + } + }) + } +} diff --git a/internal/service/manual_article_service.go b/internal/service/manual_article_service.go new file mode 100644 index 0000000..7d79da0 --- /dev/null +++ b/internal/service/manual_article_service.go @@ -0,0 +1,351 @@ +package service + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/season" + "fashionapi/internal/pkg/storage" + "fashionapi/internal/repository" + "fashionapi/internal/vision" +) + +// ManualImage 一张待上传的图片(后台表单读入的原始字节)。 +type ManualImage struct { + Filename string + Data []byte +} + +// ManualArticleInput 后台「手工新建文章」的输入:元数据 + 图片字节。 +type ManualArticleInput struct { + Kind string // runway | street + + // 走秀 + BrandName string // 品牌英文名,精确匹配(不区分大小写) + TitleEn string + TitleCn string + Year uint16 + Season string // spring | fall | resort | pre_fall + CollectionType string // rtw | menswear | couture | resort | pre_fall + + // 街拍 + City string + + Images []ManualImage +} + +const ( + manualKindRunway = "runway" + manualKindStreet = "street" + manualMaxImageBytes = 10 << 20 // 单张 10MB +) + +// ManualArticleService 后台手工建稿:上传图片到存储 + 写正式表(status=pending)。 +// +// 与爬虫入库的唯一区别是「图片来源」:那边拿 URL 由 worker 下载,这里直接拿字节。 +// 因此复用同一套存储层 Uploader(sha1 内容寻址)与同一组入库仓储写入方法, +// 保证两条路径产出的对象 key / 行结构完全一致——后台不需要为它们维护第二套口径。 +type ManualArticleService struct { + ingest repository.IngestRepository + brands repository.BrandRepository + uploader storage.Uploader + local *storage.LocalUploader // S4 上传失败时的本地兜底(可为 nil) + vision *vision.Client // 可为 nil:未启用视觉服务时跳过向量 + embedRep repository.EmbeddingRepository // 可为 nil +} + +// NewManualArticleService 创建手工建稿服务。 +func NewManualArticleService(ingest repository.IngestRepository, brands repository.BrandRepository, uploader storage.Uploader, local *storage.LocalUploader, vis *vision.Client, embedRep repository.EmbeddingRepository) *ManualArticleService { + return &ManualArticleService{ingest: ingest, brands: brands, uploader: uploader, local: local, vision: vis, embedRep: embedRep} +} + +// Create 建稿:校验 → 上传图片(sha1 内容寻址、同内容自动去重)→ 写正式表 → 补向量。 +// 返回新建记录的数字主键。 +func (s *ManualArticleService) Create(ctx context.Context, in ManualArticleInput) (uint32, error) { + if len(in.Images) == 0 { + return 0, NewError(http.StatusBadRequest, "至少上传 1 张图片") + } + switch in.Kind { + case manualKindRunway: + return s.createRunway(ctx, in) + case manualKindStreet: + return s.createStreet(ctx, in) + default: + return 0, NewError(http.StatusBadRequest, "类型无效(仅支持走秀 / 街拍)") + } +} + +func (s *ManualArticleService) createRunway(ctx context.Context, in ManualArticleInput) (uint32, error) { + titleEn := strings.TrimSpace(in.TitleEn) + if titleEn == "" { + return 0, NewError(http.StatusBadRequest, "走秀标题(英文)必填") + } + if in.Year == 0 || strings.TrimSpace(in.Season) == "" || strings.TrimSpace(in.CollectionType) == "" { + return 0, NewError(http.StatusBadRequest, "年份、季节、系列均必填") + } + brand, err := s.findBrand(ctx, in.BrandName) + if err != nil { + return 0, err + } + seasonCode := season.Derive(in.Year, in.CollectionType, in.Season) + if seasonCode == "" { + return 0, NewError(http.StatusBadRequest, "无法推导季节码,请检查年份") + } + // 实体键预检:与库里的唯一索引同口径,先给出人话提示,避免把 23505 甩给运营。 + if _, _, found, deleted, e := s.ingest.RunwayEntityState(ctx, brand.ID, seasonCode, in.CollectionType); e != nil { + return 0, internalErr(e.Error()) + } else if found && !deleted { + return 0, NewError(http.StatusConflict, "该品牌在同一季节/系列下已有图集,请直接编辑那一条,不要重复新建") + } + + uploaded, err := s.uploadAll(ctx, manualKindRunway, in.Images) + if err != nil { + return 0, err + } + + now := uint32(time.Now().Unix()) + rw := &model.BrandRunway{ + JobID: 0, // 手工建稿没有入库任务;该列 NOT NULL,用 0 表示「无任务」 + BrandID: brand.ID, + TitleEn: titleEn, + TitleCn: strings.TrimSpace(in.TitleCn), + Year: in.Year, + Season: in.Season, + CollectionType: in.CollectionType, + SeasonCode: seasonCode, + Cover: uploaded[0].key, + ImageCount: uint16(len(uploaded)), + Status: model.StatusPending, + CreatedAt: now, + UpdatedAt: now, + } + imgs := make([]model.BrandRunwayImage, 0, len(uploaded)) + for i, u := range uploaded { + imgs = append(imgs, model.BrandRunwayImage{ + Image: u.key, + Name: fmt.Sprintf("Look %d", i+1), + SortOrder: uint32(i + 1), + IsDetail: 0, + ImageSize: u.size, + }) + } + id, err := s.ingest.CreateRunwayWithImages(ctx, rw, imgs) + if err != nil { + return 0, s.writeErr(err) + } + s.embedUploaded(ctx, manualKindRunway, runwayImageIDs(imgs), uploaded) + return id, nil +} + +func (s *ManualArticleService) createStreet(ctx context.Context, in ManualArticleInput) (uint32, error) { + title := strings.TrimSpace(in.TitleEn) + titleCn := strings.TrimSpace(in.TitleCn) + if title == "" { + title = titleCn + } + if title == "" { + return 0, NewError(http.StatusBadRequest, "街拍标题必填") + } + city := strings.TrimSpace(in.City) + if city == "" { + return 0, NewError(http.StatusBadRequest, "城市必填") + } + if in.Year == 0 { + return 0, NewError(http.StatusBadRequest, "年份必填") + } + if _, _, found, deleted, e := s.ingest.StreetSnapEntityState(ctx, city, in.Year, title); e != nil { + return 0, internalErr(e.Error()) + } else if found && !deleted { + return 0, NewError(http.StatusConflict, "同一城市、年份、标题下已有街拍,请直接编辑那一条,不要重复新建") + } + + uploaded, err := s.uploadAll(ctx, manualKindStreet, in.Images) + if err != nil { + return 0, err + } + + now := uint32(time.Now().Unix()) + snap := &model.StreetSnap{ + JobID: 0, + Title: title, + TitleCn: titleCn, + City: city, + Year: in.Year, + Cover: uploaded[0].key, + ImageCount: uint16(len(uploaded)), + Status: model.StatusPending, + // source / source_url 不设:模型未映射这两列,由库默认值兜底(爬虫入库同样不设)。 + // 唯一索引 uq_ss_source 只对非空 source_url 生效,因此手工建稿天然不会与来源去重冲突。 + CreatedAt: now, + UpdatedAt: now, + } + imgs := make([]model.StreetSnapImage, 0, len(uploaded)) + for i, u := range uploaded { + imgs = append(imgs, model.StreetSnapImage{ + Image: u.key, + Name: fmt.Sprintf("Look %d", i+1), + SortOrder: uint32(i + 1), + ImageSize: u.size, + }) + } + id, err := s.ingest.CreateStreetSnapWithImages(ctx, snap, imgs) + if err != nil { + return 0, s.writeErr(err) + } + s.embedUploaded(ctx, manualKindStreet, streetImageIDs(imgs), uploaded) + return id, nil +} + +// uploadedManualImage 一张已上传成功的图片:内容寻址 key + 原始字节数 + 原始字节。 +// data 保留下来只为随后补向量,避免再向存储回读一次。 +type uploadedManualImage struct { + key string + size int64 + data []byte +} + +// uploadAll 逐张校验 → 内容寻址 key → 上传(S4 失败回退本地)→ 同内容去重。 +// +// 类型用 http.DetectContentType 按真实字节判断,不看扩展名(可伪造); +// key 用 sha1,与爬虫入库完全同一口径,因此「同一张图」无论来自哪条路径都落在同一个对象上。 +func (s *ManualArticleService) uploadAll(ctx context.Context, prefix string, images []ManualImage) ([]uploadedManualImage, error) { + out := make([]uploadedManualImage, 0, len(images)) + seen := make(map[string]struct{}, len(images)) + for _, img := range images { + if len(img.Data) == 0 { + continue + } + if len(img.Data) > manualMaxImageBytes { + return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 超过 10MB", img.Filename)) + } + ct := http.DetectContentType(img.Data) + ext, ok := manualImageExt(ct) + if !ok { + return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 不是 JPG/PNG/WEBP(实际识别为 %s)", img.Filename, ct)) + } + sum := sha1.Sum(img.Data) + key := prefix + "/" + hex.EncodeToString(sum[:]) + ext + if _, dup := seen[key]; dup { + continue // 同一次提交里的同内容图片只留一份 + } + seen[key] = struct{}{} + if _, err := s.uploader.Upload(ctx, key, img.Data, ct); err != nil { + if s.local == nil { + return nil, internalErr("图片上传失败: " + err.Error()) + } + if _, lerr := s.local.Upload(ctx, key, img.Data, ct); lerr != nil { + return nil, internalErr("图片上传失败(本地兜底也失败): " + err.Error()) + } + } + out = append(out, uploadedManualImage{key: key, size: int64(len(img.Data)), data: img.Data}) + } + if len(out) == 0 { + return nil, NewError(http.StatusBadRequest, "没有可用的图片(全部为空或重复)") + } + return out, nil +} + +// manualImageExt 把真实 content-type 映射为扩展名;不支持的格式返回 ok=false。 +func manualImageExt(contentType string) (string, bool) { + switch contentType { + case "image/jpeg": + return ".jpg", true + case "image/png": + return ".png", true + case "image/webp": + return ".webp", true + } + return "", false +} + +// findBrand 按英文名精确匹配(不区分大小写、忽略首尾空格)。 +// +// 用 AdminList 的关键词查询而不是新增仓储方法:关键词路径本身就会跳过「必须有图集」 +// 的过滤,因此能命中导入的 2 万+ 无图集品牌。 +func (s *ManualArticleService) findBrand(ctx context.Context, name string) (*model.Brand, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, NewError(http.StatusBadRequest, "品牌名必填") + } + items, _, err := s.brands.ListAdmin(ctx, dto.BrandQuery{Keyword: name, Size: 50}, nil) + if err != nil { + return nil, internalErr(err.Error()) + } + for i := range items { + if strings.EqualFold(strings.TrimSpace(items[i].NameEn), name) { + return &items[i], nil + } + } + return nil, NewError(http.StatusBadRequest, "找不到品牌「"+name+"」,请先在品牌管理里新建(英文名需完全一致)") +} + +// writeErr 把唯一键冲突翻译成人话;其它错误按内部错误处理。 +// 预检之外仍有并发窗口,DB 层的兜底错误不该原样甩给运营。 +func (s *ManualArticleService) writeErr(err error) error { + msg := err.Error() + if strings.Contains(msg, "uq_br_entity") || strings.Contains(msg, "uq_ss_entity") || + strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key") { + return NewError(http.StatusConflict, "该实体已存在未删除的记录(走秀=品牌+季节+系列;街拍=城市+年份+标题)") + } + return internalErr(msg) +} + +// embedUploaded 给刚写入的图片补向量,供后台「疑似同款」使用。 +// +// 任何一步不可用(未注入视觉服务 / 仓储为 nil / 熔断中 / 批量调用失败 / 图片 id 未回填) +// 都静默跳过:建稿成功与否绝不能取决于向量产出。 +// +// 只处理「仓储回填了主键」的图片(id == 0 视为未回填),并且只在「返回的向量数与输入一一对应」 +// 时才写入——有失败项时映射关系不再可靠,宁可少写向量,也不写错位的向量。 +func (s *ManualArticleService) embedUploaded(ctx context.Context, kind string, imageIDs []uint32, uploaded []uploadedManualImage) { + if s.vision == nil || s.embedRep == nil || !s.vision.Enabled() { + return + } + batch := make([][]byte, 0, len(uploaded)) + slot := make([]uint32, 0, len(uploaded)) + for i, u := range uploaded { + if i >= len(imageIDs) || imageIDs[i] == 0 || len(u.data) == 0 { + continue + } + batch = append(batch, u.data) + slot = append(slot, imageIDs[i]) + } + if len(batch) == 0 { + return + } + vecs, idxs, err := s.vision.Embed(ctx, batch) + if err != nil || len(vecs) != len(batch) || len(idxs) != len(batch) { + return + } + for i := range batch { + if len(vecs[i]) == 0 { + continue + } + _ = s.embedRep.UpsertEmbedding(ctx, kind, slot[i], vecs[i]) + } +} + +// imageIDsOf 取出仓储回填到图片行上的主键(未回填时为 0,调用方据此跳过)。 +func runwayImageIDs(imgs []model.BrandRunwayImage) []uint32 { + ids := make([]uint32, len(imgs)) + for i := range imgs { + ids[i] = imgs[i].ID + } + return ids +} + +// streetImageIDs 同上,街拍版。 +func streetImageIDs(imgs []model.StreetSnapImage) []uint32 { + ids := make([]uint32, len(imgs)) + for i := range imgs { + ids[i] = imgs[i].ID + } + return ids +} diff --git a/internal/service/manual_article_service_test.go b/internal/service/manual_article_service_test.go new file mode 100644 index 0000000..e6710f0 --- /dev/null +++ b/internal/service/manual_article_service_test.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "testing" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/repository" +) + +// stubManualIngest 只实现建稿用到的两个方法(其余经嵌入接口转发,本测试不会调用)。 +type stubManualIngest struct { + repository.IngestRepository + entityHit bool + created bool + gotRW *model.BrandRunway + gotImgs []model.BrandRunwayImage +} + +func (s *stubManualIngest) RunwayEntityState(_ context.Context, _ uint32, _, _ string) (uint32, string, bool, bool, error) { + return 0, "", s.entityHit, false, nil +} + +func (s *stubManualIngest) CreateRunwayWithImages(_ context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) { + s.created, s.gotRW, s.gotImgs = true, rw, imgs + return 99, nil +} + +// stubManualBrands 提供品牌精确匹配所需的一行。 +type stubManualBrands struct { + repository.BrandRepository + name string +} + +func (s *stubManualBrands) ListAdmin(_ context.Context, _ dto.BrandQuery, _ []uint32) ([]model.Brand, int64, error) { + if s.name == "" { + return []model.Brand{}, 0, nil + } + return []model.Brand{{ID: 7, NameEn: s.name}}, 1, nil +} + +// stubManualUploader 记录上传的 key,用于断言 sha1 内容寻址与去重。 +type stubManualUploader struct{ keys []string } + +func (s *stubManualUploader) Upload(_ context.Context, key string, _ []byte, _ string) (string, error) { + s.keys = append(s.keys, key) + return "https://cdn/" + key, nil +} +func (s *stubManualUploader) Enabled() bool { return true } + +func manualRunwayInput(images []ManualImage) ManualArticleInput { + return ManualArticleInput{ + Kind: manualKindRunway, BrandName: "Gucci", TitleEn: "Spring 2027 RTW", + Year: 2027, Season: "spring", CollectionType: "rtw", + Images: images, + } +} + +// 一张最小 JPEG 头,http.DetectContentType 会识别为 image/jpeg。 +var fakeJPEG = append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("manual-test-payload")...) + +// TestManualCreateRequiresImages 没有图片必须直接拒绝,且既不上传也不写库。 +func TestManualCreateRequiresImages(t *testing.T) { + up := &stubManualUploader{} + ing := &stubManualIngest{} + svc := NewManualArticleService(ing, &stubManualBrands{name: "Gucci"}, up, nil, nil, nil) + + if _, err := svc.Create(context.Background(), manualRunwayInput(nil)); err == nil { + t.Fatal("没有图片应报错") + } + if len(up.keys) != 0 || ing.created { + t.Fatalf("校验失败不应上传/写库,实际 uploads=%d created=%v", len(up.keys), ing.created) + } +} + +// TestManualCreateBrandNotFound 品牌名匹配不到时必须报错且不写库。 +func TestManualCreateBrandNotFound(t *testing.T) { + up := &stubManualUploader{} + ing := &stubManualIngest{} + svc := NewManualArticleService(ing, &stubManualBrands{name: "SomeOtherBrand"}, up, nil, nil, nil) + + in := manualRunwayInput([]ManualImage{{Filename: "a.jpg", Data: fakeJPEG}}) + if _, err := svc.Create(context.Background(), in); err == nil { + t.Fatal("品牌不存在应报错") + } + if ing.created || len(up.keys) != 0 { + t.Fatalf("品牌不存在不应写库/上传,实际 created=%v uploads=%d", ing.created, len(up.keys)) + } +} + +// TestManualCreateRunwayUploadsAndInserts 正常提交:sha1 内容寻址、同内容去重、 +// 封面取第一张、image_count 与实际行数一致。 +func TestManualCreateRunwayUploadsAndInserts(t *testing.T) { + up := &stubManualUploader{} + ing := &stubManualIngest{} + svc := NewManualArticleService(ing, &stubManualBrands{name: "gucci"}, up, nil, nil, nil) + + // 两张同内容(应去重成 1 张)+ 一张不同内容 → 共 2 张 + other := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("another-payload")...) + in := manualRunwayInput([]ManualImage{ + {Filename: "a.jpg", Data: fakeJPEG}, + {Filename: "a-copy.jpg", Data: fakeJPEG}, + {Filename: "b.jpg", Data: other}, + }) + id, err := svc.Create(context.Background(), in) + if err != nil { + t.Fatalf("正常提交不应报错: %v", err) + } + if id != 99 { + t.Fatalf("应返回仓储给的主键,实际 %d", id) + } + if len(up.keys) != 2 { + t.Fatalf("同内容图片应去重为 2 张上传,实际 %d: %v", len(up.keys), up.keys) + } + for _, k := range up.keys { + if len(k) < len("runway/")+40+len(".jpg") || k[:7] != "runway/" { + t.Fatalf("key 应为 runway/.jpg,实际 %q", k) + } + } + if !ing.created { + t.Fatal("应写入正式表") + } + if len(ing.gotImgs) != 2 || ing.gotRW.ImageCount != 2 { + t.Fatalf("图片行数与 image_count 都应等于 2,实际 rows=%d count=%d", len(ing.gotImgs), ing.gotRW.ImageCount) + } + if ing.gotRW.Cover != up.keys[0] { + t.Fatalf("封面应取第一张,实际 cover=%q keys=%v", ing.gotRW.Cover, up.keys) + } + if ing.gotRW.Status != model.StatusPending { + t.Fatalf("新建记录必须是 pending(走审核),实际 %s", ing.gotRW.Status) + } + if ing.gotRW.BrandID != 7 { + t.Fatalf("品牌应按英文名匹配到 id=7(不区分大小写),实际 %d", ing.gotRW.BrandID) + } +} diff --git a/internal/service/media_purge.go b/internal/service/media_purge.go index f9e7151..4d9d9ad 100644 --- a/internal/service/media_purge.go +++ b/internal/service/media_purge.go @@ -8,14 +8,14 @@ import ( "fashionapi/internal/repository" ) -// purgeOrphanImages 清理七牛/本地孤儿图片:仅当某 key 在所有图集/草稿表中 +// purgeOrphanImages 清理S4/本地孤儿图片:仅当某 key 在所有图集/草稿表中 // 引用计数归零时才真正删除对象;否则保留(仍有其他图集在用,避免误删)。 // -// 设计要点(为何必须引用计数而非「删库即删七牛」): -// 七牛 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。 +// 设计要点(为何必须引用计数而非「删库即删S4」): +// S4 key 为 sha1 内容寻址,同一张图跨图集共享同一 key。直接删会误伤其他图集。 // 另外,删除失败/计数失败一律「保守跳过」——宁可留文件,不冒误删被引用文件的风险。 // -// 参数 del 为 storage.Deleter(七牛或本地实现),由装配点按配置注入。 +// 参数 del 为 storage.Deleter(S4或本地实现),由装配点按配置注入。 func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, del storage.Deleter, keys []string) { seen := make(map[string]struct{}, len(keys)) for _, k := range keys { @@ -37,7 +37,7 @@ func purgeOrphanImages(ctx context.Context, media repository.MediaRepository, de continue } if err := del.Delete(ctx, k); err != nil { - log.Printf("[purge] 七牛/本地删除失败,保留 key=%s: %v", k, err) + log.Printf("[purge] S4/本地删除失败,保留 key=%s: %v", k, err) continue } log.Printf("[purge] 已清理孤儿图片 key=%s", k) diff --git a/internal/service/review_service.go b/internal/service/review_service.go index 6a2784b..4f44acc 100644 --- a/internal/service/review_service.go +++ b/internal/service/review_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "sort" "strconv" @@ -12,8 +13,8 @@ import ( "fashionapi/internal/repository" ) -// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。 -type DraftCard struct { +// RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。 +type RecordCard struct { Kind string `json:"kind"` // runway | street | 未来其它模块 ID uint32 `json:"id"` Title string `json:"title"` // 展示标题 @@ -21,17 +22,22 @@ type DraftCard struct { Cover string `json:"cover"` ImageCount uint16 `json:"image_count"` Status string `json:"status"` - SourceURL string `json:"source_url"` CreatedAt uint32 `json:"created_at"` UpdatedAt uint32 `json:"updated_at"` } -// DraftImageRef 草稿图片引用(统一结构)。 -type DraftImageRef struct { - ID uint32 `json:"id"` - Image string `json:"image"` - Name string `json:"name"` - SortOrder uint32 `json:"sort_order"` +// RecordImageRef 记录图片引用(统一结构)。 +// +// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0), +// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。 +type RecordImageRef struct { + ID uint32 `json:"id"` + Image string `json:"image"` + Name string `json:"name"` + SortOrder uint32 `json:"sort_order"` + IsDetail uint8 `json:"is_detail"` // 1=副图(挂在 ParentImageID 指向的主图下);0=主图 + ParentImageID uint32 `json:"parent_image_id"` // 副图所属主图的行 id;主图为 0 + DetailCount int `json:"detail_count"` // 仅主图有意义:名下(存活)副图数量 } // EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框, @@ -43,30 +49,43 @@ type EditField struct { Type string `json:"type"` // text | textarea | number } -// DraftDetailView 审核详情统一视图(所有模块共用)。 -type DraftDetailView struct { - Kind string `json:"kind"` - ID uint32 `json:"id"` - BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0 - Title string `json:"title"` // 展示标题(runway=title_en,street=title) - TitleEn string `json:"title_en"` - TitleCn string `json:"title_cn"` - DescriptionEn string `json:"description_en"` - DescriptionCn string `json:"description_cn"` - Year uint16 `json:"year"` - Season string `json:"season"` - CollectionType string `json:"collection_type"` - SeasonCode string `json:"season_code"` - City string `json:"city"` - Cover string `json:"cover"` - SourceURL string `json:"source_url"` - ImageCount uint16 `json:"image_count"` - Status string `json:"status"` - Images []DraftImageRef `json:"images"` +// RecordDetailView 审核详情统一视图(所有模块共用)。 +type RecordDetailView struct { + Kind string `json:"kind"` + ID uint32 `json:"id"` + BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0 + Title string `json:"title"` // 展示标题(runway=title_en,street=title) + TitleEn string `json:"title_en"` + TitleCn string `json:"title_cn"` + DescriptionEn string `json:"description_en"` + DescriptionCn string `json:"description_cn"` + Year uint16 `json:"year"` + Season string `json:"season"` + CollectionType string `json:"collection_type"` + SeasonCode string `json:"season_code"` + City string `json:"city"` + Cover string `json:"cover"` + ImageCount uint16 `json:"image_count"` + Status string `json:"status"` + Images []RecordImageRef `json:"images"` // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 Fields []EditField `json:"fields"` + // Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。 + // 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。 + // 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。 + Groups []ImageGroup `json:"groups,omitempty"` } +// ImageGroup 一张主图及其副图,供审核页按组折叠渲染。 +type ImageGroup struct { + Image RecordImageRef `json:"image"` // 主图 + Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空) +} + +// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。 +// 这里是给用户看的文案,handler 直接渲染 err.Error()。 +var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图") + // KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。 type KindTab struct { Kind string `json:"kind"` // 空串表示「全部」 @@ -74,37 +93,43 @@ type KindTab struct { Pending int64 `json:"pending"` } -// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。 +// ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。 // // 新增一种爬虫数据类型需要审核时,只需: -// 1. 建草稿表 + 迁移; -// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending; +// 1. 建正式表 + 迁移(记录自带 status 列); +// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus; // 3. 在此文件实现本接口并注册到 NewReviewService。 // -// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。 +// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。 type ReviewModule interface { Kind() string Label() string - ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) - DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) - UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error - // Approve 晋升到正式表并把草稿置 approved;返回正式表主键。 + ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) + RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) + UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error + // Approve 把正式记录置为 published;返回记录主键。 + // 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。 Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) - Reject(ctx context.Context, id uint32, reviewer, reason string) error CountPending(ctx context.Context) (int64, error) } -// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。 +// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。 // 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。 +// +// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」 +// (articleService / streetSnapService 的 SetDeleted → PurgeGallery), +// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。 type ReviewService interface { - ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) - DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) - UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error + ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) + RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) + UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) - Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error - CountPending(ctx context.Context) (int64, error) - // DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删七牛文件)。 - DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error + // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。 + DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error + // AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 + AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error + // DetachImage 把副图拆出恢复为主图(街拍专用)。 + DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 KindTabs(ctx context.Context) []KindTab } @@ -119,8 +144,8 @@ type reviewService struct { } // NewReviewService 创建审核服务并注册所有已知模块。 -// img 负责把库里存储的七牛 key 拼装为高清 URL(后台审核预览始终看高清原图); -// media + deleter 用于审核阶段剔除坏图时清理七牛孤儿文件(引用计数归零才真删)。 +// img 负责把库里存储的S4 key 拼装为高清 URL(后台审核预览始终看高清原图); +// media + deleter 用于审核阶段剔除坏图时清理S4孤儿文件(引用计数归零才真删)。 func NewReviewService(repo repository.ReviewRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter) ReviewService { svc := &reviewService{ repo: repo, @@ -144,22 +169,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) { return m, ok } -func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) { +func (s *reviewService) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) { if kind == "all" { kind = "" } if kind != "" { m, ok := s.module(kind) if !ok { - return []DraftCard{}, 0, nil + return []RecordCard{}, 0, nil } - return m.ListDrafts(ctx, status, page, size) + return m.ListRecords(ctx, status, page, size) } // 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。 - var all []DraftCard + var all []RecordCard var total int64 for _, k := range s.order { - cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000) + cards, t, err := s.modules[k].ListRecords(ctx, status, 1, 1000) if err != nil { return nil, 0, err } @@ -187,20 +212,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag return all[start:end], total, nil } -func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) { +func (s *reviewService) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) { m, ok := s.module(kind) if !ok { return nil, repository.ErrNotFound } - return m.DraftDetail(ctx, id) + return m.RecordDetail(ctx, id) } -func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error { +func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error { m, ok := s.module(kind) if !ok { return repository.ErrNotFound } - return m.UpdateDraft(ctx, id, fields) + return m.UpdateRecord(ctx, id, fields) } func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) { @@ -211,26 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev return m.Approve(ctx, id, reviewer) } -func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error { - m, ok := s.module(kind) - if !ok { - return repository.ErrNotFound - } - return m.Reject(ctx, id, reviewer, reason) -} - -func (s *reviewService) CountPending(ctx context.Context) (int64, error) { - var total int64 - for _, k := range s.order { - n, err := s.modules[k].CountPending(ctx) - if err != nil { - return 0, err - } - total += n - } - return total, nil -} - func (s *reviewService) KindTabs(ctx context.Context) []KindTab { tabs := []KindTab{{Kind: "", Label: "全部"}} var all int64 @@ -247,22 +252,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab { return tabs } -// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。 -// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count; -// 若该 key 在全库已无其他引用(引用计数归零),则一并清理七牛孤儿文件。 -func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { +// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。 +// 按 kind 委派到对应图片表的软删,并同步记录 image_count; +// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。 +func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error { if _, ok := s.module(kind); !ok { return repository.ErrNotFound } - key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID) + key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID) if err != nil { return err } switch kind { case dto.IngestKindRunway: - err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID) + err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID) case dto.IngestKindStreet: - err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID) + err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID) default: return repository.ErrNotFound } @@ -275,6 +280,42 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft return nil } +func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error { + if len(imgIDs) == 0 { + return ErrInvalidImageGroup + } + switch kind { + case dto.IngestKindStreet: + return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs)) + case dto.IngestKindRunway: + return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs)) + default: + return ErrInvalidImageGroup + } +} + +func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error { + switch kind { + case dto.IngestKindStreet: + return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID)) + case dto.IngestKindRunway: + return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID)) + default: + return ErrInvalidImageGroup + } +} + +// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 +func translateGroupErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, repository.ErrInvalidImageGroup) { + return ErrInvalidImageGroup + } + return err +} + // ── runway 模块 ──────────────────────────────────────────────────────────────── type runwayModule struct { @@ -285,24 +326,24 @@ type runwayModule struct { func (m *runwayModule) Kind() string { return dto.IngestKindRunway } func (m *runwayModule) Label() string { return "走秀" } -func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { - rows, total, err := m.repo.ListDrafts(ctx, status, page, size) +func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) { + rows, total, err := m.repo.ListRecords(ctx, status, page, size) if err != nil { return nil, 0, err } return m.toRunwayCards(rows), total, nil } -func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { - d, err := m.repo.DraftDetail(ctx, id) +func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) { + d, err := m.repo.RecordDetail(ctx, id) if err != nil { return nil, err } - imgs, err := m.repo.DraftImages(ctx, id) + imgs, err := m.repo.RecordImages(ctx, id) if err != nil { return nil, err } - v := &DraftDetailView{ + v := &RecordDetailView{ Kind: dto.IngestKindRunway, ID: d.ID, BrandID: d.BrandID, @@ -315,8 +356,7 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail Season: d.Season, CollectionType: d.CollectionType, SeasonCode: d.SeasonCode, - Cover: m.img.Compose(d.Cover, true), // 后台预览看高清 - SourceURL: d.SourceURL, + Cover: m.img.Compose(d.Cover), // 后台预览看展示图 ImageCount: d.ImageCount, Status: d.Status, Fields: []EditField{ @@ -330,33 +370,71 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail {Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"}, }, } + // 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0 + // 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。 + // 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。 + mains := make([]model.BrandRunwayImage, 0, len(imgs)) + mainSeen := make(map[uint32]bool, len(imgs)) for _, im := range imgs { - v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder}) + isMain := im.IsDetail == 0 || im.ParentImageID == 0 + if isMain && !mainSeen[im.ID] { + mainSeen[im.ID] = true + mains = append(mains, im) + } + } + detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs)) + for _, im := range imgs { + if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) + } + } + detailCount := make(map[uint32]int, len(detailsByParent)) + for p, ds := range detailsByParent { + detailCount[p] = len(ds) + } + for _, main := range mains { + ref := RecordImageRef{ + ID: main.ID, + Image: m.img.Compose(main.Image), + Name: main.Name, + SortOrder: main.SortOrder, + IsDetail: 0, + ParentImageID: 0, + DetailCount: detailCount[main.ID], + } + v.Images = append(v.Images, ref) + grp := ImageGroup{Image: ref} + for _, d := range detailsByParent[main.ID] { + dref := RecordImageRef{ + ID: d.ID, + Image: m.img.Compose(d.Image), + Name: d.Name, + SortOrder: d.SortOrder, + IsDetail: 1, + ParentImageID: d.ParentImageID, + } + grp.Details = append(grp.Details, dref) + v.Images = append(v.Images, dref) + } + v.Groups = append(v.Groups, grp) } return v, nil } -func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { - return m.repo.UpdateDraft(ctx, id, fields) + +func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { + return m.repo.UpdateRecord(ctx, id, fields) } func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { - officialID, err := m.repo.SaveRunwayFromDraft(ctx, id) - if err != nil { + if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil { return 0, err } - if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { - return 0, err - } - return officialID, nil -} - -func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { - return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason) + return id, nil } func (m *runwayModule) CountPending(ctx context.Context) (int64, error) { - return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{}) + return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{}) } // ── street 模块 ──────────────────────────────────────────────────────────────── @@ -369,31 +447,30 @@ type streetModule struct { func (m *streetModule) Kind() string { return dto.IngestKindStreet } func (m *streetModule) Label() string { return "街拍" } -func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { - rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size) +func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) { + rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size) if err != nil { return nil, 0, err } return m.toStreetCards(rows), total, nil } -func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { - d, err := m.repo.StreetDraftDetail(ctx, id) +func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) { + d, err := m.repo.StreetRecordDetail(ctx, id) if err != nil { return nil, err } - imgs, err := m.repo.StreetDraftImages(ctx, id) + imgs, err := m.repo.StreetRecordImages(ctx, id) if err != nil { return nil, err } - v := &DraftDetailView{ + v := &RecordDetailView{ Kind: dto.IngestKindStreet, ID: d.ID, Title: d.Title, Year: d.Year, City: d.City, - Cover: m.img.Compose(d.Cover, true), // 后台预览看高清 - SourceURL: d.SourceURL, + Cover: m.img.Compose(d.Cover), // 后台预览看展示图 ImageCount: d.ImageCount, Status: d.Status, Fields: []EditField{ @@ -402,39 +479,97 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail {Name: "city", Label: "城市", Value: d.City, Type: "text"}, }, } + + // 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。 + // 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。 + byID := make(map[uint32]model.StreetSnapImage, len(imgs)) for _, im := range imgs { - v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image, true), Name: im.Name, SortOrder: im.SortOrder}) + byID[im.ID] = im } + resolveRoot := func(start uint32) uint32 { + cur := start + seen := map[uint32]bool{} + for { + n, ok := byID[cur] + if !ok { + return start // 父行不存在:按主图渲染(返回自身) + } + if n.IsDetail == 0 { + return n.ID + } + if seen[cur] { + return start // 环:容错,自成主图 + } + seen[cur] = true + cur = n.ParentImageID + } + } + + detailCount := make(map[uint32]int, len(imgs)) + for _, im := range imgs { + if im.IsDetail == 1 { + if root := resolveRoot(im.ID); root != im.ID { + detailCount[root]++ + } + } + } + + groups := make([]ImageGroup, 0, len(imgs)) + groupIndex := make(map[uint32]int, len(imgs)) + for _, im := range imgs { + root := resolveRoot(im.ID) + isDetail := root != im.ID + isDetailVal := uint8(0) + parent := uint32(0) // 副图徽标显示「所属主图」:取沿链归并后的根主图;主图恒为 0 + if isDetail { + isDetailVal = 1 + parent = root + } + ref := RecordImageRef{ + ID: im.ID, + Image: m.img.Compose(im.Image), + Name: im.Name, + SortOrder: im.SortOrder, + IsDetail: isDetailVal, + ParentImageID: parent, + DetailCount: detailCount[im.ID], + } + v.Images = append(v.Images, ref) + if !isDetail { + groups = append(groups, ImageGroup{Image: ref}) + groupIndex[im.ID] = len(groups) - 1 + continue + } + if gi, ok := groupIndex[root]; ok { + groups[gi].Details = append(groups[gi].Details, ref) + } else { // 兜底(理论不发生):主图未先出现,自成一组 + groups = append(groups, ImageGroup{Image: ref}) + groupIndex[im.ID] = len(groups) - 1 + } + } + v.Groups = groups return v, nil } -func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { - return m.repo.UpdateStreetDraft(ctx, id, fields) +func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { + return m.repo.UpdateStreetRecord(ctx, id, fields) } func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { - officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id) - if err != nil { + if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil { return 0, err } - if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { - return 0, err - } - return officialID, nil -} - -func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { - return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason) + return id, nil } func (m *streetModule) CountPending(ctx context.Context) (int64, error) { - return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{}) + return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{}) } // ── 卡片转换(保留原逻辑)────────────────────────────────────────────────────── -func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard { - cards := make([]DraftCard, 0, len(rows)) +func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard { + cards := make([]RecordCard, 0, len(rows)) for _, d := range rows { sub := d.SeasonCode if d.CollectionType != "" { @@ -443,15 +578,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard if sub == "" { sub = "—" } - cards = append(cards, DraftCard{ + cards = append(cards, RecordCard{ Kind: dto.IngestKindRunway, ID: d.ID, Title: d.TitleEn, Subtitle: sub, - Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清 + Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, - SourceURL: d.SourceURL, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) @@ -459,22 +593,21 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard return cards } -func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard { - cards := make([]DraftCard, 0, len(rows)) +func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard { + cards := make([]RecordCard, 0, len(rows)) for _, d := range rows { sub := d.City if sub == "" { sub = "—" } - cards = append(cards, DraftCard{ + cards = append(cards, RecordCard{ Kind: dto.IngestKindStreet, ID: d.ID, Title: d.Title, Subtitle: sub, - Cover: m.img.Compose(d.Cover, true), // 后台列表封面看高清 + Cover: m.img.Compose(d.Cover), // 后台列表封面看展示图 ImageCount: d.ImageCount, Status: d.Status, - SourceURL: d.SourceURL, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) diff --git a/internal/service/review_street_group_test.go b/internal/service/review_street_group_test.go new file mode 100644 index 0000000..dafb306 --- /dev/null +++ b/internal/service/review_street_group_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "errors" + "testing" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/imgurl" + "fashionapi/internal/repository" +) + +// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 +type fakeGroupRepo struct { + repository.ReviewRepository + images []model.StreetSnapImage + detail *model.StreetSnap + attachFn func(snapID, mainID uint32, imgIDs []uint32) error + attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error +} + +func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) { + return f.detail, nil +} + +func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) { + return f.images, nil +} + +func (f *fakeGroupRepo) AttachStreetSnapImages(_ context.Context, snapID, mainID uint32, imgIDs []uint32) error { + if f.attachFn != nil { + return f.attachFn(snapID, mainID, imgIDs) + } + return nil +} + +func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error { + if f.attachFn != nil { + return f.attachFn(0, 0, nil) + } + return nil +} + +func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error { + if f.attachRunwayFn != nil { + return f.attachRunwayFn(runwayID, mainID, imgIDs) + } + return nil +} + +func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error { + return nil +} + +var _ repository.ReviewRepository = (*fakeGroupRepo)(nil) + +func newGroupService(f *fakeGroupRepo) ReviewService { + return NewReviewService(f, nil, imgurl.New("", "", ""), nil) +} + +// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。 +func TestStreetGroupDetailExposesGrouping(t *testing.T) { + repo := &fakeGroupRepo{ + detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending}, + images: []model.StreetSnapImage{ + {ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0}, + {ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1}, + {ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0}, + }, + } + svc := newGroupService(repo) + v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7) + if err != nil { + t.Fatalf("RecordDetail 失败: %v", err) + } + if len(v.Images) != 3 { + t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images)) + } + if len(v.Groups) != 2 { + t.Fatalf("Groups 应为 2 组,实际 %d", len(v.Groups)) + } + if v.Groups[0].Image.ID != 1 || len(v.Groups[0].Details) != 1 { + t.Fatalf("第 0 组应为 1 张主图 + 1 副图,实际 main=%d details=%d", v.Groups[0].Image.ID, len(v.Groups[0].Details)) + } + if v.Groups[0].Image.DetailCount != 1 { + t.Fatalf("主图 DetailCount 应为 1,实际 %d", v.Groups[0].Image.DetailCount) + } + if v.Groups[0].Details[0].ID != 2 { + t.Fatalf("副图应为 id=2,实际 %d", v.Groups[0].Details[0].ID) + } + if v.Groups[1].Image.ID != 3 { + t.Fatalf("第 1 组主图应为 id=3,实际 %d", v.Groups[1].Image.ID) + } +} + +// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。 +func TestStreetGroupAttachDelegates(t *testing.T) { + var got struct { + snapID uint32 + mainID uint32 + imgIDs []uint32 + } + repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error { + got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs + return nil + }} + svc := newGroupService(repo) + if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil { + t.Fatalf("AttachImages 失败: %v", err) + } + if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 { + t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs) + } +} + +// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。 +func TestStreetGroupNonStreetRejected(t *testing.T) { + repo := &fakeGroupRepo{} + svc := newGroupService(repo) + if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) { + t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err) + } +} + +// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。 +func TestRunwayGroupAttachDelegates(t *testing.T) { + var got struct { + runwayID uint32 + mainID uint32 + imgIDs []uint32 + } + repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error { + got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs + return nil + }} + svc := newGroupService(repo) + if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil { + t.Fatalf("Runway AttachImages 失败: %v", err) + } + if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 { + t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs) + } +} + +// TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。 +func TestStreetGroupPreventCrossRecord(t *testing.T) { + repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error { + return repository.ErrInvalidImageGroup + }} + svc := newGroupService(repo) + err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}) + if err == nil { + t.Fatal("跨记录应报错") + } + if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" { + t.Fatalf("错误文案不符: %q", err.Error()) + } +} diff --git a/internal/service/similar_service.go b/internal/service/similar_service.go new file mode 100644 index 0000000..772a664 --- /dev/null +++ b/internal/service/similar_service.go @@ -0,0 +1,189 @@ +package service + +import ( + "context" + "fmt" + "log" + "sort" + + "fashionapi/internal/repository" +) + +// SimilarHint 是一条「疑似同款」提示:某张图片的一个近邻。 +// +// 带上近邻的图片 key / 名称 / 所属图集标题,后台才能直接显示「和哪张图像」。 +type SimilarHint struct { + ImageID uint32 // 近邻图片 id + OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转) + Similar float64 // 余弦相似度 0~1 + Image string // 近邻图片存储 key(前端显示缩略图) + Name string // 近邻图片名(如 "Look 12") + OwnerTitle string // 近邻所属图集标题 +} + +// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。 +type DupStat struct { + Total int // 该图集存活图片总数(实时统计,不用过期的 image_count 缓存列) + Images int // 其中有跨图集重复的图片数 + FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位 +} + +// SimilarService 组装后台「疑似同款」提示。 +// +// 定位(对应规格 §8/§9):**只提示、不隐藏、不计入重复率、不参与任何去重**。 +// +// 分工: +// - PrecomputeForGallery 在**入库时**(或 dbtool similarbackfill 回填时)算好近邻并落库; +// - HintsForImages 只读结果表,供页面渲染。 +// +// 为什么必须预计算:原实现在**打开图集页时**对每张图实时检索,一个 116 张图的图集要发 +// 116 次查询、每次约 20KB SQL(768 维向量文本)。本机 PostgreSQL 跑在 WSL2 里、Windows 经 +// wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s —— 服务端执行单条只要 0.45ms, +// 客户端往返却要 45ms,合计 116×45ms ≈ 5 秒。挪到入库时后页面只读一次小查询。 +type SimilarService struct { + embedRep repository.EmbeddingRepository + threshold float64 +} + +// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。 +// embedRep 可为 nil(视觉服务未启用),此时所有查询返回空结果。 +func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService { + return &SimilarService{embedRep: embedRep, threshold: threshold} +} + +// Enabled 报告是否可用(仓储已注入)。 +func (s *SimilarService) Enabled() bool { + return s != nil && s.embedRep != nil +} + +// Threshold 返回当前的「疑似同款」相似度阈值,供页面展示。 +func (s *SimilarService) Threshold() float64 { + if s == nil { + return 0 + } + return s.threshold +} + +// HintsForImages 读取一个图集全部图片的「疑似同款」提示(只读结果表,不计算)。 +// +// 返回 map[图片 id] -> 近邻提示列表(按相似度降序、每张最多 similarNeighborLimit 条)。 +// **不返回 error**:提示是增值信息,读取失败只记日志并返回空, +// 绝不能让「查不到同款」影响后台页面渲染。 +func (s *SimilarService) HintsForImages(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]SimilarHint { + if !s.Enabled() || len(imageIDs) == 0 { + return nil + } + pairs, err := s.embedRep.ListSimilarPairs(ctx, kind, imageIDs) + if err != nil { + log.Printf("[similar] 读近邻失败 kind=%s 数量=%d err=%v(页面不显示同款提示)", kind, len(imageIDs), err) + return nil + } + if len(pairs) == 0 { + return nil + } + out := make(map[uint32][]SimilarHint, len(pairs)) + for selfID, ns := range pairs { + if len(ns) == 0 { + continue + } + sort.Slice(ns, func(i, j int) bool { return ns[i].Similar > ns[j].Similar }) + if len(ns) > similarNeighborLimit { + ns = ns[:similarNeighborLimit] + } + hints := make([]SimilarHint, 0, len(ns)) + for _, n := range ns { + hints = append(hints, SimilarHint{ + ImageID: n.OtherID, + OwnerID: n.OwnerID, + Similar: n.Similar, + Image: n.Image, + Name: n.Name, + OwnerTitle: n.OwnerTitle, + }) + } + out[selfID] = hints + } + return out +} + +// DupStats 汇总 runway / street 两类图集的跨图集重复情况(kind -> 图集 id -> 概况)。 +// +// 供文章列表直接标出「哪些图集有重复」——否则只能一个个点进图集才能发现。 +// +// **不返回 error**:与提示同理,汇总失败只记日志并跳过该类, +// 绝不能让列表页因为查不到重复而渲染失败。 +func (s *SimilarService) DupStats(ctx context.Context) map[string]map[uint32]DupStat { + if !s.Enabled() { + return nil + } + out := make(map[string]map[uint32]DupStat, 2) + for _, kind := range []string{"runway", "street"} { + raw, err := s.embedRep.DupStatsByOwner(ctx, kind) + if err != nil { + log.Printf("[similar] 汇总跨图集重复失败 kind=%s err=%v(列表不显示重复列)", kind, err) + continue + } + if len(raw) == 0 { + continue + } + m := make(map[uint32]DupStat, len(raw)) + for ownerID, st := range raw { + m[ownerID] = DupStat{Total: st.Total, Images: st.Images, FirstImageID: st.FirstImageID} + } + out[kind] = m + } + return out +} + +// PrecomputeForGallery 计算并落库一个图集全部图片的跨图集近邻。 +// +// imageIDs 须为该图集的全部图片 id:它同时充当「同图集排除集」——需求是 +// 「本图集的图与其他图集有没有重复」,同图集内所有照片共享背景/灯光/模特/造型, +// 相似度天然偏高(实测同场中位 0.69、最高 0.91),参与比对只会淹没真正的跨图集重复。 +// +// 结果**双向**写入(A→B 与 B→A):这样打开任一侧所在的图集都能看到提示。 +// +// 说明:单张查询失败只记日志并跳过,整体不中断——预计算是增值数据, +// 绝不能让「算不出同款」影响入库或回填。 +func (s *SimilarService) PrecomputeForGallery(ctx context.Context, kind string, imageIDs []uint32) error { + if !s.Enabled() || len(imageIDs) == 0 { + return nil + } + vecByImage, err := s.embedRep.ListEmbeddings(ctx, kind, imageIDs) + if err != nil { + return fmt.Errorf("取向量: %w", err) + } + if len(vecByImage) == 0 { + return nil // 这批图还没有向量(视觉服务未启用),无需预计算 + } + + rows := make([]repository.SimilarPair, 0, len(vecByImage)*similarNeighborLimit*2) + done := 0 + for imageID, vec := range vecByImage { + if len(vec) == 0 { + continue + } + neighbors, ferr := s.embedRep.FindSimilarImages( + ctx, kind, imageID, imageIDs, vec, s.threshold, similarNeighborLimit) + if ferr != nil { + log.Printf("[similar] 预计算近邻失败 kind=%s 图片=%d err=%v", kind, imageID, ferr) + continue + } + done++ + for _, n := range neighbors { + rows = append(rows, + repository.SimilarPair{SelfID: imageID, OtherID: n.ImageID, Similar: n.Similar}, + repository.SimilarPair{SelfID: n.ImageID, OtherID: imageID, Similar: n.Similar}, + ) + } + } + if err := s.embedRep.ReplaceSimilarPairs(ctx, kind, imageIDs, rows); err != nil { + return fmt.Errorf("写入近邻: %w", err) + } + log.Printf("[similar] 预计算完成 kind=%s 图=%d 已算=%d 对=%d 阈值=%.2f", + kind, len(imageIDs), done, len(rows), s.threshold) + return nil +} + +// similarNeighborLimit 每张图最多展示的「疑似同款」条数:只作人工参考,多了是噪音。 +const similarNeighborLimit = 5 diff --git a/internal/service/similar_service_test.go b/internal/service/similar_service_test.go new file mode 100644 index 0000000..e8a8a08 --- /dev/null +++ b/internal/service/similar_service_test.go @@ -0,0 +1,243 @@ +package service + +import ( + "context" + "errors" + "testing" + + "fashionapi/internal/repository" +) + +// fakeEmbeddingRepo 是 EmbeddingRepository 的可控替身,记录被调用的参数。 +type fakeEmbeddingRepo struct { + vecs map[uint32][]float32 + neighbors map[uint32][]repository.SimilarImage + pairs map[uint32][]repository.SimilarNeighbor + dupStats map[string]map[uint32]repository.DupStat + listErr error + + gotKind string + gotQueryID uint32 + gotExclude []uint32 + gotMinSim float64 + gotLimit int + + replacedSelfIDs []uint32 + replacedRows []repository.SimilarPair + replaceErr error +} + +func (f *fakeEmbeddingRepo) UpsertEmbedding(context.Context, string, uint32, []float32) error { + return nil +} + +func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(context.Context, string, []uint32, [][]float32) error { + return nil +} + +func (f *fakeEmbeddingRepo) ListEmbeddings(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]float32, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := map[uint32][]float32{} + for _, id := range imageIDs { + if v, ok := f.vecs[id]; ok { + out[id] = v + } + } + return out, nil +} + +func (f *fakeEmbeddingRepo) FindSimilarImages(_ context.Context, kind string, imageID uint32, excludeIDs []uint32, _ []float32, minSim float64, limit int) ([]repository.SimilarImage, error) { + f.gotKind = kind + f.gotQueryID = imageID + f.gotExclude = excludeIDs + f.gotMinSim = minSim + f.gotLimit = limit + return f.neighbors[imageID], nil +} + +func (f *fakeEmbeddingRepo) ReplaceSimilarPairs(_ context.Context, _ string, selfIDs []uint32, rows []repository.SimilarPair) error { + f.replacedSelfIDs = selfIDs + f.replacedRows = rows + return f.replaceErr +} + +func (f *fakeEmbeddingRepo) ListSimilarPairs(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]repository.SimilarNeighbor, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := map[uint32][]repository.SimilarNeighbor{} + for _, id := range imageIDs { + if ns, ok := f.pairs[id]; ok { + out[id] = ns + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func (f *fakeEmbeddingRepo) DupStatsByOwner(_ context.Context, kind string) (map[uint32]repository.DupStat, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.dupStats[kind], nil +} + +// TestSimilarDisabledReturnsNothing 未启用(仓储为 nil)时必须安全返回空结果,不 panic。 +func TestSimilarDisabledReturnsNothing(t *testing.T) { + svc := NewSimilarService(nil, 0.93) + if svc.Enabled() { + t.Fatal("未注入仓储时应为未启用") + } + if got := svc.HintsForImages(context.Background(), "runway", []uint32{1, 2}); got != nil { + t.Fatalf("未启用应返回 nil,得到 %v", got) + } + if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil { + t.Fatalf("未启用时预计算应直接返回 nil,得到 %v", err) + } +} + +// TestHintsReadsStoredPairs 页面渲染只读结果表:验证分组、降序、每张上限,以及字段透传。 +func TestHintsReadsStoredPairs(t *testing.T) { + nb := func(id uint32, sim float64) repository.SimilarNeighbor { + return repository.SimilarNeighbor{ + OtherID: id, OwnerID: id * 10, Similar: sim, + Image: "runway/x.jpg", Name: "Look 1", OwnerTitle: "Fall 2019 Menswear", + } + } + repo := &fakeEmbeddingRepo{pairs: map[uint32][]repository.SimilarNeighbor{ + // 故意乱序且超过上限(6 条),应被降序 + 截到 5 条。 + 7: {nb(71, 0.95), nb(72, 0.99), nb(73, 0.91), nb(74, 0.98), nb(75, 0.92), nb(76, 0.90)}, + }} + svc := NewSimilarService(repo, 0.93) + + got := svc.HintsForImages(context.Background(), "runway", []uint32{7, 8}) + if len(got) != 1 { + t.Fatalf("应只有图片 7 有提示,得到 %d 项", len(got)) + } + hints := got[7] + if len(hints) != 5 { + t.Fatalf("每张最多 5 条提示,得到 %d", len(hints)) + } + for i := 1; i < len(hints); i++ { + if hints[i].Similar > hints[i-1].Similar { + t.Fatalf("提示应按相似度降序,实际 %+v", hints) + } + } + if hints[0].ImageID != 72 || hints[0].Similar != 0.99 { + t.Fatalf("第一条应为相似度最高的 72,实际 %+v", hints[0]) + } + if hints[0].Image != "runway/x.jpg" || hints[0].Name != "Look 1" || hints[0].OwnerTitle != "Fall 2019 Menswear" { + t.Fatalf("近邻描述字段未透传: %+v", hints[0]) + } + if hints[0].OwnerID != 720 { + t.Fatalf("近邻所属图集 id 未透传: %+v", hints[0]) + } +} + +// TestHintsReadErrorDegradesQuietly 读结果表失败绝不能让页面渲染失败:返回空、不 panic。 +func TestHintsReadErrorDegradesQuietly(t *testing.T) { + repo := &fakeEmbeddingRepo{listErr: errors.New("db down")} + svc := NewSimilarService(repo, 0.93) + if got := svc.HintsForImages(context.Background(), "street", []uint32{1}); got != nil { + t.Fatalf("读取失败应返回 nil,得到 %v", got) + } +} + +// TestPrecomputeWritesBidirectionalPairs 预计算:逐张查近邻,并把结果**双向**落库; +// 同时验证阈值、排除集(整个图集)与上限都透传给仓储。 +func TestPrecomputeWritesBidirectionalPairs(t *testing.T) { + repo := &fakeEmbeddingRepo{ + vecs: map[uint32][]float32{1: {0.1, 0.2}, 2: {0.3, 0.4}}, + neighbors: map[uint32][]repository.SimilarImage{ + 1: {{ImageID: 9, OwnerID: 100, Similar: 0.97}}, + }, + } + svc := NewSimilarService(repo, 0.93) + + // 图集含图片 1、2;其中只有 1 有近邻 9(属于别的图集)。 + if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil { + t.Fatalf("预计算失败: %v", err) + } + + if repo.gotMinSim != 0.93 { + t.Fatalf("阈值应透传给仓储,实际 %v", repo.gotMinSim) + } + if repo.gotLimit != 5 { + t.Fatalf("每张最多取 5 条近邻,实际 %d", repo.gotLimit) + } + // 排除集必须是整个图集:同图集内不比(同场共享背景/灯光/造型,相似度天然偏高)。 + if len(repo.gotExclude) != 2 || repo.gotExclude[0] != 1 || repo.gotExclude[1] != 2 { + t.Fatalf("同图集排除集应为整个图集的图片 id,实际 %v", repo.gotExclude) + } + + // 双向:1→9 与 9→1 各一行。 + if len(repo.replacedRows) != 2 { + t.Fatalf("应写入双向共 2 行,实际 %+v", repo.replacedRows) + } + forward, backward := false, false + for _, r := range repo.replacedRows { + switch { + case r.SelfID == 1 && r.OtherID == 9: + forward = true + case r.SelfID == 9 && r.OtherID == 1: + backward = true + } + } + if !forward || !backward { + t.Fatalf("必须双向写入(A→B 与 B→A),实际 %+v", repo.replacedRows) + } + // 替换范围是本图集的图片 id(先删后插用)。 + if len(repo.replacedSelfIDs) != 2 { + t.Fatalf("替换范围应为整个图集的图片 id,实际 %v", repo.replacedSelfIDs) + } +} + +// TestPrecomputeNoVectorsSkips 图集还没有向量(视觉服务未启用)时不做任何计算与写入。 +func TestPrecomputeNoVectorsSkips(t *testing.T) { + repo := &fakeEmbeddingRepo{vecs: map[uint32][]float32{}} + svc := NewSimilarService(repo, 0.93) + if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil { + t.Fatalf("无向量时应静默返回,实际 %v", err) + } + if repo.replacedRows != nil || repo.replacedSelfIDs != nil { + t.Fatalf("无向量时不应写库,实际 rows=%v selfIDs=%v", repo.replacedRows, repo.replacedSelfIDs) + } +} + +// TestDupStatsByKind 列表页要用的汇总:按 kind 分组返回图集的重复概况与总图数。 +func TestDupStatsByKind(t *testing.T) { + repo := &fakeEmbeddingRepo{dupStats: map[string]map[uint32]repository.DupStat{ + "runway": {7: {Total: 120, Images: 3, FirstImageID: 55}}, + }} + svc := NewSimilarService(repo, 0.93) + + got := svc.DupStats(context.Background()) + st, ok := got["runway"][7] + if !ok { + t.Fatalf("应返回 runway 图集 7 的概况,实际 %+v", got) + } + if st.Total != 120 || st.Images != 3 || st.FirstImageID != 55 { + t.Fatalf("概况字段不正确: %+v", st) + } +} + +// TestDupStatsErrorDegradesQuietly 汇总失败只能记日志:列表页必须照常渲染。 +func TestDupStatsErrorDegradesQuietly(t *testing.T) { + repo := &fakeEmbeddingRepo{listErr: errors.New("db down")} + svc := NewSimilarService(repo, 0.93) + if got := svc.DupStats(context.Background()); len(got) != 0 { + t.Fatalf("汇总失败应返回空,得到 %+v", got) + } +} + +// TestDupStatsDisabled 未启用时直接返回 nil,不触发任何查询。 +func TestDupStatsDisabled(t *testing.T) { + svc := NewSimilarService(nil, 0.93) + if got := svc.DupStats(context.Background()); got != nil { + t.Fatalf("未启用应返回 nil,得到 %+v", got) + } +} diff --git a/internal/service/street_snap_group_test.go b/internal/service/street_snap_group_test.go new file mode 100644 index 0000000..82a5643 --- /dev/null +++ b/internal/service/street_snap_group_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "testing" + + "fashionapi/internal/model" + "fashionapi/internal/pkg/imgurl" +) + +// TestBuildSnapImages 验证公开详情的 image 只含主图、副图嵌套在其主图的 Detail 下, +// 且「副图的副图」上浮归并到该副图所在组的主图。 +// +// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A) +// +// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。 +func TestBuildSnapImages(t *testing.T) { + s := &streetSnapService{img: imgurl.New("", "", "")} + imgs := []model.StreetSnapImage{ + {ID: 1, Image: "A", Name: "A", IsDetail: 0, ParentImageID: 0}, + {ID: 2, Image: "B", Name: "B", IsDetail: 1, ParentImageID: 1}, + {ID: 3, Image: "C", Name: "C", IsDetail: 0, ParentImageID: 0}, + {ID: 4, Image: "D", Name: "D", IsDetail: 1, ParentImageID: 3}, + {ID: 5, Image: "E", Name: "E", IsDetail: 1, ParentImageID: 2}, // 2 是副图 → 上浮到 A + } + + out := s.buildSnapImages(imgs) + if len(out) != 2 { + t.Fatalf("images 应只含 2 张主图,实际 %d", len(out)) + } + + a := out[0] + if a.Name != "A" || a.IsDetail != 0 { + t.Fatalf("第 1 张应为 A 主图,实际 name=%s is_detail=%d", a.Name, a.IsDetail) + } + if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" { + t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail)) + } + for _, d := range a.Detail { + if d.IsDetail != 1 { + t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail) + } + } + + c := out[1] + if c.Name != "C" { + t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name) + } + if len(c.Detail) != 1 || c.Detail[0].Name != "D" { + t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail)) + } +} + +// TestBuildSnapImagesOrphan 父引用指向失效行(不存在)时,该图按主图渲染,不丢图。 +func TestBuildSnapImagesOrphan(t *testing.T) { + s := &streetSnapService{img: imgurl.New("", "", "")} + imgs := []model.StreetSnapImage{ + {ID: 1, Image: "A", Name: "A", IsDetail: 1, ParentImageID: 99}, // 父 99 不存在 + {ID: 2, Image: "B", Name: "B", IsDetail: 0, ParentImageID: 0}, + } + out := s.buildSnapImages(imgs) + if len(out) != 2 { + t.Fatalf("孤儿副图应自成主图:images=%d", len(out)) + } + if out[0].Name != "A" || out[0].IsDetail != 0 || len(out[0].Detail) != 0 { + t.Fatalf("A 应作为无副图的主图,实际 name=%s is_detail=%d details=%d", out[0].Name, out[0].IsDetail, len(out[0].Detail)) + } +} diff --git a/internal/service/street_snap_i18n_test.go b/internal/service/street_snap_i18n_test.go new file mode 100644 index 0000000..31c4b86 --- /dev/null +++ b/internal/service/street_snap_i18n_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "testing" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/imgurl" + "fashionapi/internal/repository" +) + +// fakeSnapRepo 只实现测试路径用到的仓储方法;其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 +type fakeSnapRepo struct { + repository.StreetSnapRepository + findByID *model.StreetSnap + listImages []model.StreetSnapImage + rows []model.StreetSnap +} + +func (f *fakeSnapRepo) FindByID(_ context.Context, _ string) (*model.StreetSnap, error) { + return f.findByID, nil +} + +func (f *fakeSnapRepo) ListImages(_ context.Context, _ string) ([]model.StreetSnapImage, error) { + return f.listImages, nil +} + +func (f *fakeSnapRepo) List(_ context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) { + return f.rows, int64(len(f.rows)), nil +} + +var _ repository.StreetSnapRepository = (*fakeSnapRepo)(nil) + +func newSnapService(f *fakeSnapRepo) StreetSnapService { + return NewStreetSnapService(f, nil, imgurl.New("", "", ""), nil, nil) +} + +// TestStreetSnapDetailLocalizesTitle 详情按 locale 返回中/英标题。 +func TestStreetSnapDetailLocalizesTitle(t *testing.T) { + repo := &fakeSnapRepo{ + findByID: &model.StreetSnap{ID: 1, Title: "London Street Style", TitleCn: "伦敦街拍", Cover: "c.jpg"}, + listImages: []model.StreetSnapImage{{ID: 1, Image: "a.jpg"}}, + } + svc := newSnapService(repo) + + cn, err := svc.Detail(context.Background(), "1", "cn") + if err != nil { + t.Fatalf("Detail cn err: %v", err) + } + if cn.Title != "伦敦街拍" { + t.Errorf("cn 标题应为中文,实际 %q", cn.Title) + } + + en, err := svc.Detail(context.Background(), "1", "en") + if err != nil { + t.Fatalf("Detail en err: %v", err) + } + if en.Title != "London Street Style" { + t.Errorf("en 标题应为英文,实际 %q", en.Title) + } +} + +// TestStreetSnapDetailFallsBackToEn 中文为空时回落英文。 +func TestStreetSnapDetailFallsBackToEn(t *testing.T) { + repo := &fakeSnapRepo{findByID: &model.StreetSnap{ID: 1, Title: "Only English", TitleCn: ""}} + svc := newSnapService(repo) + + cn, err := svc.Detail(context.Background(), "1", "cn") + if err != nil { + t.Fatalf("Detail cn err: %v", err) + } + if cn.Title != "Only English" { + t.Errorf("中文为空应回落英文,实际 %q", cn.Title) + } +} + +// TestStreetSnapListLocalizesTitle 列表按 locale 取标题,中文为空回落英文。 +func TestStreetSnapListLocalizesTitle(t *testing.T) { + repo := &fakeSnapRepo{rows: []model.StreetSnap{ + {ID: 1, Title: "EN A", TitleCn: "中 A"}, + {ID: 2, Title: "EN B", TitleCn: ""}, + }} + svc := newSnapService(repo) + + got, _, err := svc.List(context.Background(), dto.StreetSnapQuery{Locale: "cn", Images: 0}) + if err != nil { + t.Fatalf("List err: %v", err) + } + if len(got) != 2 { + t.Fatalf("期望 2 条,实际 %d", len(got)) + } + if got[0].Title != "中 A" || got[1].Title != "EN B" { + t.Errorf("列表本地化错误: %#v", got) + } +} diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index a5b4015..aac897b 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -5,6 +5,7 @@ import ( "errors" "log" "net/http" + "sort" "fashionapi/internal/dto" "fashionapi/internal/model" @@ -17,9 +18,13 @@ import ( // StreetSnapService 街拍业务接口。 type StreetSnapService interface { List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) - Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) + // AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。 + AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) + Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) + // DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。 + DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 - Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) + Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) // UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。 @@ -30,7 +35,9 @@ type StreetSnapService interface { SetCover(ctx context.Context, snapID uint32, image string) error // SetDeleted 上下架:deleted=1 下架,0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error - // PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理七牛孤儿图。 + // SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, snapID uint32) (int64, error) + // PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } @@ -39,13 +46,13 @@ type streetSnapService struct { media repository.MediaRepository img *imgurl.Composer deleter storage.Deleter - cleaner MediaCleanupEnqueuer // 删除图集时异步清理七牛孤儿图的入队入口 + cleaner MediaCleanupEnqueuer // 删除图集时异步清理S4孤儿图的入队入口 } // NewStreetSnapService 创建街拍服务。 -// img 负责把库里存储的七牛 key 拼装为可访问 URL(按 tier 决定普通/高清); -// media + deleter 用于删除图集时清理七牛孤儿文件(引用计数归零才真删); -// cleaner 把「删七牛」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 +// img 负责把库里存储的 S4 key 拼装为可访问 URL(统一走展示样式,VIP 与免费同质量); +// media + deleter 用于删除图集时清理孤儿文件(引用计数归零才真删); +// cleaner 把「删存储」动作异步入队(由入库 worker 处理),避免阻塞删除请求。 func NewStreetSnapService(repo repository.StreetSnapRepository, media repository.MediaRepository, img *imgurl.Composer, deleter storage.Deleter, cleaner MediaCleanupEnqueuer) StreetSnapService { return &streetSnapService{repo: repo, media: media, img: img, deleter: deleter, cleaner: cleaner} } @@ -62,17 +69,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] return nil, 0, internalErr(err.Error()) } - list := make([]dto.PublicStreetSnap, 0, len(rows)) ids := make([]uint32, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) - list = append(list, dto.PublicStreetSnap{ - UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), - Title: r.Title, - Cover: s.img.Compose(r.Cover, false), // 列表封面统一普通图 - ImageCount: r.ImageCount, - }) } + list := s.toPublicSnaps(rows, q.Locale) if q.Images > 0 && len(list) > 0 { if err := s.attachImages(ctx, list, ids, q.Images); err != nil { @@ -82,6 +83,36 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] return list, total, nil } +// AdminList 后台街拍列表:读基表,包含 pending / rejected。 +// +// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段, +// 与走秀后台 AdminList 的口径一致。 +func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + q.Normalize() + + rows, total, err := s.repo.ListAdmin(ctx, q) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + return s.toPublicSnaps(rows, q.Locale), total, nil +} + +// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。 +// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。 +func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap { + list := make([]dto.PublicStreetSnap, 0, len(rows)) + for _, r := range rows { + list = append(list, dto.PublicStreetSnap{ + UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), + Title: pickLocale(r.Title, r.TitleCn, locale), + Cover: s.img.ComposeThumb(r.Cover), + ImageCount: r.ImageCount, + Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应) + }) + } + return list +} + // attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。 func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error { grouped, err := s.repo.ImagesBySnapIDs(ctx, ids) @@ -93,8 +124,9 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS if !ok { continue } - // 列表缩略图统一普通图;再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 - imgs := s.toPublicSnapImages(raw, false) + // 列表缩略图走缩略图样式(与卡片封面同样式,故去重比较仍成立); + // 再剔除与卡片主图(cover)同 URL 的首图,避免重复展示。 + imgs := s.toThumbSnapImages(raw) imgs = excludeCoverSnap(imgs, list[i].Cover) if len(imgs) > limit { imgs = imgs[:limit] @@ -118,33 +150,121 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA return kept } -// Detail 查询街拍详情,含完整图片集。 -// hd=true 时返回高清原图(VIP),false 时所有图拼普通样式(免费/未登录)。 -func (s *streetSnapService) Detail(ctx context.Context, id string, hd bool) (*dto.PublicStreetSnapDetail, error) { +// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。 +// 图片统一走展示样式(VIP 与免费同质量)。 +func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) { snap, err := s.repo.FindByID(ctx, id) if err != nil { - if errors.Is(err, repository.ErrNotFound) { - return nil, ErrStreetSnapNotFound - } - return nil, internalErr(err.Error()) + return nil, s.detailErr(err) } - imgs, err := s.repo.ListImages(ctx, id) if err != nil { return nil, internalErr(err.Error()) } - - return &dto.PublicStreetSnapDetail{ - UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID), - Title: snap.Title, - Cover: s.img.Compose(snap.Cover, hd), - Images: s.toPublicSnapImages(imgs, hd), - }, nil + return s.buildSnapDetail(snap, imgs, locale), nil +} + +// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。 +// +// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口; +// 若后台详情也走公开视图,待审项会 404、审核无从下手。 +func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) { + snap, err := s.repo.FindByIDBase(ctx, id) + if err != nil { + return nil, s.detailErr(err) + } + imgs, err := s.repo.ListImagesBase(ctx, id) + if err != nil { + return nil, internalErr(err.Error()) + } + return s.buildSnapDetail(snap, imgs, locale), nil +} + +// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。 +func (s *streetSnapService) detailErr(err error) error { + if errors.Is(err, repository.ErrNotFound) { + return ErrStreetSnapNotFound + } + return internalErr(err.Error()) +} + +// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 +func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail { + return &dto.PublicStreetSnapDetail{ + UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), + Title: pickLocale(snap.Title, snap.TitleCn, locale), + Cover: s.img.Compose(snap.Cover), + Images: s.buildSnapImages(imgs), + } +} + +// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): +// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。 +// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。 +// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。 +func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { + if len(imgs) == 0 { + return nil + } + + byID := make(map[uint32]*model.StreetSnapImage, len(imgs)) + for i := range imgs { + byID[imgs[i].ID] = &imgs[i] + } + + // resolveRoot 返回 img 所属主图的行 id;自身是主图则返回自身;父行缺失 / 成环则按主图渲染(返回自身)。 + resolveRoot := func(start uint32) uint32 { + cur := start + seen := map[uint32]bool{} + for { + n, ok := byID[cur] + if !ok { + return start // 父行不存在 → 按主图渲染(返回自身) + } + if n.IsDetail == 0 { + return n.ID + } + if seen[cur] { + return start // 环 → 容错,自成主图 + } + seen[cur] = true + cur = n.ParentImageID + } + } + + // 第一遍:按 sort_order 建立主图(根),并记录「主图行 id → 输出下标」,副图排在主图之前也能正确归位。 + out := make([]dto.PublicArticleImage, 0, len(imgs)) + index := make(map[uint32]int, len(imgs)) + for _, im := range imgs { + if resolveRoot(im.ID) != im.ID { + continue + } + pub := s.toPublicSnapImage(im) + + index[im.ID] = len(out) + out = append(out, pub) + } + + // 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。 + for _, im := range imgs { + root := resolveRoot(im.ID) + if root == im.ID { + continue + } + gi, ok := index[root] + if !ok { + continue // 理论不发生(孤儿已按主图渲染) + } + pub := s.toPublicSnapImage(im) + pub.IsDetail = 1 + + out[gi].Detail = append(out[gi].Detail, pub) + } + return out } -// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 -func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { +func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) { rows, err := s.repo.Popular(ctx, limit) if err != nil { return nil, internalErr(err.Error()) @@ -152,9 +272,9 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi list := make([]dto.PublicStreetSnap, 0, len(rows)) for _, r := range rows { list = append(list, dto.PublicStreetSnap{ - UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), - Title: r.Title, - Cover: s.img.Compose(r.Cover, false), // 热门街拍封面统一普通图 + UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), + Title: pickLocale(r.Title, r.TitleCn, locale), + Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) ImageCount: r.ImageCount, }) } @@ -162,17 +282,41 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi } // toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 -// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。 -// hd=true 时返回高清原图(VIP),false 时拼普通样式(免费/未登录)。 -func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd bool) []dto.PublicArticleImage { +// ID 用 EncodeWithType(TypeSnapImage) 编码(类型进密码、无前缀),与街拍图集 uid 区分开,供单图收藏定位。 +// Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等 +// 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。 +func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { + if len(imgs) == 0 { + return nil + } + out := make([]dto.PublicArticleImage, 0, len(imgs)) + for _, im := range imgs { + out = append(out, s.toPublicSnapImage(im)) + } + return out +} + +// toPublicSnapImage 把单张图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 +func (s *streetSnapService) toPublicSnapImage(im model.StreetSnapImage) dto.PublicArticleImage { + return dto.PublicArticleImage{ + ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), + Image: s.img.Compose(im.Image), + Thumb: s.img.ComposeThumb(im.Image), + Name: im.Name, + } +} + +// toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb), +// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。 +func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil } out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID), - Image: s.img.Compose(im.Image, hd), + ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), + Image: s.img.ComposeThumb(im.Image), Name: im.Name, }) } @@ -182,7 +326,50 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage, hd // ── 后台编辑支持 ────────────────────────────────────────────────────────────── func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { - return s.repo.GetForEdit(ctx, id) + snap, imgs, err := s.repo.GetForEdit(ctx, id) + if err != nil { + return nil, nil, err + } + // 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后, + // 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。 + // 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。 + return snap, orderStreetImagesForEdit(imgs), nil +} + +// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。 +// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。 +func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage { + byID := make(map[uint32]*model.StreetSnapImage, len(imgs)) + detailsByParent := make(map[uint32][]model.StreetSnapImage) + var mains []model.StreetSnapImage + for i := range imgs { + im := &imgs[i] + byID[im.ID] = im + if im.IsDetail == 0 { + mains = append(mains, *im) + } else { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im) + } + } + out := make([]model.StreetSnapImage, 0, len(imgs)) + emitted := make(map[uint32]bool, len(imgs)) + for _, m := range mains { + out = append(out, m) + emitted[m.ID] = true + ds := detailsByParent[m.ID] + sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder }) + for _, d := range ds { + out = append(out, d) + emitted[d.ID] = true + } + } + // 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。 + for _, im := range imgs { + if im.IsDetail == 1 && !emitted[im.ID] { + out = append(out, im) + } + } + return out } func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error { @@ -190,7 +377,7 @@ func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields ma } func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) error { - // 先取 key(删除前清理七牛孤儿用),再软删 DB 行,最后按引用计数决定是否真删七牛文件。 + // 先取 key(删除前清理S4孤儿用),再软删 DB 行,最后按引用计数决定是否真删S4文件。 key, err := s.repo.GetSnapImageKey(ctx, imageID) if err != nil { return err @@ -208,16 +395,21 @@ func (s *streetSnapService) SetCover(ctx context.Context, snapID uint32, image s return s.repo.SetSnapCover(ctx, snapID, image) } +// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 +func (s *streetSnapService) SumImageSize(ctx context.Context, snapID uint32) (int64, error) { + return s.repo.SumImageSize(ctx, snapID) +} + func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { - // 下架街拍:软删图片 + 置下架 + 异步入队清理七牛孤儿图(见 PurgeGallery)。 + // 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 return s.PurgeGallery(ctx, id) } // 上架(deleted=0)仅恢复图集行,不动图片/不清理(图片若已删不可恢复,属预期)。 return s.repo.SetSnapDeleted(ctx, id, 0) } -// PurgeGallery 下架街拍并异步清理七牛孤儿图:逻辑同 articleService.PurgeGallery, +// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery, // 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。 func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error { keys, err := s.repo.SoftDeleteSnapImages(ctx, id) @@ -229,7 +421,7 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error { } if len(keys) > 0 && s.cleaner != nil { if err := s.cleaner.EnqueueMediaCleanup(ctx, keys); err != nil { - log.Printf("[warn] 下架街拍 %d 入队清理七牛失败(文件将暂留): %v", id, err) + log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err) } } return nil diff --git a/internal/service/translate.go b/internal/service/translate.go new file mode 100644 index 0000000..ae79484 --- /dev/null +++ b/internal/service/translate.go @@ -0,0 +1,224 @@ +package service + +import ( + "context" + "regexp" + "sort" + "strings" + "sync" + + "fashionapi/internal/repository" +) + +// TranslateService 标题中英文翻译:规则兜底 + 人工记忆优先。 +// +// 设计目标:标题高度固定(品牌/年份/季节/系列/城市),绝大多数可规则翻译; +// 人工在后台纠正过的中文标题写入记忆表,之后无论入库还是编辑都优先命中,永久精确。 +// 入库只读取记忆(不写入),避免把规则猜测的半成品固化成「真理」。 +type TranslateService struct { + brandRepo repository.BrandRepository + memRepo repository.TranslateRepository + + brandMu sync.Mutex + brandOnce bool + brandMap map[string]string // name_en(小写) -> name_cn +} + +// NewTranslateService 创建翻译服务。brandRepo / memRepo 任一为 nil 时,对应能力自动降级 +// (品牌映射为空 / 记忆不命中),仅影响翻译覆盖率,不会 panic。 +func NewTranslateService(brandRepo repository.BrandRepository, memRepo repository.TranslateRepository) *TranslateService { + return &TranslateService{brandRepo: brandRepo, memRepo: memRepo} +} + +// SuggestTitleCn 给出 title_en 的中文翻译:记忆优先,规则兜底。 +// 规则无法完整翻译(存在未覆盖的英文词)时返回空串,绝不写半吊子脏数据。 +func (s *TranslateService) SuggestTitleCn(ctx context.Context, titleEn string) (string, error) { + en := strings.TrimSpace(titleEn) + if en == "" { + return "", nil + } + if s.memRepo != nil { + if cn, ok, err := s.memRepo.GetTitleCnMemory(ctx, en); err != nil { + return "", err + } else if ok && cn != "" { + return cn, nil + } + } + cn, _ := s.ruleTranslate(en) + return cn, nil +} + +// Remember 记录一条「英文标题 → 中文标题」的人工映射(幂等 upsert)。 +func (s *TranslateService) Remember(ctx context.Context, titleEn, titleCn string) error { + en := strings.TrimSpace(titleEn) + cn := strings.TrimSpace(titleCn) + if en == "" || cn == "" || s.memRepo == nil { + return nil + } + return s.memRepo.UpsertTitleCnMemory(ctx, en, cn) +} + +// ── 规则翻译 ─────────────────────────────────────────────────────────────────── + +// repItem 静态替换项:英文短语 → 中文。 +type repItem struct { + pat string + cn string +} + +// staticPatterns 固定映射。组合词(Spring Summer)须排在单词(Spring)之前, +// 由 init 统一按长度降序编译,保证优先匹配。 +var staticPatterns = []repItem{ + // 街拍短语 + {"Fashion Week", "时装周"}, + {"Street Style", "街拍"}, + {"Street Snap", "街拍"}, + {"Street Snaps", "街拍"}, + // 季节(组合在前) + {"Spring Summer", "春夏"}, + {"Fall Winter", "秋冬"}, + {"Autumn Winter", "秋冬"}, + {"Pre-Spring", "早春"}, + {"Pre-Fall", "早秋"}, + {"Spring", "春夏"}, + {"Summer", "盛夏"}, + {"Autumn", "秋冬"}, + {"Winter", "秋冬"}, + {"Fall", "秋冬"}, + // 系列 / 品类 + {"Ready-to-Wear", "成衣"}, + {"Ready To Wear", "成衣"}, + {"Couture", "高级定制"}, + {"Resort", "度假"}, + {"Menswear", "男装"}, + {"Womenswear", "女装"}, + {"Mens", "男装"}, + {"Womens", "女装"}, + {"RTW", "成衣"}, + // 城市 + {"New York", "纽约"}, + {"Hong Kong", "香港"}, + {"Los Angeles", "洛杉矶"}, + {"San Francisco", "旧金山"}, + {"London", "伦敦"}, + {"Paris", "巴黎"}, + {"Milan", "米兰"}, + {"Tokyo", "东京"}, + {"Seoul", "首尔"}, + {"Shanghai", "上海"}, + {"Beijing", "北京"}, + {"Berlin", "柏林"}, + {"Copenhagen", "哥本哈根"}, + {"Florence", "佛罗伦萨"}, + {"Madrid", "马德里"}, + {"Rome", "罗马"}, + {"Sydney", "悉尼"}, + {"Toronto", "多伦多"}, + {"Chicago", "芝加哥"}, +} + +type compiledRep struct { + re *regexp.Regexp + cn string +} + +// staticReplacements 编译后的静态替换表(按短语长度降序)。 +var staticReplacements []compiledRep + +func init() { + sorted := append([]repItem{}, staticPatterns...) + sort.Slice(sorted, func(i, j int) bool { return len(sorted[i].pat) > len(sorted[j].pat) }) + for _, it := range sorted { + staticReplacements = append(staticReplacements, compiledRep{ + re: regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(it.pat) + `\b`), + cn: it.cn, + }) + } +} + +// 非中/非数/非字母/非空白的标点一律清掉(避免替换后遗留孤标点符号)。 +var nonWordRe = regexp.MustCompile(`[^\p{Han}\p{Hangul}\p{Hiragana}\p{Katakana}0-9A-Za-z\s]`) + +// 仍含 ASCII 字母 ⇒ 存在未覆盖的英文词 ⇒ 未完整翻译。 +var asciiLetterRe = regexp.MustCompile(`[A-Za-z]+`) + +// ruleTranslate 翻译服务的规则分支:直接用共享的 RuleTranslate(注入已加载的品牌映射)。 +func (s *TranslateService) ruleTranslate(en string) (string, bool) { + s.ensureBrandMap() + return RuleTranslate(en, s.brandMap) +} + +// RuleTranslate 纯规则翻译(不含记忆):brandCN 为 name_en(小写)→name_cn 映射。 +// 品牌名按长度降序、词边界(\b)匹配,避免子串误替(如 brand "On" 误伤 "fashion")。 +// 所有英文词都被覆盖才成功,否则返回 ("", false) —— 绝不写半吊子脏数据。 +// +// 导出供一次性脚本(dbtool filltitlecn)复用,避免规则逻辑重复实现。 +func RuleTranslate(en string, brandCN map[string]string) (string, bool) { + if strings.TrimSpace(en) == "" { + return "", false + } + lower := strings.ToLower(en) + for _, r := range staticReplacements { + lower = r.re.ReplaceAllString(lower, r.cn) + } + if len(brandCN) > 0 { + keys := make([]string, 0, len(brandCN)) + for k := range brandCN { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) + for _, k := range keys { + cn := brandCN[k] + if cn == "" { + continue + } + if re := brandRegex(k); re != nil { + lower = re.ReplaceAllString(lower, cn) + } + } + } + clean := nonWordRe.ReplaceAllString(lower, " ") + if asciiLetterRe.MatchString(clean) { + return "", false + } + return strings.Join(strings.Fields(clean), " "), true +} + +// brandReCache 缓存品牌名编译后的正则,避免每次翻译重复编译(ingest 高频场景)。 +var brandReCache sync.Map // en(lower) -> *regexp.Regexp + +func brandRegex(en string) *regexp.Regexp { + if v, ok := brandReCache.Load(en); ok { + return v.(*regexp.Regexp) + } + re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(en) + `\b`) + brandReCache.Store(en, re) + return re +} + +// ensureBrandMap 懒加载品牌「英文→中文」映射(按名称长度降序 + 词边界正则),并发安全。 +func (s *TranslateService) ensureBrandMap() { + s.brandMu.Lock() + defer s.brandMu.Unlock() + if s.brandOnce { + return + } + s.brandOnce = true + if s.brandRepo == nil { + s.brandMap = map[string]string{} + return + } + brands, err := s.brandRepo.CrawlBrands(context.Background(), 0) + if err != nil || len(brands) == 0 { + s.brandMap = map[string]string{} + return + } + m := make(map[string]string, len(brands)) + for _, b := range brands { + if b.NameEn == "" { + continue + } + m[strings.ToLower(b.NameEn)] = strings.TrimSpace(b.NameCn) + } + s.brandMap = m +} diff --git a/internal/service/translate_test.go b/internal/service/translate_test.go new file mode 100644 index 0000000..afccc2d --- /dev/null +++ b/internal/service/translate_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "testing" + + "fashionapi/internal/model" + "fashionapi/internal/repository" +) + +type fakeTranslateBrandRepo struct{ repository.BrandRepository } + +func (fakeTranslateBrandRepo) CrawlBrands(ctx context.Context, brandID uint32) ([]model.Brand, error) { + return []model.Brand{ + {NameEn: "Chanel", NameCn: "香奈儿"}, + {NameEn: "Acne Studios", NameCn: "阿尼亚"}, + }, nil +} + +type fakeTranslateMem struct { + m map[string]string + repository.TranslateRepository +} + +func (f *fakeTranslateMem) GetTitleCnMemory(ctx context.Context, titleEn string) (string, bool, error) { + v, ok := f.m[titleEn] + return v, ok, nil +} + +func (f *fakeTranslateMem) UpsertTitleCnMemory(ctx context.Context, titleEn, titleCn string) error { + if f.m == nil { + f.m = map[string]string{} + } + f.m[titleEn] = titleCn + return nil +} + +func newTranslateServiceForTest() *TranslateService { + return &TranslateService{brandRepo: fakeTranslateBrandRepo{}, memRepo: &fakeTranslateMem{m: map[string]string{}}} +} + +func TestRuleTranslate(t *testing.T) { + brandCN := map[string]string{"chanel": "香奈儿", "acne studios": "阿尼亚"} + cases := []struct { + en string + want string + ok bool + }{ + {"Spring 2024 Ready-to-Wear", "春夏 2024 成衣", true}, + {"London Fashion Week Street Style", "伦敦 时装周 街拍", true}, + {"Chanel Spring 2024 Ready-to-Wear", "香奈儿 春夏 2024 成衣", true}, + {"Acne Studios Fall 2023 Couture", "阿尼亚 秋冬 2023 高级定制", true}, + // 含未知品牌 → 未完整翻译 → 失败(不写脏数据) + {"UnknownBrand Spring 2024 Ready-to-Wear", "", false}, + {"", "", false}, + } + for _, c := range cases { + got, ok := RuleTranslate(c.en, brandCN) + if ok != c.ok || got != c.want { + t.Errorf("RuleTranslate(%q) = (%q, %v), want (%q, %v)", c.en, got, ok, c.want, c.ok) + } + } +} + +func TestSuggestTitleCnMemory(t *testing.T) { + svc := newTranslateServiceForTest() + ctx := context.Background() + if err := svc.Remember(ctx, "Foobar Fall 2023 Couture", "自定义中文"); err != nil { + t.Fatal(err) + } + got, err := svc.SuggestTitleCn(ctx, "Foobar Fall 2023 Couture") + if err != nil { + t.Fatal(err) + } + if got != "自定义中文" { + t.Fatalf("记忆未优先命中: got %q", got) + } +} + +func TestSuggestTitleCnRule(t *testing.T) { + svc := newTranslateServiceForTest() + got, err := svc.SuggestTitleCn(context.Background(), "Chanel Spring 2024 Ready-to-Wear") + if err != nil { + t.Fatal(err) + } + if got != "香奈儿 春夏 2024 成衣" { + t.Fatalf("规则翻译不符: got %q", got) + } +} diff --git a/internal/vision/client.go b/internal/vision/client.go new file mode 100644 index 0000000..84ed665 --- /dev/null +++ b/internal/vision/client.go @@ -0,0 +1,177 @@ +// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。 +// +// 设计要点(对应规格 docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §6/§11): +// - 服务不可用、超时、维度不符一律返回 ErrUnavailable,调用方据此「不写向量」而非中断入库; +// - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上; +// - 熔断期与禁用态下不发起任何 HTTP 请求。 +// +// 该客户端只服务于「疑似同款」提示,**不参与任何自动去重**。 +package vision + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" +) + +// MaxBatch 单次请求的最大图片数,须与服务端限制保持一致。 +const MaxBatch = 32 + +// cooldown 熔断后的冷却时长。 +const cooldown = 5 * time.Minute + +var ( + // ErrUnavailable 表示服务不可用:未启用、熔断中、网络错误、超时、响应异常或维度不符。 + ErrUnavailable = errors.New("vision service unavailable") + // ErrBatchTooLarge 表示单批图片数超过 MaxBatch。 + ErrBatchTooLarge = errors.New("vision batch too large") +) + +// embedResponse 是服务端 POST /embed 的响应结构。 +type embedResponse struct { + Dim int `json:"dim"` + Vectors [][]float32 `json:"vectors"` + Indexes []int `json:"indexes"` + Skipped []int `json:"skipped"` + Device string `json:"device"` +} + +// embedRequest 是 POST /embed 的请求结构。 +type embedRequest struct { + Images []string `json:"images"` +} + +// Client 是视觉服务客户端。并发安全。 +type Client struct { + endpoint string + dim int + failLimit int + http *http.Client + + mu sync.Mutex + fails int + cooldownEnd time.Time + disabled bool +} + +// New 构造客户端。timeout 与 failLimit 非正时取默认值(5s / 3 次)。 +func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client { + if timeout <= 0 { + timeout = 5 * time.Second + } + if failLimit <= 0 { + failLimit = 3 + } + return &Client{ + endpoint: endpoint, + dim: dim, + failLimit: failLimit, + http: &http.Client{Timeout: timeout}, + } +} + +// Disable 关闭客户端:此后 Embed 直接返回 ErrUnavailable 且不发起请求。 +// 用于配置未启用视觉服务时走零开销路径。 +func (c *Client) Disable() { + c.mu.Lock() + defer c.mu.Unlock() + c.disabled = true +} + +// Enabled 报告客户端当前是否可发起请求(未禁用且不在熔断冷却期)。 +func (c *Client) Enabled() bool { return c.Open() } + +// Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。 +func (c *Client) Open() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.canRequestLocked() +} + +func (c *Client) canRequestLocked() bool { + if c.disabled { + return false + } + return time.Now().After(c.cooldownEnd) +} + +// fail 记录一次失败;达到阈值则进入熔断冷却并清零计数。 +func (c *Client) fail() { + c.mu.Lock() + defer c.mu.Unlock() + c.fails++ + if c.fails >= c.failLimit { + c.cooldownEnd = time.Now().Add(cooldown) + c.fails = 0 + } +} + +// ok 记录一次成功,清零连续失败计数。 +func (c *Client) ok() { + c.mu.Lock() + defer c.mu.Unlock() + c.fails = 0 +} + +// Embed 把图片字节批量编码为归一化向量,返回的 skipped 是无法解码的图片下标。 +// +// 任何故障(未启用 / 熔断 / 网络 / 超时 / 响应异常 / 维度不符)都返回 ErrUnavailable, +// 调用方应据此跳过写向量,而不是让入库失败。 +// 空输入直接返回 nil, nil, nil;超过 MaxBatch 返回 ErrBatchTooLarge。 +func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) { + if len(imgs) == 0 { + return nil, nil, nil + } + if len(imgs) > MaxBatch { + return nil, nil, ErrBatchTooLarge + } + if !c.Open() { + return nil, nil, ErrUnavailable + } + + payload := embedRequest{Images: make([]string, len(imgs))} + for i, b := range imgs { + payload.Images[i] = base64.StdEncoding.EncodeToString(b) + } + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + c.fail() + return nil, nil, ErrUnavailable + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + c.fail() + return nil, nil, ErrUnavailable + } + var out embedResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + c.fail() + return nil, nil, ErrUnavailable + } + if out.Dim != c.dim { + // 维度不符属于配置与模型不一致:必须报错并拒写,防止污染向量列。 + c.fail() + return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim) + } + + c.ok() + return out.Vectors, out.Skipped, nil +} diff --git a/internal/vision/client_integration_test.go b/internal/vision/client_integration_test.go new file mode 100644 index 0000000..2837080 --- /dev/null +++ b/internal/vision/client_integration_test.go @@ -0,0 +1,104 @@ +//go:build integration + +// 集成测试:打真实运行的视觉服务,验证 Go 客户端与服务端的接口契约。 +// +// 运行前先起服务(本地开发用项目 .venv,走 GPU): +// +// cd D:\project\fashion-siglip +// .venv\Scripts\python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1 +// +// 然后: +// +// go test -tags integration ./internal/vision/ -run TestClientAgainstRealService -v +// +// 服务未启动时本测试 **跳过**(不算失败),以免阻塞没有该服务的环境。 +// 可用 VISION_ENDPOINT 环境变量指向其它地址。 +package vision + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "os" + "testing" + "time" +) + +func TestClientAgainstRealService(t *testing.T) { + endpoint := os.Getenv("VISION_ENDPOINT") + if endpoint == "" { + endpoint = "http://127.0.0.1:8900" + } + + // 1) 探活:不通就跳过(本测试依赖外部进程,不应让 CI 变红)。 + hc := &http.Client{Timeout: 3 * time.Second} + resp, err := hc.Get(endpoint + "/health") + if err != nil { + t.Skipf("视觉服务不可达(%s),跳过集成测试:%v", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + var health struct { + Ready bool `json:"ready"` + Dim int `json:"dim"` + Device string `json:"device"` + } + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + t.Fatalf("解析 /health 失败: %v", err) + } + if !health.Ready { + t.Skipf("视觉服务未就绪(模型仍在加载或加载失败),跳过:%+v", health) + } + t.Logf("服务就绪:dim=%d device=%s", health.Dim, health.Device) + + // 2) 用一张程序生成的 PNG 验证完整契约:base64 请求 → 服务解码 → 768 维归一化向量。 + img := generateTestPNG(t) + + c := New(endpoint, health.Dim, 60*time.Second, 3) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + vecs, skipped, err := c.Embed(ctx, [][]byte{img}) + if err != nil { + t.Fatalf("Embed 失败: %v", err) + } + if len(skipped) != 0 { + t.Fatalf("合法 PNG 不应被跳过,skipped=%v", skipped) + } + if len(vecs) != 1 { + t.Fatalf("应返回 1 个向量,得到 %d", len(vecs)) + } + if got := len(vecs[0]); got != health.Dim { + t.Fatalf("向量维度应为 %d,得到 %d", health.Dim, got) + } + + // 服务端承诺已 L2 归一化:模长≈1 是「点积即余弦相似度」的前提。 + var sumSq float64 + for _, f := range vecs[0] { + sumSq += float64(f) * float64(f) + } + norm := sumSq + if norm < 0.99 || norm > 1.01 { + t.Fatalf("向量应已 L2 归一化(模长²≈1),实际 %f", norm) + } +} + +// generateTestPNG 生成一张 64x64 的纯色 PNG(不引入外部测试资源)。 +func generateTestPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + for x := 0; x < 64; x++ { + for y := 0; y < 64; y++ { + img.Set(x, y, color.RGBA{R: 200, G: 60, B: 120, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("生成测试 PNG 失败: %v", err) + } + return buf.Bytes() +} diff --git a/internal/vision/client_test.go b/internal/vision/client_test.go new file mode 100644 index 0000000..e695379 --- /dev/null +++ b/internal/vision/client_test.go @@ -0,0 +1,164 @@ +package vision + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestEmbedReturnsVectorsAndSkipsBroken 验证正常路径:批量编码返回向量, +// 服务端跳过的图片下标原样带回,向量维度与配置一致。 +func TestEmbedReturnsVectorsAndSkipsBroken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/embed" { + t.Errorf("路径不对: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "dim": 768, + "vectors": [][]float32{make([]float32, 768), make([]float32, 768)}, + "indexes": []int{0, 2}, + "skipped": []int{1}, + "device": "cpu", + }) + })) + defer srv.Close() + + c := New(srv.URL, 768, 2*time.Second, 3) + vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("a"), []byte("b"), []byte("c")}) + if err != nil { + t.Fatalf("Embed 返回错误: %v", err) + } + if len(vecs) != 2 { + t.Fatalf("应有 2 个向量,得到 %d", len(vecs)) + } + if len(skipped) != 1 || skipped[0] != 1 { + t.Fatalf("skipped 应为 [1],得到 %v", skipped) + } + if len(vecs[0]) != 768 { + t.Fatalf("向量维度应为 768,得到 %d", len(vecs[0])) + } +} + +// TestEmbedDisabledSkipsCall 验证禁用后完全不发起 HTTP 请求(视觉服务未启用时的零开销路径)。 +func TestEmbedDisabledSkipsCall(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + + c := New(srv.URL, 768, time.Second, 3) + c.Disable() + vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("x")}) + if err != ErrUnavailable { + t.Fatalf("禁用时应返回 ErrUnavailable,实际 %v", err) + } + if vecs != nil || skipped != nil { + t.Fatalf("禁用时应返回 nil,实际 %v %v", vecs, skipped) + } + if called { + t.Fatal("禁用时不应发起 HTTP 请求") + } +} + +// TestEmbedDimensionMismatchIsError 验证维度不符必须报错: +// 若放过,会把错误维度的向量写进 image_embeddings 造成脏数据。 +func TestEmbedDimensionMismatchIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "dim": 512, "vectors": [][]float32{make([]float32, 512)}, "indexes": []int{0}, "skipped": []int{}, + }) + })) + defer srv.Close() + + c := New(srv.URL, 768, time.Second, 3) + if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil { + t.Fatal("维度不符应返回错误") + } +} + +// TestEmbedTripBreakerThenCooldownSkips 验证熔断:连续失败达阈值后进入冷却, +// 冷却期内直接返回错误且不再发起请求(避免每张图都卡在超时上)。 +func TestEmbedTripBreakerThenCooldownSkips(t *testing.T) { + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := New(srv.URL, 768, time.Second, 3) + for i := 0; i < 3; i++ { + if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil { + t.Fatalf("第 %d 次调用应报错", i+1) + } + } + if c.Open() { + t.Fatal("熔断后 Open 应为 false") + } + before := hits + if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil { + t.Fatalf("熔断期应直接返回错误: %v", err) + } + if hits != before { + t.Fatalf("熔断期间不应再发起请求,hits 从 %d 变为 %d", before, hits) + } +} + +// TestEmbedSendsBase64Payload 验证请求体格式(base64 编码的图片数组), +// 这是与服务端约定的契约,写错会导致服务端解码失败。 +func TestEmbedSendsBase64Payload(t *testing.T) { + var got struct { + Images []string `json:"images"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + _ = json.NewEncoder(w).Encode(map[string]any{ + "dim": 768, "vectors": [][]float32{make([]float32, 768)}, "indexes": []int{0}, "skipped": []int{}, + }) + })) + defer srv.Close() + + c := New(srv.URL, 768, time.Second, 3) + if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("hello")}); err != nil { + t.Fatalf("Embed 返回错误: %v", err) + } + want := base64.StdEncoding.EncodeToString([]byte("hello")) + if len(got.Images) != 1 || got.Images[0] != want { + t.Fatalf("请求体 base64 不正确: %v", got.Images) + } +} + +// TestEmbedEmptyInputIsNoop 验证空输入不发起请求、不报错。 +func TestEmbedEmptyInputIsNoop(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + + c := New(srv.URL, 768, time.Second, 3) + vecs, skipped, err := c.Embed(context.Background(), nil) + if err != nil || vecs != nil || skipped != nil { + t.Fatalf("空输入应返回 nil,nil,nil,实际 %v %v %v", vecs, skipped, err) + } + if called { + t.Fatal("空输入不应发起请求") + } +} + +// TestEmbedBatchTooLarge 验证单批上限,超限直接拒绝(避免把一张大图撑爆服务端)。 +func TestEmbedBatchTooLarge(t *testing.T) { + c := New("http://127.0.0.1:1", 768, time.Second, 3) + imgs := make([][]byte, MaxBatch+1) + for i := range imgs { + imgs[i] = []byte("x") + } + if _, _, err := c.Embed(context.Background(), imgs); err != ErrBatchTooLarge { + t.Fatalf("超上限应返回 ErrBatchTooLarge,实际 %v", err) + } +} diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..1ee7206 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,92 @@ +# 蓝绿切换的入口:唯一对外端口 80,容器常驻、发布时从不重建。 +# 上游地址不写死,全部来自 active/ 下的 map(由 deploy.sh 生成 + nginx -s reload)。 +# 该目录挂的是仓库外的 .deploy/nginx,所以发布时不会把 git 工作区改脏。 +# 用变量做 proxy_pass,槽容器不存在时 nginx 依然能启动(按请求时才解析)。 + +# 切换点:map $host $api_up { default "backend_a:8090"; } ... +include /etc/nginx/active/*.conf; + +# Docker 内置 DNS:服务名 -> 容器 IP +resolver 127.0.0.11 valid=10s ipv6=off; +resolver_timeout 3s; + +server { + listen 80; + server_name _; + + # SSG 内部接口:公网一律 404,绝不从外部出口 + location ~ ^/api/(internal/ssg/|v1/ssg/) { + return 404; + } + + # 后端 API:proxy_pass 不带 URI,原样透传 /api/... 前缀(带斜杠会把前缀剥掉导致全站 404) + location /api/ { + proxy_pass http://$api_up; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 3s; + proxy_read_timeout 60s; + } + + # Astro 构建产物文件名带内容哈希,可长期缓存 + location /_astro/ { + proxy_pass http://$web_up; + proxy_set_header Host $host; + expires 1y; + } + + # Astro node server:SSR 详情页与静态资源 + location / { + proxy_pass http://$web_up; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # 部署脚本用来确认代理本身活着 + location = /healthz { + access_log off; + return 200 "ok\n"; + } +} + +# 内部:web 容器的 SSR 取数走这里,永远指向当前活跃的后端槽 +server { + listen 18090; + access_log off; + location / { + proxy_pass http://$api_up; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + } +} + +# 内部:前端构建期取 SSG 数据(只绑宿主回环,前端镜像 build 时用 host 网络访问) +server { + listen 127.0.0.1:18091; + access_log off; + location / { + proxy_pass http://$ssg_up; + proxy_set_header Host $host; + } +} + +# 内部:管理后台(只绑宿主回环;远程访问用 SSH 隧道到 18092) +server { + listen 127.0.0.1:18092; + location / { + proxy_pass http://$admin_up; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/scripts/diag_brand/main.go b/scripts/diag_brand/main.go index 970726f..1976718 100644 --- a/scripts/diag_brand/main.go +++ b/scripts/diag_brand/main.go @@ -3,21 +3,21 @@ package main import ( "fmt" - "gorm.io/driver/mysql" + "gorm.io/driver/postgres" "gorm.io/gorm" ) -const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" +const dsn = "postgres://fashion:fashion_dev_2026@127.0.0.1:5432/fashion?sslmode=disable" func main() { - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { panic(err) } // 1) brand 3100 的走秀档案数(only_with_articles 子查询依赖这个) var artCount int64 - db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND brand_id=3100`).Scan(&artCount) + db.Raw(`SELECT COUNT(*) FROM brand_runways WHERE is_deleted=0 AND brand_id=3100`).Scan(&artCount) fmt.Printf("brand 3100 走秀档案数 = %d\n", artCount) // 2) 复现后端完整搜索:only_with_articles=1 + keyword=猿人头 @@ -30,9 +30,9 @@ func main() { } var rows []Row db.Raw(` - SELECT id, name, name_en, name_cn FROM brand + SELECT id, name, name_en, name_cn FROM brands WHERE is_deleted=0 - AND id IN (SELECT DISTINCT brand_id FROM brand_runway WHERE is_deleted=0) + AND id IN (SELECT DISTINCT brand_id FROM brand_runways WHERE is_deleted=0) AND (name_en LIKE ? OR name_cn LIKE ?)`, kw, kw).Scan(&rows) fmt.Printf("\n[搜索 keyword=猿人头, only_with_articles=1] 命中 %d 条:\n", len(rows)) @@ -43,7 +43,7 @@ func main() { // 3) 对比:only_with_articles=0(不过滤档案)时的命中 var rows2 []Row db.Raw(` - SELECT id, name, name_en, name_cn FROM brand + SELECT id, name, name_en, name_cn FROM brands WHERE is_deleted=0 AND (name_en LIKE ? OR name_cn LIKE ?)`, kw, kw).Scan(&rows2) diff --git a/scripts/diag_hot/main.go b/scripts/diag_hot/main.go index d92f46f..e3b43ca 100644 --- a/scripts/diag_hot/main.go +++ b/scripts/diag_hot/main.go @@ -5,14 +5,14 @@ package main import ( "fmt" - "gorm.io/driver/mysql" + "gorm.io/driver/postgres" "gorm.io/gorm" ) -const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" +const dsn = "postgres://fashion:fashion_dev_2026@127.0.0.1:5432/fashion?sslmode=disable" func main() { - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { panic(err) } @@ -26,7 +26,7 @@ func main() { var rows []Row db.Raw(` SELECT brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows - FROM brand_runway + FROM brand_runways WHERE is_deleted = 0 GROUP BY brand_id ORDER BY images DESC @@ -36,24 +36,24 @@ func main() { fmt.Println("== FeaturedIDs(images, 30) 原始排名 ==") for i, r := range rows { var name string - db.Raw(`SELECT name_en FROM brand WHERE id = ?`, r.BrandID).Scan(&name) + db.Raw(`SELECT name_en FROM brands WHERE id = ?`, r.BrandID).Scan(&name) fmt.Printf("%2d. brand_id=%-6d images=%-6d shows=%-4d %s\n", i+1, r.BrandID, r.Images, r.Shows, name) } // 2) image_count 分布:有多少品牌有图、多少归零 var zeroBrands, hasImgBrands int64 var totalRows, zeroRows int64 - db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runway WHERE is_deleted=0 GROUP BY brand_id) t WHERE s = 0`).Scan(&zeroBrands) - db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runway WHERE is_deleted=0 GROUP BY brand_id) t WHERE s > 0`).Scan(&hasImgBrands) - db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0`).Scan(&totalRows) - db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND image_count=0`).Scan(&zeroRows) + db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runways WHERE is_deleted=0 GROUP BY brand_id) t WHERE s = 0`).Scan(&zeroBrands) + db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runways WHERE is_deleted=0 GROUP BY brand_id) t WHERE s > 0`).Scan(&hasImgBrands) + db.Raw(`SELECT COUNT(*) FROM brand_runways WHERE is_deleted=0`).Scan(&totalRows) + db.Raw(`SELECT COUNT(*) FROM brand_runways WHERE is_deleted=0 AND image_count=0`).Scan(&zeroRows) fmt.Printf("\n== image_count 分布 ==\n有图品牌=%d 零图品牌=%d | 走秀总行=%d 其中 image_count=0 的行=%d\n", hasImgBrands, zeroBrands, totalRows, zeroRows) // 3) 对比口径:按走秀数(shows) DESC 前 10 var byShows []Row db.Raw(` SELECT brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows - FROM brand_runway + FROM brand_runways WHERE is_deleted = 0 GROUP BY brand_id ORDER BY shows DESC @@ -62,7 +62,7 @@ func main() { fmt.Println("\n== 若按 shows DESC 前 10 ==") for i, r := range byShows { var name string - db.Raw(`SELECT name_en FROM brand WHERE id = ?`, r.BrandID).Scan(&name) + db.Raw(`SELECT name_en FROM brands WHERE id = ?`, r.BrandID).Scan(&name) fmt.Printf("%2d. brand_id=%-6d images=%-6d shows=%-4d %s\n", i+1, r.BrandID, r.Images, r.Shows, name) } } diff --git a/scripts/fashionapi.service b/scripts/fashionapi.service deleted file mode 100644 index 2afbacc..0000000 --- a/scripts/fashionapi.service +++ /dev/null @@ -1,34 +0,0 @@ -# ============================================================ -# Fashion API —— systemd 服务单元(可选,用于服务器常驻运行) -# ------------------------------------------------------------ -# 用法(在服务器上): -# sudo cp fashionapi.service /etc/systemd/system/ -# sudo systemctl daemon-reload -# sudo systemctl enable --now fashionapi -# 查看日志:sudo journalctl -u fashionapi -f -# -# 敏感项(JWT_SECRET / DB_PASSWORD 等)建议用 Environment 注入, -# 不要写进 config.yml,也不要提交到仓库。 -# ============================================================ - -[Unit] -Description=Fashion API (public readonly endpoints) -After=network.target - -[Service] -Type=simple -WorkingDirectory=/opt/fashionapi -ExecStart=/opt/fashionapi/server-linux-amd64 -# 生产环境变量覆盖(按需修改) -Environment=CONFIG_PATH=/opt/fashionapi/config.yml -Environment=GIN_MODE=release -# Environment=DB_HOST=127.0.0.1 -# Environment=DB_PASSWORD=your_strong_password -# Environment=JWT_SECRET=change-me-to-a-random-long-string -Restart=on-failure -RestartSec=3 -# 如需指定运行账户,取消下一行注释并确保该用户对 /opt/fashionapi 有读权限 -# User=www-data - -[Install] -WantedBy=multi-user.target diff --git a/scripts/pgvector/docker-compose.yml b/scripts/pgvector/docker-compose.yml new file mode 100644 index 0000000..332eabb --- /dev/null +++ b/scripts/pgvector/docker-compose.yml @@ -0,0 +1,34 @@ +# docker-compose.yml — PostgreSQL 16 + pgvector (Docker) +# +# 数据持久化在命名卷 pgvector_data;首次初始化时 initdb/01-extensions.sql +# 会自动 CREATE EXTENSION vector。端口 5432 映射到宿主机 localhost, +# 与 Go 后端用 postgres://fashion:fashion_dev_2026@localhost:5432/fashion 连接。 +# +# 管理: +# 启动 : docker compose up -d +# 停止 : docker compose stop +# 重建 : docker compose down && docker compose up -d (不动数据卷则数据不丢) +# 清数据: docker compose down -v + +services: + pgvector: + image: ankane/pgvector:latest # 预编译好 pgvector 的 PG 镜像;latest 当前底层为 PG17,对去重无影响 + container_name: pgvector + restart: unless-stopped + environment: + POSTGRES_USER: fashion + POSTGRES_PASSWORD: fashion_dev_2026 # 仅本地开发;生产务必换强密码 + POSTGRES_DB: fashion + ports: + - "5432:5432" + volumes: + - pgvector_data:/var/lib/postgresql/data + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fashion -d fashion"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + pgvector_data: diff --git a/scripts/pgvector/initdb/01-extensions.sql b/scripts/pgvector/initdb/01-extensions.sql new file mode 100644 index 0000000..d93c761 --- /dev/null +++ b/scripts/pgvector/initdb/01-extensions.sql @@ -0,0 +1,3 @@ +-- 首次初始化(数据卷为空时由 postgres 官方 entrypoint 自动执行)即启用 pgvector 扩展。 +-- 用 IF NOT EXISTS 保证重复执行幂等。 +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/scripts/pgvector/initdb/03-migrate-phash-bigint-to-vector.sql b/scripts/pgvector/initdb/03-migrate-phash-bigint-to-vector.sql new file mode 100644 index 0000000..2e6a122 --- /dev/null +++ b/scripts/pgvector/initdb/03-migrate-phash-bigint-to-vector.sql @@ -0,0 +1,50 @@ +-- 03-migrate-phash-bigint-to-vector.sql +-- 兼容旧库:phash 早期以 bigint 存储 64-bit dHash 指纹(uint64),现模型改为 vector(64)。 +-- pgvector 不支持 bigint -> vector 直接转换,需逐位拆成 0/1 向量(位 i -> 第 i 维, +-- 与 Go 侧 phash.ToVectorBits 完全一致,保证旧数据与新写入向量在同一语义空间可比)。 +-- +-- 用法(仅升级旧库时需要): +-- 本迁移原先由后端启动时的 AutoMigrate(migratePhashSQL)自动执行;AutoMigrate 已移除, +-- 故升级老库改回手动执行: +-- psql "postgres://fashion:fashion_dev_2026@localhost:5432/fashion" -f 03-migrate-phash-bigint-to-vector.sql +-- +-- 已为 vector/text 类型的列自动跳过;可重复执行。 + +DO $$ +DECLARE + t text; + dt text; +BEGIN + FOR t IN + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('brand_runway_images','brand_runway_draft_images','street_snap_images','street_snap_draft_images') + LOOP + SELECT data_type INTO dt + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = t AND column_name = 'phash'; + + IF dt = 'bigint' THEN + -- bigint 即 64-bit dHash 指纹;pgvector 不支持 bigint->vector 直接转换,且 ALTER ... USING 的 + -- transform 表达式不允许子查询(0A000)。改用纯表达式:bigint::bit(64) 取 64 位模式(MSB 在左), + -- reverse 成 LSB 在左(与 phash.ToVectorBits 维度顺序一致:第 i 维 = bit i),插逗号后 text::vector(64)。 + -- NULL 自然透传。 + EXECUTE format($e$ + ALTER TABLE %I ALTER COLUMN phash TYPE vector(64) + USING ( + ('[' || + rtrim(regexp_replace(reverse(phash::bit(64)::text), '(.)', '\1,', 'g'), ',') || + ']')::vector(64) + ) + $e$, t); + RAISE NOTICE 'migrated phash (bigint -> vector) on %', t; + ELSIF dt IN ('character varying','text') THEN + EXECUTE format($e$ + ALTER TABLE %I ALTER COLUMN phash TYPE vector(64) USING phash::vector(64) + $e$, t); + RAISE NOTICE 'migrated phash (text -> vector) on %', t; + ELSE + RAISE NOTICE 'phash on % already type %, skipped', t, dt; + END IF; + END LOOP; +END $$; diff --git a/scripts/pgvector/setup_pg_docker.sh b/scripts/pgvector/setup_pg_docker.sh new file mode 100644 index 0000000..2104a46 --- /dev/null +++ b/scripts/pgvector/setup_pg_docker.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# setup_pg_docker.sh — 用 Docker 起 PostgreSQL 16 + pgvector 本地开发环境 +# +# 用法(在 WSL 终端里,不要用 Windows 的 CMD/PowerShell): +# sudo bash setup_pg_docker.sh +# +# 行为: +# 1) 先卸载上次用 apt 装失败的 PostgreSQL 残留(清理 pgdg 源 / 坏 key / 包),幂等。 +# 2) 确保 docker 可用(缺失则尝试 apt 装 docker.io 并起守护进程;仍不行则给出 Docker Desktop 指引)。 +# 3) docker compose up -d 起 pgvector 容器(幂等:已存在则不变)。 +# 4) 等就绪后验证 version() 与 vector 扩展版本。 +# +# 仅用于本地开发 / 迁移验证。生产请改强密码并单独评审配置。 +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" +DB_USER=fashion +DB_PASS="fashion_dev_2026" +DB_NAME=fashion + +log() { echo -e "\033[32m==>\033[0m $*"; } +warn() { echo -e "\033[33m⚠️ \033[0m $*"; } + +if [ "$(id -u)" -ne 0 ]; then + echo "请以 root 运行: sudo bash $0"; exit 1 +fi + +# ---------- 1) 卸载上次 apt 版 PostgreSQL 的残留 ---------- +if command -v psql >/dev/null 2>&1 || [ -f /etc/apt/sources.list.d/pgdg.list ]; then + log "卸载上次 apt 版 PostgreSQL 残留(清理失败残留,幂等)" + pg_ctlcluster "$(ls /etc/postgresql 2>/dev/null | head -1)" main stop 2>/dev/null \ + || service postgresql stop 2>/dev/null \ + || true + apt-get remove --purge -y 'postgresql-*' 2>/dev/null || true + rm -f /etc/apt/sources.list.d/pgdg.list + rm -f /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \ + /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg + apt-get autoremove -y 2>/dev/null || true + log "apt 版 PostgreSQL 残留已清理" +else + log "未发现 apt 版 PostgreSQL,跳过卸载" +fi + +# ---------- 2) 确保 docker 可用 ---------- +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + log "docker 可用" +else + if command -v docker >/dev/null 2>&1; then + warn "docker 已安装但守护进程未起,尝试启动" + service docker start 2>/dev/null || (dockerd >/var/log/docker.log 2>&1 &) || true + sleep 3 + fi + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + warn "docker 不可用,尝试 apt 安装 docker.io" + apt-get update -y + if apt-get install -y docker.io; then + service docker start 2>/dev/null || (dockerd >/var/log/docker.log 2>&1 &) || true + sleep 3 + fi + fi + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + echo "❌ 仍无法使用 docker。请二选一:" + echo " A) 装 Docker Desktop (Windows),安装时勾选 'Use WSL 2'," + echo " 装好后在其 Settings > Resources > WSL Integration 里启用你的 WSL 发行版,重启 WSL 后重试;" + echo " B) 或在本 WSL 内: sudo apt-get install -y docker.io && sudo service docker start" + exit 1 + fi +fi + +# 选 docker compose 命令(v2 插件优先,回退 v1) +if docker compose version >/dev/null 2>&1; then + DC=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + DC=(docker-compose) +else + echo "❌ 未找到 docker compose / docker-compose,请安装 Docker Compose 后重试"; exit 1 +fi + +# ---------- 2.5) 配置国内镜像源加速(避免直连 Docker Hub 慢) ---------- +MIRRORS='["https://docker.m.daocloud.io","https://hub-mirror.c.163.com","https://mirror.baidubce.com"]' +if docker context ls 2>/dev/null | grep -qw "desktop-linux" || [[ "${DOCKER_HOST:-}" == *"npipe"* ]]; then + # Docker Desktop(守护进程在 Windows 侧):WSL 内的 daemon.json 不生效,必须在 GUI 配 + warn "检测到 Docker Desktop:WSL 内的 daemon.json 不影响 Windows 侧守护进程。" + echo " 请在 Windows 的 Docker Desktop > Settings > Docker Engine 的 JSON 中加入:" + echo " \"registry-mirrors\": $MIRRORS" + echo " 然后点 Apply & Restart;重启后再直接跑: docker compose -f $COMPOSE_FILE up -d" + echo " (本脚本不再自动拉起,避免与 Docker Desktop 守护进程冲突)" + exit 1 +elif ! docker info 2>/dev/null | grep -q "docker.m.daocloud.io"; then + # docker.io 跑在 WSL 内:直接写 daemon.json 并重启守护进程 + if [ ! -f /etc/docker/daemon.json ] || ! grep -q registry-mirrors /etc/docker/daemon.json; then + log "配置国内镜像源加速(写入 /etc/docker/daemon.json 并重启 docker 守护进程)" + cat > /etc/docker/daemon.json </dev/null || systemctl restart docker 2>/dev/null \ + || (dockerd --registry-mirror=https://docker.m.daocloud.io >/var/log/docker.log 2>&1 &) || true + sleep 3 + else + log "daemon.json 已含 registry-mirrors,跳过" + fi + # 等守护进程重新就绪 + for i in $(seq 1 15); do + docker info >/dev/null 2>&1 && break + sleep 1 + done +fi + +# ---------- 3) 起容器(幂等) ---------- +log "启动 PostgreSQL + pgvector 容器(docker compose up -d)" +"${DC[@]}" -f "$COMPOSE_FILE" up -d + +# ---------- 4) 等就绪 ---------- +log "等待数据库就绪(最多 30s)" +ready=0 +for i in $(seq 1 30); do + if docker exec pgvector pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + ready=1; break + fi + sleep 1 +done +if [ "$ready" -ne 1 ]; then + echo "❌ 数据库未就绪,查看日志: docker logs pgvector"; exit 1 +fi + +# ---------- 5) 验证 ---------- +log "验证安装" +docker exec -i pgvector psql -U "$DB_USER" -d "$DB_NAME" \ + -c "SELECT version();" \ + -c "SELECT extname, extversion FROM pg_extension WHERE extname='vector';" + +cat < '[...]'::vector) AS sim + FROM image_embedding + WHERE kind = 0 + ORDER BY embedding <=> '[...]'::vector + LIMIT 20; +EOF diff --git a/scripts/seed_users/main.go b/scripts/seed_users/main.go index 5b46c07..095e6f8 100644 --- a/scripts/seed_users/main.go +++ b/scripts/seed_users/main.go @@ -1,7 +1,7 @@ // Command seed_users 预置内部账号(运营/编辑用,不开放公开注册)。 // -// 项目已移除 AutoMigrate,表结构由 scripts/sql/005_create_users.sql 托管; -// 本脚本用 CREATE TABLE IF NOT EXISTS 兜底建表,再 upsert 一个内部账号。 +// 项目表结构由 cmd/dbtool 导出的 SQL 维护(dbtool dump → psql -f),服务启动时不再自动建表; +// 本脚本为「尚未灌库时也能独立跑」保留一份 users 兜底建表,再 upsert 一个内部账号。 // // 用法: // go run ./scripts/seed_users @@ -43,19 +43,19 @@ func main() { } }() - // 1. 兜底建表(与 scripts/sql/005_create_users.sql 完全一致) + // 1. 兜底建表(与 User 模型保持一致;正式结构由 dbtool dump 的 SQL 创建,此处仅作独立运行兜底) createSQL := `CREATE TABLE IF NOT EXISTS users ( - id int unsigned NOT NULL AUTO_INCREMENT, - created_at int unsigned NOT NULL DEFAULT 0, - updated_at int unsigned NOT NULL DEFAULT 0, + id bigserial PRIMARY KEY, + created_at bigint NOT NULL DEFAULT 0, + updated_at bigint NOT NULL DEFAULT 0, username varchar(64) NOT NULL, email varchar(191) NOT NULL, password_hash varchar(255) NOT NULL DEFAULT '', - is_deleted tinyint unsigned NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_username (username), - UNIQUE KEY uk_email (email) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` + is_deleted smallint NOT NULL DEFAULT 0, + tier varchar(16) NOT NULL DEFAULT 'free', + UNIQUE (username), + UNIQUE (email) + );` if e := db.Exec(createSQL).Error; e != nil { log.Fatalf("✗ 建表失败: %v", e) } diff --git a/scripts/setup_pg_wsl.sh b/scripts/setup_pg_wsl.sh new file mode 100644 index 0000000..9334f7b --- /dev/null +++ b/scripts/setup_pg_wsl.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# +# setup_pg_wsl.sh — WSL (Ubuntu/Debian) 下一键装 PostgreSQL + pgvector 开发环境 +# +# 用法(在 WSL 终端里,不要用 Windows 的 CMD/PowerShell 跑): +# sudo bash setup_pg_wsl.sh +# +# 三态幂等: +# 1) 未安装 -> 安装 + 启动 +# 2) 已安装未运行 -> 仅启动 +# 3) 已安装且运行 -> 完全不动(不重装 / 不重启;仅幂等确保库/用户/扩展存在) +# +# 仅用于本地开发 / 迁移验证,生产环境请改强密码并单独评审配置。 +# +set -euo pipefail + +# ============================ 配置(按需修改)============================ +PG_VERSION=16 # PostgreSQL 大版本,pgvector HNSW 在 13+ 均可用 +DB_NAME=fashion # 业务库名(替换成你的项目库) +DB_USER=fashion # 应用账号 +DB_PASS="fashion_dev_2026" # 仅本地开发;生产务必换成强密码 +# ========================================================================= + +log() { echo -e "\033[32m==>\033[0m $*"; } + +# 需要 root +if [ "$(id -u)" -ne 0 ]; then + echo "请以 root 运行: sudo bash $0"; exit 1 +fi + +# 发行版探测(仅提示,不强制) +if [ -r /etc/os-release ]; then + . /etc/os-release + log "检测到发行版: ${PRETTY_NAME:-unknown}" + case "${ID:-}" in + ubuntu|debian) ;; + *) echo "⚠️ 本脚本面向 Ubuntu/Debian WSL,其他发行版请自行调整 apt 命令";; + esac +fi + +# PG 是否在运行(用超级用户连 postgres 库试探,对活实例零副作用) +pg_is_running() { sudo -u postgres psql -d postgres -tAc "SELECT 1" >/dev/null 2>&1; } + +# 1) 已装则跳过安装(不重装) +if command -v psql >/dev/null 2>&1; then + log "PostgreSQL 已安装 ($(psql --version 2>/dev/null || echo unknown)),跳过安装" +else + log "安装依赖并添加 PostgreSQL 官方 apt 源" + # 关键:先清掉旧的 pgdg 源与坏 key,否则下面第一次 apt-get update 会因旧 .asc key + # 报 "not signed" 返回非零,被 set -e 直接退出,导致修 key 步骤永远没机会执行。 + rm -f /etc/apt/sources.list.d/pgdg.list + rm -f /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc + apt-get update -y + apt-get install -y curl ca-certificates gnupg lsb-release + # 加 pgdg 源(官方标准:armored key -> dearmor 成 .gpg,signed-by 指向 .gpg) + install -d /usr/share/postgresql-common/pgdg + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /tmp/pgdg-key.asc + gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg /tmp/pgdg-key.asc + chmod 644 /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list + apt-get update -y + apt-get install -y "postgresql-$PG_VERSION" "postgresql-client-$PG_VERSION" +fi + +# 2) pgvector(已可用则跳过,不重装) +if sudo -u postgres psql -d postgres -tAc "SELECT 1 FROM pg_available_extensions WHERE name='vector'" 2>/dev/null | grep -q 1; then + log "pgvector 已可用,跳过" +else + log "安装 pgvector 扩展" + if apt-get install -y "postgresql-$PG_VERSION-pgvector"; then + : + else + echo "apt 无 pgvector 包,改用源码编译..." + apt-get install -y "build-essential" "postgresql-server-dev-$PG_VERSION" git + tmp=$(mktemp -d) + git clone --depth 1 https://github.com/pgvector/pgvector.git "$tmp/pgvector" + make -C "$tmp/pgvector" + make -C "$tmp/pgvector" install + rm -rf "$tmp" + fi +fi + +# 3) 仅在未运行时启动;已在运行则完全不动 +if pg_is_running; then + log "PostgreSQL 已在运行,跳过启动 / 重启" +else + log "启动 PostgreSQL" + pg_ctlcluster "$PG_VERSION" main start 2>/dev/null \ + || service postgresql start 2>/dev/null \ + || systemctl start postgresql + # 等实例就绪(最多 15s) + for i in $(seq 1 15); do + if pg_is_running; then break; fi + sleep 1 + done + if ! pg_is_running; then + echo "❌ PostgreSQL 启动失败,请查日志: journalctl -u postgresql 或 /var/log/postgresql/"; exit 1 + fi +fi + +# 4) 幂等确保库/用户/扩展(对运行中实例无副作用,不重启) +log "确保用户、数据库与 pgvector 扩展存在(幂等)" +sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD '$DB_PASS';" +sudo -u postgres psql -c "CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';" 2>/dev/null \ + || echo " 角色 $DB_USER 已存在,跳过" +sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 \ + || sudo -u postgres createdb -O "$DB_USER" "$DB_NAME" +sudo -u postgres psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS vector;" + +# 5) 本地访问配置:仅当 listen 还不是 localhost 才改;运行中走在线 reload,绝不重启 +PGCONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf" +if [ -f "$PGCONF" ]; then + if grep -q "^listen_addresses = 'localhost'" "$PGCONF"; then + : # 已配置,无需改动 + else + log "调整 listen_addresses=localhost(仅本地)" + sed -i "s/^#*listen_addresses = .*/listen_addresses = 'localhost'/" "$PGCONF" + if pg_is_running; then + pg_ctlcluster "$PG_VERSION" main reload 2>/dev/null \ + || sudo -u postgres psql -d postgres -c "SELECT pg_reload_conf();" >/dev/null 2>&1 \ + || echo " ⚠️ reload 失败,配置将在下次重启生效" + else + log "(实例未运行,配置将在下次启动时生效)" + fi + fi +fi + +# 6) 验证 +log "验证安装" +sudo -u postgres psql -d "$DB_NAME" \ + -c "SELECT version();" \ + -c "SELECT extname, extversion FROM pg_extension WHERE extname='vector';" + +cat < '[...]'::vector) AS sim + FROM image_embedding + WHERE kind = 0 + ORDER BY embedding <=> '[...]'::vector + LIMIT 20; +EOF diff --git a/scripts/sql/002_i18n_columns.sql b/scripts/sql/002_i18n_columns.sql deleted file mode 100644 index 38d963e..0000000 --- a/scripts/sql/002_i18n_columns.sql +++ /dev/null @@ -1,79 +0,0 @@ --- ============================================================================ --- 002_i18n_columns.sql --- 方案 A(独立列):把品牌名 / 走秀标题 / 描述 拆分为 en / cn 两列。 --- --- 设计前提(已用探查脚本核对线上数据): --- * brand.name 多为拉丁名,但 266 个含中文;show_name 形如 "拉丁/中文"(2025 个含中文)。 --- * brand_runway.title / description 校验过 han=0,当前全为英文。 --- * 排序 / A-Z 索引 / 关键词搜索 统一基于 name_en(拉丁),中文品牌(name_en 为空)归入 OTHER 桶。 --- --- 回填策略: --- name_en = name 去掉中文后的部分;若 name 全中文则取 show_name 斜杠前部分。 --- name_cn = show_name 斜杠后含中文的部分;否则 show_name 本身(含中文);否则 name(含中文)。 --- title_en / description_en = 原 title / description;title_cn / description_cn = ''(当前无中文)。 --- --- 旧列 name / show_name / title / description 保留不删,作为回退与对比; --- 代码全部改用 *_en / *_cn 后,可择机在后续迁移中 DROP。 --- ============================================================================ - -ALTER TABLE brand - ADD COLUMN name_en VARCHAR(255) NOT NULL DEFAULT '' AFTER name, - ADD COLUMN name_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en; - -ALTER TABLE brand_runway - ADD COLUMN title_en VARCHAR(255) NOT NULL DEFAULT '' AFTER title, - ADD COLUMN title_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en, - ADD COLUMN description_en VARCHAR(1024) NOT NULL DEFAULT '' AFTER description, - ADD COLUMN description_cn VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en; - --- 回填品牌名:拆拉丁 / 中文 -UPDATE brand SET - name_en = CASE - WHEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', '')) - WHEN show_name LIKE '%/%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1)) - ELSE '' - END, - name_cn = CASE - WHEN show_name LIKE '%/%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '[一-鿿]' - THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1)) - WHEN show_name REGEXP '[一-鿿]' THEN TRIM(show_name) - WHEN name REGEXP '[一-鿿]' THEN TRIM(name) - ELSE '' - END; - --- 回填走秀:当前 title / description 全为英文,直接进 *_en -UPDATE brand_runway SET - title_en = title, - description_en = description, - title_cn = '', - description_cn = ''; - --- ============================================================================ --- 第 5 步(依赖「分类字段回填迁移」之后执行):title_cn 规则生成 --- --- 前提:collection_type / season / year 已由历史迁移解析回填(rtw/menswear/ --- couture/resort/pre_fall × spring/fall/空)。title 本质是「季节/品类描述」, --- 品牌名在 JOIN 的 brand_name 里单独给,因此 title_cn 可纯规则生成, --- 完全不依赖外部翻译服务,结果稳定可重跑。 --- --- 映射:resort→「{year} 度假系列」、pre_fall→「{year} 早秋系列」、 --- rtw→「{year} {春夏|秋冬}成衣」、menswear→「{year} {春夏|秋冬}男装」、 --- couture→「{year} {春夏|秋冬}高定」。 --- --- 执行器:scripts/backfill_title_cn/main.go(幂等,整表覆盖重跑无害)。 --- 该步骤与上方 i18n 加列/回填相互独立,可单独重跑。 --- ============================================================================ -UPDATE brand_runway -SET title_cn = CASE - WHEN collection_type = 'resort' THEN CONCAT(year, ' 度假系列') - WHEN collection_type = 'pre_fall' THEN CONCAT(year, ' 早秋系列') - WHEN collection_type = 'rtw' AND season = 'spring' THEN CONCAT(year, ' 春夏成衣') - WHEN collection_type = 'rtw' AND season = 'fall' THEN CONCAT(year, ' 秋冬成衣') - WHEN collection_type = 'rtw' THEN CONCAT(year, ' 成衣') - WHEN collection_type = 'menswear' AND season = 'spring' THEN CONCAT(year, ' 春夏男装') - WHEN collection_type = 'menswear' AND season = 'fall' THEN CONCAT(year, ' 秋冬男装') - WHEN collection_type = 'couture' AND season = 'spring' THEN CONCAT(year, ' 春夏高定') - WHEN collection_type = 'couture' AND season = 'fall' THEN CONCAT(year, ' 秋冬高定') - ELSE CONCAT(year, ' 系列') -END -WHERE is_deleted = 0; diff --git a/scripts/sql/003_drop_brand_alias.sql b/scripts/sql/003_drop_brand_alias.sql deleted file mode 100644 index 19afb82..0000000 --- a/scripts/sql/003_drop_brand_alias.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 003_drop_brand_alias.sql --- 删除 brand_alias 孤儿表。 --- 背景:i18n 方案 A 落地后,brand.name_cn / name_en 已成为品牌名权威来源; --- brand_alias 全代码库零引用,且 99.9% 中文记录与 brand.name_cn 完全重复, --- 保留它只会成为名字的第二来源、后期悄然分叉。故删除。 --- 注意:本文件为审阅底稿,实际执行由 scripts/i18n/drop_alias/main.go 驱动(无 mysql 客户端环境)。 - -DROP TABLE IF EXISTS brand_alias; diff --git a/scripts/sql/004_add_streetsnap_city.sql b/scripts/sql/004_add_streetsnap_city.sql deleted file mode 100644 index 5260275..0000000 --- a/scripts/sql/004_add_streetsnap_city.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 街拍地区筛选:street_snap 新增 city 列(从 title 第一段 \t 解析回填) --- 对应执行器 scripts/backfill_city/main.go(幂等,可重跑) --- city 存「地点」原文(如 Paris / 巴黎 / New York),'' 表示未归类,不参与地区筛选 -ALTER TABLE street_snap ADD COLUMN city VARCHAR(128) NOT NULL DEFAULT '' AFTER year; diff --git a/scripts/sql/005_create_users.sql b/scripts/sql/005_create_users.sql deleted file mode 100644 index 2d501da..0000000 --- a/scripts/sql/005_create_users.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 账号体系用户表(内部运营/编辑账号,不开放公开注册)。 --- 与 scripts/seed_users/main.go 中内置的建表语句保持一致。 -CREATE TABLE IF NOT EXISTS `users` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `created_at` int unsigned NOT NULL DEFAULT 0, - `updated_at` int unsigned NOT NULL DEFAULT 0, - `username` varchar(64) NOT NULL, - `email` varchar(191) NOT NULL, - `password_hash` varchar(255) NOT NULL DEFAULT '', - `is_deleted` tinyint unsigned NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uk_username` (`username`), - UNIQUE KEY `uk_email` (`email`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/sql/006_create_refresh_tokens.sql b/scripts/sql/006_create_refresh_tokens.sql deleted file mode 100644 index 65132bc..0000000 --- a/scripts/sql/006_create_refresh_tokens.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 006: refresh_tokens —— 刷新令牌表(双令牌续期 + 踢下线的服务端状态) --- 与项目约定一致:表结构由 SQL 托管,不依赖 GORM AutoMigrate。 -CREATE TABLE IF NOT EXISTS refresh_tokens ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id INT UNSIGNED NOT NULL, - token_hash VARCHAR(64) NOT NULL, - expires_at INT UNSIGNED NOT NULL DEFAULT 0, - revoked TINYINT UNSIGNED NOT NULL DEFAULT 0, - user_agent VARCHAR(255) NOT NULL DEFAULT '', - ip VARCHAR(64) NOT NULL DEFAULT '', - created_at INT UNSIGNED NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_token_hash (token_hash), - KEY idx_user_id (user_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/sql/007_create_favorites.sql b/scripts/sql/007_create_favorites.sql deleted file mode 100644 index b471ff6..0000000 --- a/scripts/sql/007_create_favorites.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 007: favorites —— 用户收藏(服务端持久化,按用户隔离) --- 与项目约定一致:表结构由 SQL 托管,不依赖 GORM AutoMigrate。 --- target_uid 存前端卡片的 it.id(编码串),与前端直接对齐;title/cover/brand 为收藏时快照。 -CREATE TABLE IF NOT EXISTS `favorites` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `created_at` int unsigned NOT NULL DEFAULT 0, - `user_id` int unsigned NOT NULL, - `target_type` varchar(16) NOT NULL DEFAULT '', - `target_uid` varchar(64) NOT NULL DEFAULT '', - `title` varchar(255) NOT NULL DEFAULT '', - `cover` varchar(512) NOT NULL DEFAULT '', - `brand` varchar(255) NOT NULL DEFAULT '', - PRIMARY KEY (`id`), - UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`), - KEY `idx_user` (`user_id`), - KEY `idx_user_created` (`user_id`, `created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/sql/008_create_histories.sql b/scripts/sql/008_create_histories.sql deleted file mode 100644 index 4d12d52..0000000 --- a/scripts/sql/008_create_histories.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 008: histories —— 用户浏览历史(服务端持久化,按用户隔离,跨设备一致) --- 与 favorites 同理:表结构由 SQL 托管,不依赖 GORM AutoMigrate。 --- --- 2026-09-02 收敛为「文章级」浏览历史: --- 1) 触发点:点开某篇走秀/街拍详情(/item/xxx)时记一条;进入列表页不再记。 --- 2) 仅存 (user_id, target_uid, viewed_at):target_uid 为带类型前缀的对外编码串 --- (runway r=xxx / street s=xxx,见后端 hashid.EncodeTyped),前缀本身携带类型, --- 无需 kind / target_type 列。 --- 3) title/cover/brand 快照已移除:账户页展示时按 target_uid 回查公开详情接口, --- 保持表最简、展示数据新鲜。 --- 唯一键 (user_id, target_uid):同一篇文章反复打开只 upsert 刷新 viewed_at,绝不新增行。 --- 每用户上限 HISTORY_CAP(应用层维护,默认 2000),超出 FIFO 删最旧。 -CREATE TABLE IF NOT EXISTS `histories` ( - `id` int unsigned NOT NULL AUTO_INCREMENT, - `viewed_at` int unsigned NOT NULL DEFAULT 0, - `user_id` int unsigned NOT NULL, - `target_uid` varchar(64) NOT NULL DEFAULT '', - PRIMARY KEY (`id`), - UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`), - KEY `idx_user_viewed` (`user_id`, `viewed_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server.err b/server.err deleted file mode 100644 index 33ba907..0000000 --- a/server.err +++ /dev/null @@ -1,6 +0,0 @@ -2026/09/02 15:39:33 ✓ 配置文件: D:\project\backend_v2\configs\config.yml -2026/09/02 15:39:33 ✓ MySQL 已连接: 127.0.0.1:3306/db_dev -2026/09/02 15:39:33 ✓ 静态资源: /uploads → D:\project\backend_v2\uploads -2026/09/02 15:39:33 🚀 公开服务已启动: http://localhost:8090 -2026/09/02 15:39:33 🔒 SSG 内部服务已启动: http://localhost:8091 (仅构建期/回环可达) -[mysql] 2026/09/02 16:01:06 connection.go:49: bad connection diff --git a/server.out b/server.out deleted file mode 100644 index 3a363da..0000000 --- a/server.out +++ /dev/null @@ -1,63 +0,0 @@ -[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. - -[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - - using env: export GIN_MODE=release - - using code: gin.SetMode(gin.ReleaseMode) - -[GIN-debug] GET /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers) -[GIN-debug] HEAD /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers) -[GIN-debug] GET /api/v1/health --> fashionapi/internal/handler.(*HealthHandler).Check-fm (4 handlers) -[GIN-debug] GET /api/v1/public/runway-looks --> fashionapi/internal/handler.(*ArticleHandler).List-fm (6 handlers) -[GIN-debug] GET /api/v1/public/brands --> fashionapi/internal/handler.(*BrandHandler).List-fm (6 handlers) -[GIN-debug] GET /api/v1/public/street-snaps --> fashionapi/internal/handler.(*StreetSnapHandler).List-fm (6 handlers) -[GIN-debug] GET /api/v1/public/runway-looks/:id --> fashionapi/internal/handler.(*ArticleHandler).Detail-fm (4 handlers) -[GIN-debug] GET /api/v1/public/street-snaps/:id --> fashionapi/internal/handler.(*StreetSnapHandler).Detail-fm (4 handlers) -[GIN-debug] POST /api/v1/auth/login --> fashionapi/internal/handler.(*AuthHandler).Login-fm (4 handlers) -[GIN-debug] POST /api/v1/auth/refresh --> fashionapi/internal/handler.(*AuthHandler).Refresh-fm (4 handlers) -[GIN-debug] POST /api/v1/auth/logout --> fashionapi/internal/handler.(*AuthHandler).Logout-fm (4 handlers) -[GIN-debug] POST /api/v1/auth/logout-all --> fashionapi/internal/handler.(*AuthHandler).LogoutAll-fm (4 handlers) -[GIN-debug] GET /api/v1/auth/me --> fashionapi/internal/handler.(*AuthHandler).Me-fm (5 handlers) -[GIN-debug] GET /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).List-fm (5 handlers) -[GIN-debug] POST /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).Add-fm (5 handlers) -[GIN-debug] DELETE /api/v1/auth/favorites/:target_uid --> fashionapi/internal/handler.(*FavoriteHandler).Remove-fm (5 handlers) -[GIN-debug] POST /api/v1/auth/favorites/check --> fashionapi/internal/handler.(*FavoriteHandler).Check-fm (5 handlers) -[GIN-debug] GET /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).List-fm (5 handlers) -[GIN-debug] POST /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Record-fm (5 handlers) -[GIN-debug] DELETE /api/v1/auth/history/:target_uid --> fashionapi/internal/handler.(*HistoryHandler).Remove-fm (5 handlers) -[GIN-debug] DELETE /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Clear-fm (5 handlers) -[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. - - using env: export GIN_MODE=release - - using code: gin.SetMode(gin.ReleaseMode) - -[GIN-debug] GET /api/v1/ssg/brands/hot --> fashionapi/internal/handler.(*SSGHandler).HotBrands-fm (4 handlers) -[GIN-debug] GET /api/v1/ssg/index/runway --> fashionapi/internal/handler.(*SSGHandler).IndexRunway-fm (4 handlers) -[GIN-debug] GET /api/v1/ssg/street-snaps/popular --> fashionapi/internal/handler.(*SSGHandler).StreetSnapPopular-fm (4 handlers) -[GIN] 2026/09/02 - 15:40:07 | 200 | 0s | ::1 | GET "/api/v1/health" -[GIN] 2026/09/02 - 15:40:07 | 200 | 22.41ms | ::1 | GET "/api/v1/public/brands" -[GIN] 2026/09/02 - 15:41:59 | 200 | 220.55ms | ::1 | POST "/api/v1/auth/login" -[GIN] 2026/09/02 - 15:41:59 | 200 | 22.64ms | ::1 | POST "/api/v1/auth/history" -[GIN] 2026/09/02 - 15:41:59 | 200 | 11.67ms | ::1 | POST "/api/v1/auth/history" -[GIN] 2026/09/02 - 15:41:59 | 200 | 1.03ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" -[GIN] 2026/09/02 - 15:41:59 | 200 | 2.82ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery" -[GIN] 2026/09/02 - 15:41:59 | 200 | 1.6ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image" -[GIN] 2026/09/02 - 15:41:59 | 200 | 17.42ms | ::1 | DELETE "/api/v1/auth/history/rTEST123" -[GIN] 2026/09/02 - 15:41:59 | 200 | 13.48ms | ::1 | DELETE "/api/v1/auth/history/runway-looks" -[GIN] 2026/09/02 - 15:43:41 | 200 | 129.48ms | ::1 | POST "/api/v1/auth/login" -[GIN] 2026/09/02 - 15:43:41 | 200 | 7.37ms | ::1 | POST "/api/v1/auth/history" -[GIN] 2026/09/02 - 15:43:41 | 200 | 10.92ms | ::1 | POST "/api/v1/auth/history" -[GIN] 2026/09/02 - 15:43:41 | 200 | 2.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" -[GIN] 2026/09/02 - 15:43:41 | 200 | 1.22ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery" -[GIN] 2026/09/02 - 15:43:41 | 200 | 1.57ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image" -[GIN] 2026/09/02 - 15:43:41 | 200 | 16.02ms | ::1 | POST "/api/v1/auth/history" -[GIN] 2026/09/02 - 15:43:41 | 200 | 1.88ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" -[GIN] 2026/09/02 - 15:43:41 | 200 | 10.24ms | ::1 | DELETE "/api/v1/auth/history/rTEST123" -[GIN] 2026/09/02 - 15:43:41 | 200 | 5.76ms | ::1 | DELETE "/api/v1/auth/history/runway-looks" -[GIN] 2026/09/02 - 15:43:41 | 200 | 1.15ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24" -[GIN] 2026/09/02 - 16:01:06 | 200 | 14.3ms | ::1 | GET "/api/v1/auth/me?locale=en" -[GIN] 2026/09/02 - 16:01:06 | 200 | 14.8ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24" -[GIN] 2026/09/02 - 16:01:06 | 200 | 19.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all" -[GIN] 2026/09/02 - 16:01:09 | 200 | 13.48ms | ::1 | GET "/api/v1/public/runway-looks/r000AhByP?locale=cn" -[GIN] 2026/09/02 - 16:01:09 | 200 | 3.19ms | ::1 | GET "/api/v1/auth/me?locale=en" -[GIN] 2026/09/02 - 16:01:12 | 200 | 627.5µs | ::1 | GET "/api/v1/auth/me?locale=en" -[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24" -[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all"