From 42f63161254c054044bb9ebba05309b991b73e2e Mon Sep 17 00:00:00 2001 From: toom1996 <23cm.cn@gmail.com> Date: Fri, 25 Sep 2026 11:31:52 +0800 Subject: [PATCH] update --- cmd/server/main.go | 7 +- .../2026-09-22-01-single-table-publish.sql | 21 +- .../2026-09-24-01-runway-parent-image-id.sql | 37 + db_dump.sql | 214 +- .../plans/2026-09-22-single-table-publish.md | 2341 +++++++++++++++++ .../2026-09-22-single-table-publish-design.md | 4 +- internal/dto/article.go | 6 +- internal/handler/assets/admin.css | 77 +- internal/handler/assets/admin.js | 555 ++-- internal/handler/backstage_api.go | 18 +- internal/handler/backstage_handler.go | 420 ++- internal/handler/backstage_handler_test.go | 22 +- internal/handler/handler.go | 8 +- internal/handler/ssg_handler.go | 2 +- internal/handler/street_snap_handler.go | 4 +- internal/handler/templates.go | 22 +- internal/handler/templates/layout.html | 1 + .../handler/templates/pages/articles.html | 59 + .../handler/templates/pages/dashboard.html | 4 +- .../templates/pages/review-detail.html | 56 - .../handler/templates/pages/review-list.html | 50 - .../templates/pages/runway-detail.html | 4 +- .../handler/templates/pages/runway-edit.html | 16 +- internal/handler/templates/pages/runways.html | 39 - .../handler/templates/pages/snap-edit.html | 18 +- .../templates/pages/street-snap-detail.html | 4 +- .../handler/templates/pages/street-snaps.html | 27 - internal/handler/templates/partials.html | 48 +- internal/model/runway_image.go | 8 +- internal/model/street_snap.go | 3 +- internal/pkg/phash/phash.go | 3 +- .../admin_mixed_integration_test.go | 174 ++ internal/repository/admin_repository.go | 172 ++ internal/repository/article_repository.go | 41 +- .../editor_image_delete_integration_test.go | 130 + internal/repository/ingest_repository.go | 52 + .../ingest_single_table_integration_test.go | 2 +- .../public_view_columns_integration_test.go | 66 + internal/repository/review_repository.go | 91 + internal/repository/street_snap_repository.go | 50 +- ...street_title_normalize_integration_test.go | 48 + internal/router/backstage.go | 32 +- internal/router/backstage_test.go | 130 +- internal/service/article_service.go | 59 +- internal/service/ingest_image_count_test.go | 5 + internal/service/ingest_service.go | 22 +- internal/service/review_service.go | 77 +- internal/service/review_street_group_test.go | 44 +- internal/service/street_snap_group_test.go | 14 +- internal/service/street_snap_i18n_test.go | 96 + internal/service/street_snap_service.go | 84 +- 51 files changed, 4489 insertions(+), 998 deletions(-) create mode 100644 db/migrations/2026-09-24-01-runway-parent-image-id.sql create mode 100644 docs/superpowers/plans/2026-09-22-single-table-publish.md create mode 100644 internal/handler/templates/pages/articles.html delete mode 100644 internal/handler/templates/pages/review-detail.html delete mode 100644 internal/handler/templates/pages/review-list.html delete mode 100644 internal/handler/templates/pages/runways.html delete mode 100644 internal/handler/templates/pages/street-snaps.html create mode 100644 internal/repository/admin_mixed_integration_test.go create mode 100644 internal/repository/admin_repository.go create mode 100644 internal/repository/public_view_columns_integration_test.go create mode 100644 internal/repository/street_title_normalize_integration_test.go create mode 100644 internal/service/street_snap_i18n_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e8486c8..15b8b5f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -94,6 +94,8 @@ func main() { histRepo = repository.NewHistoryRepository(db) ingestRepo = repository.NewIngestRepository(db) reviewRepo = repository.NewReviewRepository(db) + // 文章管理混合列表(跨 brand_runways / street_snaps 的统一只读查询) + adminRepo = repository.NewAdminRepository(db) // 跨表图片引用计数(删除图集时判定 S4 孤儿文件用) mediaRepo = repository.NewMediaRepository(db) @@ -124,6 +126,9 @@ func main() { reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel) ) + // 把图片 URL 拼装器交给后台模板:编辑/详情页用 imgURL 渲染图片,S4 模式下也能打开。 + handler.SetImgComposer(imgComposer) + // 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表) // 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。 // imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。 @@ -156,7 +161,7 @@ func main() { }) // 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。 - backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc) + backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo) // 爬虫上报 handler(HMAC 验签入口) ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc) diff --git a/db/migrations/2026-09-22-01-single-table-publish.sql b/db/migrations/2026-09-22-01-single-table-publish.sql index 92c1fda..89f942f 100644 --- a/db/migrations/2026-09-22-01-single-table-publish.sql +++ b/db/migrations/2026-09-22-01-single-table-publish.sql @@ -18,7 +18,11 @@ ALTER TABLE street_snaps ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending', ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT ''; + ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '', + -- 街拍标题中文化:保留既有 title 作英文源,仅加 title_cn 供后台人工翻译、前端按 locale 取用。 + -- 与 brand_runways 用 title_en/title_cn 不同:street_snaps 上有 uq_ss_entity(city, year, COALESCE(title,'')), + -- 改名成本过高;pickLocale 在中文为空时回落英文,无需数据回灌。 + ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT ''; -- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录 -- 2026-09-22-01b-publish-existing-rows.sql。 @@ -31,14 +35,20 @@ CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status); CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status); -- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。 --- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。 +-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列 +-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。 +-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。 DROP VIEW IF EXISTS public_brand_runway_images; DROP VIEW IF EXISTS public_brand_runways; DROP VIEW IF EXISTS public_street_snap_images; DROP VIEW IF EXISTS public_street_snaps; CREATE VIEW public_brand_runways AS - SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0; + SELECT + id, brand_id, title_en, title_cn, description_en, description_cn, + cover, image_count, year, collection_type, season, season_code, + status, is_deleted, created_at, updated_at + FROM brand_runways WHERE status = 'published' AND is_deleted = 0; CREATE VIEW public_brand_runway_images AS SELECT i.* FROM brand_runway_images i @@ -46,7 +56,10 @@ CREATE VIEW public_brand_runway_images AS WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0; CREATE VIEW public_street_snaps AS - SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0; + SELECT + id, title, title_cn, year, city, cover, image_count, + status, is_deleted, created_at, updated_at + FROM street_snaps WHERE status = 'published' AND is_deleted = 0; CREATE VIEW public_street_snap_images AS SELECT i.* FROM street_snap_images i diff --git a/db/migrations/2026-09-24-01-runway-parent-image-id.sql b/db/migrations/2026-09-24-01-runway-parent-image-id.sql new file mode 100644 index 0000000..9b6c1d1 --- /dev/null +++ b/db/migrations/2026-09-24-01-runway-parent-image-id.sql @@ -0,0 +1,37 @@ +-- 2026-09-24-01 走秀图片改用 parent_image_id 分组(与街拍同构),移除 look_index。 +-- +-- 背景:走秀图片原用 look_index 把「主图 + 细节图」归到同一 Look 桶(look_index 仅作为分组桶号, +-- 对产品无意义)。现统一为 parent_image_id(细节图直接指向所属主图的行 id),与 street_snap_images +-- 完全同构,后台可复用同一套「合并 / 拆出」逻辑。 +-- +-- 步骤: +-- 1) 给 brand_runway_images 加 parent_image_id 列(与街拍对齐,默认 0); +-- 2) 回填:每个 look 内 is_detail=0 的主图,其同 look 的 is_detail=1 细节图 parent_image_id 置为该主图 id; +-- 3) 删 look_index 列; +-- 4) 重建 public_brand_runway_images 视图(原 SELECT i.* 在删列后失效,重建即按新列集展开)。 +-- +-- 幂等:ADD COLUMN IF NOT EXISTS;视图 DROP VIEW IF EXISTS 后重建。 +-- 应用后请重新导出结构:dbtool dump -clean -out db_dump.sql + +ALTER TABLE brand_runway_images ADD COLUMN IF NOT EXISTS parent_image_id bigint NOT NULL DEFAULT 0; + +-- 回填:对每个 runway,把每个 look 的 is_detail=1 细节图 parent_image_id 置为该 look 内 is_detail=0 主图的 id。 +-- 孤儿细节图(同 look 无主图)保持 parent_image_id=0(COALESCE 兜底,避免 NOT NULL 报错)。 +UPDATE brand_runway_images AS d +SET parent_image_id = COALESCE(( + SELECT MIN(m.id) + FROM brand_runway_images AS m + WHERE m.runway_id = d.runway_id + AND m.look_index = d.look_index + AND m.is_detail = 0 + AND m.is_deleted = 0 +), 0) +WHERE d.is_detail = 1 AND d.is_deleted = 0 AND d.parent_image_id = 0; + +ALTER TABLE brand_runway_images DROP COLUMN IF EXISTS look_index; + +DROP VIEW IF EXISTS public_brand_runway_images; +CREATE VIEW public_brand_runway_images AS + SELECT i.* FROM brand_runway_images i + JOIN brand_runways r ON r.id = i.runway_id + WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0; diff --git a/db_dump.sql b/db_dump.sql index e32fa47..2543bb2 100644 --- a/db_dump.sql +++ b/db_dump.sql @@ -1,15 +1,34 @@ -- dbtool 导出:127.0.0.1:5432/fashion --- 生成时间:2026-09-23T15:56:58+08:00 +-- 生成时间:2026-09-25T10:41:14+08:00 -- -- 导入(二选一): -- dbtool import -in <本文件> -- psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f <本文件> -- --- 注意:本文件不含 DROP,目标库必须是空库;重复执行会主键冲突。 +-- 注意:本文件由 dump -clean 生成,结构段前会 DROP 同名表 —— 目标库中这些表的既有数据将丢失。 SET client_encoding = 'UTF8'; SET client_min_messages = warning; -- ==================== 结构 ==================== +-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失 +DROP VIEW IF EXISTS public."public_street_snaps"; +DROP VIEW IF EXISTS public."public_street_snap_images"; +DROP VIEW IF EXISTS public."public_brand_runways"; +DROP VIEW IF EXISTS public."public_brand_runway_images"; +DROP TABLE IF EXISTS public."brand_runway_images" CASCADE; +DROP TABLE IF EXISTS public."brand_runways" CASCADE; +DROP TABLE IF EXISTS public."brands" CASCADE; +DROP TABLE IF EXISTS public."favorites" CASCADE; +DROP TABLE IF EXISTS public."histories" CASCADE; +DROP TABLE IF EXISTS public."image_duplicates" CASCADE; +DROP TABLE IF EXISTS public."image_embeddings" CASCADE; +DROP TABLE IF EXISTS public."ingest_jobs" CASCADE; +DROP TABLE IF EXISTS public."ingest_nonces" CASCADE; +DROP TABLE IF EXISTS public."refresh_tokens" CASCADE; +DROP TABLE IF EXISTS public."street_snap_images" CASCADE; +DROP TABLE IF EXISTS public."street_snaps" CASCADE; +DROP TABLE IF EXISTS public."users" CASCADE; + CREATE TABLE IF NOT EXISTS public."brand_runway_images" ( "id" bigint GENERATED BY DEFAULT AS IDENTITY, "created_at" bigint, @@ -192,24 +211,21 @@ SELECT i.id, CREATE OR REPLACE VIEW public."public_brand_runways" AS SELECT brand_runways.id, + brand_runways.brand_id, brand_runways.title_en, brand_runways.title_cn, brand_runways.description_en, brand_runways.description_cn, - brand_runways.created_at, - brand_runways.updated_at, - brand_runways.is_deleted, - brand_runways.image_count, - brand_runways.brand_id, - brand_runways.year, brand_runways.cover, + brand_runways.image_count, + brand_runways.year, brand_runways.collection_type, brand_runways.season, brand_runways.season_code, brand_runways.status, - brand_runways.job_id, - brand_runways.reviewer, - brand_runways.reject_reason + brand_runways.is_deleted, + brand_runways.created_at, + brand_runways.updated_at FROM brand_runways WHERE brand_runways.status::text = 'published'::text AND brand_runways.is_deleted = 0; @@ -238,15 +254,10 @@ SELECT street_snaps.id, street_snaps.city, street_snaps.cover, street_snaps.image_count, + street_snaps.status, street_snaps.is_deleted, street_snaps.created_at, - street_snaps.updated_at, - street_snaps.source, - street_snaps.source_url, - street_snaps.status, - street_snaps.job_id, - street_snaps.reviewer, - street_snaps.reject_reason + street_snaps.updated_at FROM street_snaps WHERE street_snaps.status::text = 'published'::text AND street_snaps.is_deleted = 0; @@ -1221,8 +1232,6 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at" (E'104', E'8', E'6161a1aa71bc1292b81fb10ae67549a329e738fac5ed80f2fad37fc8a91a0cc9', E'1791787806', E'1', E'1789195806'), (E'105', E'8', E'66e6b349950fe0ed32b96ba129238ae8539bea2a4f8be014636930b6053fe903', E'1791792765', E'1', E'1789200765'), (E'106', E'8', E'e31ee908c298498aec6fef629418be71131ae444780257b9800a5815ba69eccd', E'1791792802', E'1', E'1789200802'), - (E'107', E'8', E'409f92c490e950f4f5cd5651eb810f3b5d4313547a8d87699fef77941c58492a', E'1791875855', E'1', E'1789283855'), - (E'108', E'8', E'70193ca537cf5bd2f35b925ae4ad9232f319f56376db712b60f09af01d0b3714', E'1791883446', E'1', E'1789291446'), (E'109', E'8', E'657e2fbc46a6e907b3f032e810616482e9c3fb7ffde7970ec7fdd2727d9a9e9d', E'1791890744', E'1', E'1789298744'), (E'110', E'8', E'ee359442e4c5d117e02afe91bb122b48d887e4fca5d1e6a8e5a179bd3434a249', E'1791891010', E'1', E'1789299010'), (E'116', E'8', E'4ede0f57245a9b63b746cc4bafdb61157a7e2d1a9f1dca6cd65037aa020d905c', E'1792570859', E'1', E'1789978859'), @@ -1236,71 +1245,10 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at" (E'60', E'8', E'6c3e41b17da8c0734df4d389e996f648bf9d4fba7a718929e118f51b01f79d43', E'1791127836', E'1', E'1788535836'), (E'61', E'8', E'1b7ea57d082cd7668ec7ef8a5060188db0d93d4afcfc4d8eff7ed423e2661e25', E'1791127861', E'1', E'1788535861'), (E'62', E'8', E'2a5380647f63c87de03b79ad7dc4b8be659494fc6530dd30e62b720af2e090d7', E'1791127873', E'1', E'1788535873'), - (E'63', E'8', E'0ab8f2fad351b0acc6267b634b6dad57a6eb227791294949c75d24b25a14ddd4', E'1791127898', E'1', E'1788535898'), - (E'64', E'8', E'b654e2d27e23bb4a679d260d6828d7bd62a6643ef4b2ef6040e2b9f9333e7be2', E'1791129506', E'1', E'1788537506'), - (E'65', E'8', E'893918c82ed2379dcb303e7fb183653bed09deb3f7531dca8312d41a375240a0', E'1791129563', E'1', E'1788537563'), - (E'66', E'8', E'fdc2c38bfed4b6270d04b350e5f8f791003784f8a7ff01067e583cc2b029ede7', E'1791129702', E'1', E'1788537702'), - (E'67', E'8', E'332974485a5c4a1637d408be6dfd871c60660740f4611ae9f74d03dfc3761047', E'1791129958', E'1', E'1788537958'), - (E'68', E'8', E'f3c557d3b62d9e328594b73ea9e6910aaa0d8c474eaa33c88891ffa377d23520', E'1791133840', E'1', E'1788541840'), - (E'69', E'8', E'843cb5f4d8c25fa7c18532bb6925ac9ed7726c06f311aa090004d6b1e59d223c', E'1791134935', E'1', E'1788542935'), - (E'70', E'8', E'6be368df50a1befb326f145e9b1862edf0b5e1fdc96e6794c124af8d022b82f6', E'1791173464', E'1', E'1788581464'), - (E'71', E'8', E'6bdecb0f64e7f34c0ac7207c31a2cd075693b4a1dfeae58543bc96d0d3bee833', E'1791177493', E'1', E'1788585493'), - (E'72', E'8', E'ce3e69d9d17be46915b342f24bbc913961fd308a4c955ec7dc07ea3a239754ce', E'1791179899', E'1', E'1788587899'), - (E'73', E'8', E'c12ad4e7c5cd7d9dfabe40b82a0cd0d593cac3e453a0ef20f386aef71361f26f', E'1791182039', E'1', E'1788590039'), - (E'74', E'8', E'14dc8ab713b1f8ffd2e12dcee9bc9417dc76b911c7a1d6ff206cdc7c4617e5b6', E'1791182059', E'1', E'1788590059'), - (E'75', E'8', E'8f7c246cc0504cf50f9991e10b76e0b52e81e26c9f60a326c807f56152912e48', E'1791182164', E'1', E'1788590164'), - (E'76', E'8', E'f963ee99bb9fb2a5df5c88cdaa06c0971dc6cd94a975366c6da2691c26fda82d', E'1791184327', E'1', E'1788592327'), - (E'77', E'8', E'ac298e5efc00267c402c11b049d9d92f520bd690f5ee32a124e3659dfc3d0a19', E'1791184355', E'1', E'1788592355'), - (E'78', E'8', E'361945cad31b12d3ec0923c809020887c957dc3b052aa74a408c1c80f617169b', E'1791184476', E'1', E'1788592476'), - (E'79', E'8', E'91f8df41003d1f7872ef25e1b223c97efc5cdb52f688c5846c1b298a78596878', E'1791185097', E'1', E'1788593097'), - (E'80', E'8', E'd6cf15948cc3994f9b1a91619706571842175a9eb7e39f951206b1ae962943f2', E'1791185134', E'1', E'1788593134'), - (E'81', E'8', E'890ff168f17695a3e44af8612d20bf0cdf9e717e4e98d8936affb8076f8f52ab', E'1791185182', E'1', E'1788593182'), - (E'82', E'8', E'cb3a6e4f3ec5edf2ba21534abe8920e8bc265092417b634d113aff9ad8066a5f', E'1791186526', E'1', E'1788594526'), - (E'83', E'8', E'429ab08ce03f38a9c331541d27d12ca24a832833240430e54ef3772aa7836d03', E'1791212558', E'1', E'1788620558'), - (E'84', E'8', E'eb26f72f49277d04d355c1f32a2d3fd0311419b9e2f6b7fb2fc622eea52693bc', E'1791212567', E'1', E'1788620567'), - (E'85', E'8', E'e3b5e612dcfe72d4d03e97583976fda21fac2387f0d55ece36af819cf3bcbc51', E'1791212587', E'1', E'1788620587'), - (E'86', E'8', E'cb0764d2a1e98f78349b718d93170341ac2bbaac914c321386b0b7d0fb7c3d39', E'1791212602', E'1', E'1788620602'), - (E'87', E'8', E'16b4e8cb31a5fd085ecce3e9694912d725d9c376cab27d7116f6a4ea039e86e7', E'1791212603', E'1', E'1788620603'), - (E'88', E'8', E'b71ecec8386d158b7fd1215e43ee6f870ac4a5049162a1ecb81b40cbbf375cb6', E'1791302249', E'1', E'1788710249'), - (E'118', E'8', E'66978ac58f5437687555c535b5a9dad603c6ad905f6684de9d5cc40985e11ee0', E'1792597889', E'1', E'1790005889'), - (E'8', E'8', E'71d2cda477878b23c92d0ef574a3e99fd5c02fa4af083554b73d969d700f8004', E'1790848478', E'1', E'1788256478'), - (E'23', E'8', E'4a112c6a9e30af63a329fff29d111b352fbc24f26191212948b6cad25681ae03', E'1790928285', E'1', E'1788336285'), - (E'25', E'8', E'5155c8e63c9b3b7bc28bb1d0c5c4ec263a79b8d8dc0275d86832fcbb67603990', E'1790928395', E'1', E'1788336395'), - (E'26', E'8', E'924da4ae618c29a6f6e784a81ffc6abd2618e0bf3b15f6918f653c56aa4e42f5', E'1790928415', E'1', E'1788336415'), - (E'27', E'8', E'3ba38219e55041dfcfb838cd9667642d1e8129a3d0a2aec8398edf7531f17b27', E'1790928503', E'1', E'1788336503'), - (E'28', E'8', E'8bc32be79b3eb89e9d30bb7a4aaf0b29c58bb1c036f5c7b9d0c51a9af4686150', E'1790930929', E'1', E'1788338929'), - (E'30', E'8', E'd79a8269cc47eb22ad5329a79cc90da4142dca5a440eeaffecbeaa35e51cdcd6', E'1790931344', E'1', E'1788339344'), - (E'31', E'8', E'21dd83a1f7ae3a6dce0132aa7ad832b228fced7e1d0ff033102be59bb4fbb336', E'1790931884', E'1', E'1788339884'), - (E'32', E'8', E'f7f66ddf4bbe1c99cbe13e6448ca3dd3bbd5c6ee3d778ca31befe2a39f0eab00', E'1790932111', E'1', E'1788340111'), - (E'33', E'8', E'10e53f9937d779f27e9f62e561620cce92b7b94d4e45ef59060f1a587cbaf0da', E'1790932461', E'1', E'1788340461'), - (E'36', E'8', E'8ab626968d6ba5c85546c0154d1835622f21cba3fd5f70cb8962fcfa1f7c93f9', E'1790937983', E'1', E'1788345983'), - (E'37', E'8', E'8c819c48e7f17a248791390937c488a94fd7c4c7822907900a80f24ff086e394', E'1790938015', E'1', E'1788346015'), - (E'38', E'8', E'1e33cf9f6036efc21bbf28657577290087f8105d76c917f16f320d7cf25f8278', E'1790938336', E'1', E'1788346336'), - (E'39', E'8', E'77962d61a0c55d79de83e31e4efded50e565ca0d09c2f4554c1108e4aecef547', E'1790939501', E'1', E'1788347501'), - (E'40', E'8', E'c54cf2aececa5fd06701dd6fe2f9ee159f1046a5dd8818eec682498d7ec21a33', E'1790939520', E'1', E'1788347520'), - (E'41', E'8', E'b02994a50c903252e72ea078dc052a535069fe0a814df4de30d4aec57392ed0e', E'1790940991', E'1', E'1788348991'), - (E'42', E'8', E'0994a166bfe5a917d66da3d28e853991a724bad5ba2a56d0589b9f6e93f8ea8c', E'1790941592', E'1', E'1788349592'), - (E'43', E'8', E'941b4c59118ba58915a02f0ae4250b9ba8a0816b62b9a57e2203374b8741b035', E'1790941855', E'1', E'1788349855'), - (E'44', E'8', E'0218725a02ee66cdd6e266d6c34103352602330ffb3979b9051cfbf5126c8f90', E'1790954612', E'1', E'1788362612'), - (E'45', E'8', E'd12c5961d9118c099fe75da3ba3aa193c22c2b343d8f4e43e51977cded7a087e', E'1790954621', E'1', E'1788362621'), - (E'46', E'8', E'21b760a00aa8c919f23c8125ef331cbe935ed5398a9c1c43baeb54ae3945be2f', E'1790954789', E'1', E'1788362789'), - (E'47', E'8', E'4f0dd38a4b1d466d7eb714811747caf8a063a956c4facb35bdaf7c02941ca0d9', E'1790957460', E'1', E'1788365460'), - (E'48', E'8', E'440c584569062a9af0ef693e700e1c94b0c463ed0f682117edf1caed03c90dfb', E'1791083234', E'1', E'1788491234'), - (E'49', E'8', E'29182427b04947c65f4dbc4b750171efaa381eea076d9dc683414fb79ef941ec', E'1791101818', E'1', E'1788509818'), - (E'50', E'8', E'0f63fe9607968891784916633771b78c4125088c1cb9f612ce5b728428167f08', E'1791101826', E'1', E'1788509826'), - (E'51', E'8', E'cf652c732ae41567dcea1877f12d2042c527476d97dc673221aeee328918e7d9', E'1791126011', E'1', E'1788534011'), - (E'52', E'8', E'dcba5a0e495748eb77177f2b9c842ea6b8848bb0a4d9f2f8ba3a6f7814f3899f', E'1791126023', E'1', E'1788534023'), - (E'111', E'8', E'ce67009fb0eac30226a49f5800e4935f7433024e231971e18832ac997151544a', E'1792061397', E'1', E'1789469397'), - (E'112', E'8', E'816b96381d886887a05c12ec5883371671a95a9fa3bf27a308097c6565a23473', E'1792062995', E'1', E'1789470995'), - (E'113', E'8', E'6b6a514221de8aa4c0fda45fa6c9d6eab318aeb3ae8f72c37eec1100e11ffb73', E'1792114905', E'1', E'1789522905'), - (E'114', E'8', E'68af59790a958f09d2fe65728fdae7cf01ea59b40b99f1cd69977a4eefce3919', E'1792415160', E'1', E'1789823160'), - (E'115', E'8', E'6cc47083bd5373028aba68c89c0050ab2d691ee65e20993d50e8002cc500a1ba', E'1792415169', E'1', E'1789823169'), - (E'119', E'8', E'1b965638435da6e428c9d3d3316ac77ea3dad74f3427eed61cfd1dcaed5c1856', E'1792666163', E'0', E'1790074163'), (E'1', E'7', E'a6d40efa41eb7184626615404d62eec90837387fd9c0fab4b2e4998f99116641', E'1790768077', E'1', E'1788176077'), (E'2', E'7', E'294f4f0e9a3933bc0a346a650e9c2229c73351f23073f4826de864c60aae7ab3', E'1790768092', E'1', E'1788176092'), (E'3', E'7', E'1b5cb599d3cb5f34e154e1a30ed531f6bb60899a0f4eb7baa1ece97a2b0b8922', E'1790769528', E'1', E'1788177528'), - (E'4', E'7', E'4f53b8a0dcf701a2935e2616ca183e1538fab999fd2d282ec5efb347c130772f', E'1790769528', E'1', E'1788177528'); -INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at", "revoked", "created_at") VALUES + (E'4', E'7', E'4f53b8a0dcf701a2935e2616ca183e1538fab999fd2d282ec5efb347c130772f', E'1790769528', E'1', E'1788177528'), (E'9', E'7', E'43e9ab9901f7e3a3ca79e581195666c0a298a0cdaaa08655ccdd7b1e8995e68a', E'1790907621', E'1', E'1788315621'), (E'10', E'7', E'ee69982285607bcda68c297be3826cf17aebe16ee5d5eb354846d5f848ce1644', E'1790913746', E'1', E'1788321746'), (E'11', E'7', E'82980a5eb43432b294fe98e0ace622da74e8b60349a8a82be416972d19505017', E'1790913779', E'1', E'1788321779'), @@ -1319,7 +1267,73 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at" (E'29', E'7', E'22af98693b2caec27e958713b914414d8cf5f306101d53226c9d04dc6754b671', E'1790930929', E'1', E'1788338929'), (E'34', E'7', E'09bdc1a3388e2b09114339ac5a798b3b060a0d880d4d5a3b9e358a1a976dc703', E'1790934593', E'1', E'1788342593'), (E'35', E'7', E'a3ba322c3161d576afe7b2a3275cca4e215a91ee0768718f9aa841bc219f37bc', E'1790934604', E'1', E'1788342604'), - (E'120', E'7', E'c74e517dc0d12b895e5867b241fde55c78b55fc9ee44a72501b8f2035d80528b', E'1792736601', E'0', E'1790144601'); + (E'120', E'7', E'c74e517dc0d12b895e5867b241fde55c78b55fc9ee44a72501b8f2035d80528b', E'1792736601', E'0', E'1790144601'), + (E'107', E'8', E'409f92c490e950f4f5cd5651eb810f3b5d4313547a8d87699fef77941c58492a', E'1791875855', E'1', E'1789283855'), + (E'108', E'8', E'70193ca537cf5bd2f35b925ae4ad9232f319f56376db712b60f09af01d0b3714', E'1791883446', E'1', E'1789291446'), + (E'81', E'8', E'890ff168f17695a3e44af8612d20bf0cdf9e717e4e98d8936affb8076f8f52ab', E'1791185182', E'1', E'1788593182'), + (E'82', E'8', E'cb3a6e4f3ec5edf2ba21534abe8920e8bc265092417b634d113aff9ad8066a5f', E'1791186526', E'1', E'1788594526'), + (E'83', E'8', E'429ab08ce03f38a9c331541d27d12ca24a832833240430e54ef3772aa7836d03', E'1791212558', E'1', E'1788620558'), + (E'84', E'8', E'eb26f72f49277d04d355c1f32a2d3fd0311419b9e2f6b7fb2fc622eea52693bc', E'1791212567', E'1', E'1788620567'), + (E'85', E'8', E'e3b5e612dcfe72d4d03e97583976fda21fac2387f0d55ece36af819cf3bcbc51', E'1791212587', E'1', E'1788620587'), + (E'86', E'8', E'cb0764d2a1e98f78349b718d93170341ac2bbaac914c321386b0b7d0fb7c3d39', E'1791212602', E'1', E'1788620602'), + (E'87', E'8', E'16b4e8cb31a5fd085ecce3e9694912d725d9c376cab27d7116f6a4ea039e86e7', E'1791212603', E'1', E'1788620603'), + (E'88', E'8', E'b71ecec8386d158b7fd1215e43ee6f870ac4a5049162a1ecb81b40cbbf375cb6', E'1791302249', E'1', E'1788710249'), + (E'118', E'8', E'66978ac58f5437687555c535b5a9dad603c6ad905f6684de9d5cc40985e11ee0', E'1792597889', E'1', E'1790005889'), + (E'8', E'8', E'71d2cda477878b23c92d0ef574a3e99fd5c02fa4af083554b73d969d700f8004', E'1790848478', E'1', E'1788256478'), + (E'23', E'8', E'4a112c6a9e30af63a329fff29d111b352fbc24f26191212948b6cad25681ae03', E'1790928285', E'1', E'1788336285'), + (E'25', E'8', E'5155c8e63c9b3b7bc28bb1d0c5c4ec263a79b8d8dc0275d86832fcbb67603990', E'1790928395', E'1', E'1788336395'), + (E'26', E'8', E'924da4ae618c29a6f6e784a81ffc6abd2618e0bf3b15f6918f653c56aa4e42f5', E'1790928415', E'1', E'1788336415'), + (E'27', E'8', E'3ba38219e55041dfcfb838cd9667642d1e8129a3d0a2aec8398edf7531f17b27', E'1790928503', E'1', E'1788336503'), + (E'28', E'8', E'8bc32be79b3eb89e9d30bb7a4aaf0b29c58bb1c036f5c7b9d0c51a9af4686150', E'1790930929', E'1', E'1788338929'), + (E'63', E'8', E'0ab8f2fad351b0acc6267b634b6dad57a6eb227791294949c75d24b25a14ddd4', E'1791127898', E'1', E'1788535898'), + (E'64', E'8', E'b654e2d27e23bb4a679d260d6828d7bd62a6643ef4b2ef6040e2b9f9333e7be2', E'1791129506', E'1', E'1788537506'), + (E'65', E'8', E'893918c82ed2379dcb303e7fb183653bed09deb3f7531dca8312d41a375240a0', E'1791129563', E'1', E'1788537563'), + (E'66', E'8', E'fdc2c38bfed4b6270d04b350e5f8f791003784f8a7ff01067e583cc2b029ede7', E'1791129702', E'1', E'1788537702'), + (E'67', E'8', E'332974485a5c4a1637d408be6dfd871c60660740f4611ae9f74d03dfc3761047', E'1791129958', E'1', E'1788537958'), + (E'68', E'8', E'f3c557d3b62d9e328594b73ea9e6910aaa0d8c474eaa33c88891ffa377d23520', E'1791133840', E'1', E'1788541840'), + (E'69', E'8', E'843cb5f4d8c25fa7c18532bb6925ac9ed7726c06f311aa090004d6b1e59d223c', E'1791134935', E'1', E'1788542935'), + (E'70', E'8', E'6be368df50a1befb326f145e9b1862edf0b5e1fdc96e6794c124af8d022b82f6', E'1791173464', E'1', E'1788581464'), + (E'71', E'8', E'6bdecb0f64e7f34c0ac7207c31a2cd075693b4a1dfeae58543bc96d0d3bee833', E'1791177493', E'1', E'1788585493'), + (E'72', E'8', E'ce3e69d9d17be46915b342f24bbc913961fd308a4c955ec7dc07ea3a239754ce', E'1791179899', E'1', E'1788587899'), + (E'73', E'8', E'c12ad4e7c5cd7d9dfabe40b82a0cd0d593cac3e453a0ef20f386aef71361f26f', E'1791182039', E'1', E'1788590039'), + (E'74', E'8', E'14dc8ab713b1f8ffd2e12dcee9bc9417dc76b911c7a1d6ff206cdc7c4617e5b6', E'1791182059', E'1', E'1788590059'), + (E'75', E'8', E'8f7c246cc0504cf50f9991e10b76e0b52e81e26c9f60a326c807f56152912e48', E'1791182164', E'1', E'1788590164'), + (E'76', E'8', E'f963ee99bb9fb2a5df5c88cdaa06c0971dc6cd94a975366c6da2691c26fda82d', E'1791184327', E'1', E'1788592327'), + (E'77', E'8', E'ac298e5efc00267c402c11b049d9d92f520bd690f5ee32a124e3659dfc3d0a19', E'1791184355', E'1', E'1788592355'), + (E'78', E'8', E'361945cad31b12d3ec0923c809020887c957dc3b052aa74a408c1c80f617169b', E'1791184476', E'1', E'1788592476'), + (E'79', E'8', E'91f8df41003d1f7872ef25e1b223c97efc5cdb52f688c5846c1b298a78596878', E'1791185097', E'1', E'1788593097'), + (E'80', E'8', E'd6cf15948cc3994f9b1a91619706571842175a9eb7e39f951206b1ae962943f2', E'1791185134', E'1', E'1788593134'), + (E'30', E'8', E'd79a8269cc47eb22ad5329a79cc90da4142dca5a440eeaffecbeaa35e51cdcd6', E'1790931344', E'1', E'1788339344'), + (E'31', E'8', E'21dd83a1f7ae3a6dce0132aa7ad832b228fced7e1d0ff033102be59bb4fbb336', E'1790931884', E'1', E'1788339884'), + (E'32', E'8', E'f7f66ddf4bbe1c99cbe13e6448ca3dd3bbd5c6ee3d778ca31befe2a39f0eab00', E'1790932111', E'1', E'1788340111'), + (E'33', E'8', E'10e53f9937d779f27e9f62e561620cce92b7b94d4e45ef59060f1a587cbaf0da', E'1790932461', E'1', E'1788340461'), + (E'36', E'8', E'8ab626968d6ba5c85546c0154d1835622f21cba3fd5f70cb8962fcfa1f7c93f9', E'1790937983', E'1', E'1788345983'), + (E'37', E'8', E'8c819c48e7f17a248791390937c488a94fd7c4c7822907900a80f24ff086e394', E'1790938015', E'1', E'1788346015'), + (E'38', E'8', E'1e33cf9f6036efc21bbf28657577290087f8105d76c917f16f320d7cf25f8278', E'1790938336', E'1', E'1788346336'), + (E'39', E'8', E'77962d61a0c55d79de83e31e4efded50e565ca0d09c2f4554c1108e4aecef547', E'1790939501', E'1', E'1788347501'); +INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at", "revoked", "created_at") VALUES + (E'40', E'8', E'c54cf2aececa5fd06701dd6fe2f9ee159f1046a5dd8818eec682498d7ec21a33', E'1790939520', E'1', E'1788347520'), + (E'41', E'8', E'b02994a50c903252e72ea078dc052a535069fe0a814df4de30d4aec57392ed0e', E'1790940991', E'1', E'1788348991'), + (E'42', E'8', E'0994a166bfe5a917d66da3d28e853991a724bad5ba2a56d0589b9f6e93f8ea8c', E'1790941592', E'1', E'1788349592'), + (E'43', E'8', E'941b4c59118ba58915a02f0ae4250b9ba8a0816b62b9a57e2203374b8741b035', E'1790941855', E'1', E'1788349855'), + (E'44', E'8', E'0218725a02ee66cdd6e266d6c34103352602330ffb3979b9051cfbf5126c8f90', E'1790954612', E'1', E'1788362612'), + (E'45', E'8', E'd12c5961d9118c099fe75da3ba3aa193c22c2b343d8f4e43e51977cded7a087e', E'1790954621', E'1', E'1788362621'), + (E'46', E'8', E'21b760a00aa8c919f23c8125ef331cbe935ed5398a9c1c43baeb54ae3945be2f', E'1790954789', E'1', E'1788362789'), + (E'47', E'8', E'4f0dd38a4b1d466d7eb714811747caf8a063a956c4facb35bdaf7c02941ca0d9', E'1790957460', E'1', E'1788365460'), + (E'48', E'8', E'440c584569062a9af0ef693e700e1c94b0c463ed0f682117edf1caed03c90dfb', E'1791083234', E'1', E'1788491234'), + (E'49', E'8', E'29182427b04947c65f4dbc4b750171efaa381eea076d9dc683414fb79ef941ec', E'1791101818', E'1', E'1788509818'), + (E'50', E'8', E'0f63fe9607968891784916633771b78c4125088c1cb9f612ce5b728428167f08', E'1791101826', E'1', E'1788509826'), + (E'51', E'8', E'cf652c732ae41567dcea1877f12d2042c527476d97dc673221aeee328918e7d9', E'1791126011', E'1', E'1788534011'), + (E'52', E'8', E'dcba5a0e495748eb77177f2b9c842ea6b8848bb0a4d9f2f8ba3a6f7814f3899f', E'1791126023', E'1', E'1788534023'), + (E'111', E'8', E'ce67009fb0eac30226a49f5800e4935f7433024e231971e18832ac997151544a', E'1792061397', E'1', E'1789469397'), + (E'112', E'8', E'816b96381d886887a05c12ec5883371671a95a9fa3bf27a308097c6565a23473', E'1792062995', E'1', E'1789470995'), + (E'113', E'8', E'6b6a514221de8aa4c0fda45fa6c9d6eab318aeb3ae8f72c37eec1100e11ffb73', E'1792114905', E'1', E'1789522905'), + (E'114', E'8', E'68af59790a958f09d2fe65728fdae7cf01ea59b40b99f1cd69977a4eefce3919', E'1792415160', E'1', E'1789823160'), + (E'115', E'8', E'6cc47083bd5373028aba68c89c0050ab2d691ee65e20993d50e8002cc500a1ba', E'1792415169', E'1', E'1789823169'), + (E'119', E'8', E'1b965638435da6e428c9d3d3316ac77ea3dad74f3427eed61cfd1dcaed5c1856', E'1792666163', E'1', E'1790074163'), + (E'121', E'8', E'3a2936b9b9caff6e7c9a49b37ea0e58eddd170e6919a414789b6e0665011b51f', E'1792762709', E'1', E'1790170709'), + (E'122', E'8', E'33a51e39bf62ebb67c66bf6d1e5a70ebe1c5ac9eec7ec8773951ef1e9516bf6b', E'1792764003', E'1', E'1790172003'), + (E'123', E'8', E'0cdb2771c2cc28cb0bc31e9f11e154010abe7f711f1aa63d510ccb3a482e4324', E'1792764012', E'0', E'1790172012'); INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES (E'443', E'6', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'Look 1', E'1', E'1', E'1790050749', E'1790075943', E'[1,1,1,0,1,0,1,0,1,1,1,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,1,1,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,1,1,0,0]', E'0', E'0', E'0', E'0'), @@ -2631,14 +2645,10 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1310', E'6', E'street/1409b252c6f16b125b58a3d35180b506d8acec23.jpg', E'Look 30', E'17', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,1,1,0,1,0,1,1,0,0,1,1,1,1,1,0,0,1,0,0,1,1,0,0,0,0,1,0,0,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), (E'1311', E'6', E'street/b0689f6df162315cc055a36dd203a5928574e452.jpg', E'Look 31', E'18', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,1,0,1,1,1,1,1,1,1,0,0,0,1,1,1,0,1,0,1,1,1,0,0,0,1,0,0,1,1,1,0,0,1,0,1,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'), (E'1312', E'6', E'street/64100be46b4b98217c9e1312444c7b3a51d58bbb.jpg', E'Look 33', E'19', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,1,0,1,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,1,0,0,0,1,0,0,0,1,1,0,0,0,0,1,0,0,1,1,0,0,0,1,1,0,1,0,1,1,0,0,1,0,0,0,0]', E'0', E'0', E'0', E'0'), - (E'1313', E'6', E'street/52c8f45238ab3123cc862e5d2dd616c3d17b263c.jpg', E'Look 34', E'20', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'), - (E'1314', E'6', E'street/24423a4e3abcb2c3b509c3263ce00fcd82e29a91.jpg', E'Look 35', E'21', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0]', E'0', E'0', E'0', E'0'), - (E'1315', E'6', E'street/34b76ef6445d720dd761776af9dc77376ecff272.jpg', E'Look 36', E'22', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,0,0,0,1]', E'0', E'0', E'0', E'0'); -INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES (E'1316', E'6', E'street/09609a450e9320635999c06bd879d2d78440b0e9.jpg', E'Look 37', E'23', E'0', E'1790076009', E'1790076009', E'[1,1,0,0,1,1,1,0,1,1,0,0,1,1,1,1,1,1,0,0,1,1,0,0,1,1,0,1,1,1,0,1,1,1,0,1,1,1,0,0,1,0,0,0,1,0,1,0,0,1,1,0,1,0,1,0,0,0,1,0,0,0,1,0]', E'0', E'0', E'0', E'0'), (E'1317', E'6', E'street/ad5b8f665970b4b83fcaa0cdd23c3cf032afb52e.jpg', E'Look 38', E'24', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,1,0,0,1,1,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), - (E'1318', E'6', E'street/c8c0107e64bd5394cba9cf57f350090ddd4e4997.jpg', E'Look 39', E'25', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,1,1,0,0,1,0,1,0,0,0]', E'0', E'0', E'0', E'0'), - (E'1319', E'6', E'street/945e3d8178257ddf289cd2f18b52473c179433c1.jpg', E'Look 40', E'26', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1]', E'0', E'0', E'0', E'0'), + (E'1319', E'6', E'street/945e3d8178257ddf289cd2f18b52473c179433c1.jpg', E'Look 40', E'26', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1]', E'0', E'0', E'0', E'0'); +INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES (E'1320', E'6', E'street/c404d4e581065dbf5bff9ad6ad0788dc1b2898c9.jpg', E'Look 41', E'27', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,0,1,1,0,0,0,1,1,0,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,0,1,1,0]', E'0', E'0', E'0', E'0'), (E'1321', E'6', E'street/1a65cf85c9d26f9a284b639d75b2adcb72f1e110.jpg', E'Look 42', E'28', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,0,0,0,1,0,1,0,1,1,1,0,0,1,0,0,1,0,1,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,1,1,0,0,1,1,0,0,1,0,1,0,0,1,0,0,0,0]', E'0', E'0', E'0', E'0'), (E'1322', E'6', E'street/8469a3384ad87f1631551bd5dd446a9626b49434.jpg', E'Look 43', E'29', E'0', E'1790076009', E'1790076009', E'[0,1,1,1,0,1,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,1,0,1,1,1,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), @@ -2646,6 +2656,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1324', E'6', E'street/7f1902f48c3f801ee94de5d2088ea9647e543917.jpg', E'Look 45', E'31', E'0', E'1790076009', E'1790076009', E'[0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,1,0,0,1,0,0,0,0,1,0,0,0,1,1,0,1,1,0,1,1,1,0,1,0,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'), (E'1325', E'6', E'street/5cb18e2bc75e1be3b27b20eb12752264e90ef700.jpg', E'Look 46', E'32', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,0,0,1,1,1,0,0,0,1,0,1,1,1,0,1,1,0,0,0,1,1,1,1,1,0,0,0,1,0,0,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,1,1,0,0]', E'0', E'0', E'0', E'0'), (E'1326', E'6', E'street/ceed2936f9dccd5f9dd7ff4eafd2738f07b579c6.jpg', E'Look 47', E'33', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,0,0,1,1,0,0,0,1,0,1,1,1,1,0,0,1,0,0,1,1,1,0,0,0,1,0,1,0,1,1,1,0,1,1,0,0,1,1,1,1,0,1,0,1,1,0,1,1,0,1,0,0,1,0,1]', E'0', E'0', E'0', E'0'), + (E'1313', E'6', E'street/52c8f45238ab3123cc862e5d2dd616c3d17b263c.jpg', E'Look 34', E'20', E'0', E'1790076009', E'1790266014', E'[0,1,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'1', E'1312'), + (E'1314', E'6', E'street/24423a4e3abcb2c3b509c3263ce00fcd82e29a91.jpg', E'Look 35', E'21', E'1', E'1790076009', E'1790268289', E'[1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0]', E'0', E'0', E'0', E'0'), + (E'1315', E'6', E'street/34b76ef6445d720dd761776af9dc77376ecff272.jpg', E'Look 36', E'22', E'1', E'1790076009', E'1790268299', E'[0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,0,0,0,1]', E'0', E'0', E'0', E'0'), (E'1327', E'6', E'street/5ee5632ae55908eafe20f81be70398b0072e4e67.jpg', E'Look 48', E'34', E'0', E'1790076009', E'1790076009', E'[1,1,0,0,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,1,1,1,1,0,0,1,0,1,0,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'), (E'1328', E'6', E'street/c4c2eb5f2e5005a2cba8dfdb286042d6bb472104.jpg', E'Look 49', E'35', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,1,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,0,1,0,1,1,1,1,0,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'), (E'1329', E'6', E'street/39e218c3f927a49d7d4973946f55077ef7a0e5e4.jpg', E'Look 50', E'36', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,0,1,0,0,0,0,1,0,1,0,1,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,0,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'), @@ -2734,9 +2747,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1412', E'6', E'street/604f4b1723da9652e1d96f94318507baf9bca0b6.jpg', E'Look 133', E'119', E'0', E'1790076009', E'1790076009', E'[0,1,1,1,0,1,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0,1,0,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'), (E'1413', E'6', E'street/7d7b5efd894c32ee7ccd769fc23644adb876ab84.jpg', E'Look 134', E'120', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,1,0,0,0,0,1,0,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,1,0,1,0,0,0,0,1,1,1,1,0,1,0,0,1,1,1,0,0,1,1,1]', E'0', E'0', E'0', E'0'), (E'1414', E'6', E'street/a73e82297883c46a82b51a0a44055666dc0f15f0.jpg', E'Look 135', E'121', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,0,0,1,0,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'), - (E'1415', E'6', E'street/209ac6a167f9f24308914c8abdaa80976269b2e1.jpg', E'Look 136', E'122', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'0', E'0'); + (E'1415', E'6', E'street/209ac6a167f9f24308914c8abdaa80976269b2e1.jpg', E'Look 136', E'122', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'0', E'0'), + (E'1416', E'6', E'street/a8cf88415fbdb6eff5efeba173ddf46db6ff7341.jpg', E'Look 137', E'123', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,1,0,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'); INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES - (E'1416', E'6', E'street/a8cf88415fbdb6eff5efeba173ddf46db6ff7341.jpg', E'Look 137', E'123', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,1,0,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), (E'1417', E'6', E'street/4ed4b384433acaa514d16120d6d8a06f8047f63e.jpg', E'Look 138', E'124', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,0,1,1,1,0,1,1,1,0,0,0,1,0,1,1,1,1,0,0,1,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,0,0,1,0,1,1,1,0,0,1,0]', E'0', E'0', E'0', E'0'), (E'1418', E'6', E'street/210ca1c7e99e4ed2c6af9205118bcd9d9a4e286e.jpg', E'Look 139', E'125', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,1,1,0,0,0,1,1,0,0,1,0,1,1,1,1,0,0,1,0,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'), (E'1419', E'6', E'street/4d9230d8797eef47931fe570ccf06860039bf21a.jpg', E'Look 140', E'126', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,1,0,1,0,1,0,1,1,0,0,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,1,0,0,1,0,0,0,1,1,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'), @@ -2835,9 +2848,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1512', E'6', E'street/3c36931964afaa0b13563ac2d5d6f1a00bf31cff.jpg', E'Look 233', E'219', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,1,0,1,0,1,0,0,1,0,1,0,0,0,0,0,0,1,1,0,1,0,0,0,0,1,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'), (E'1513', E'6', E'street/e48204d9d7612eb1a4cde6ac706ec457053bdf26.jpg', E'Look 234', E'220', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,1,0,0,1,1,1,0,0,1,0,1,1,1,0,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), (E'1514', E'6', E'street/a0af9f33fb8def1c7555eb5a70c7ad3f1608f840.jpg', E'Look 235', E'221', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1,1,0,0,1,1,0,1,1,1,1,0,1,1,1,1,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'), - (E'1515', E'6', E'street/bfe38b19823bb0a60646c60ec553dc034eec2d07.jpg', E'Look 236', E'222', E'0', E'1790076009', E'1790076009', E'[1,1,1,1,0,0,0,0,1,1,1,1,0,1,0,0,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'); + (E'1515', E'6', E'street/bfe38b19823bb0a60646c60ec553dc034eec2d07.jpg', E'Look 236', E'222', E'0', E'1790076009', E'1790076009', E'[1,1,1,1,0,0,0,0,1,1,1,1,0,1,0,0,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'), + (E'1516', E'6', E'street/5f0d4db8cf460df9487bec7f3b72b689e1c48ab3.jpg', E'Look 237', E'223', E'0', E'1790076009', E'1790076009', E'[1,1,0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,1,1,1,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,1,1,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,1]', E'0', E'0', E'0', E'0'); INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES - (E'1516', E'6', E'street/5f0d4db8cf460df9487bec7f3b72b689e1c48ab3.jpg', E'Look 237', E'223', E'0', E'1790076009', E'1790076009', E'[1,1,0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,1,1,1,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,1,1,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,1]', E'0', E'0', E'0', E'0'), (E'1517', E'6', E'street/167cddda5d2c780a94d8136a142b1c4e542c3c2b.jpg', E'Look 238', E'224', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,0,0,0,1,1,1,1,0,0,0,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,0]', E'0', E'0', E'0', E'0'), (E'1518', E'6', E'street/9d4164f07e4339e99dbbf9fe9dc3eb06556016df.jpg', E'Look 239', E'225', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,0,0,1,1,0,1,0,0,1,0,1,1,0,1,0,0,0,0,0,1,1,0,0,0,0,1,0,1,1,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,0,0]', E'0', E'0', E'0', E'0'), (E'1519', E'6', E'street/023955dc7e06e660231ec664f97cb39fe9f439c3.jpg', E'Look 240', E'226', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,0,0,0,0,0]', E'0', E'0', E'0', E'0'), @@ -2936,9 +2949,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1612', E'6', E'street/217bfe6290196fc69314483198be5031b467583c.jpg', E'Look 333', E'319', E'0', E'1790076009', E'1790076009', E'[1,0,0,0,1,0,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,1,0,0,1,0,0,1,1,0,1,1,1,0,0,1,0,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,0,0,1,1,1,1,0,0,0,1,0,1]', E'0', E'0', E'0', E'0'), (E'1613', E'6', E'street/26ec653a2ebc5c9d6726a5b2144452d8047b7ad5.jpg', E'Look 334', E'320', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,1,1,0,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,0,1,1,0,1,0,1,1,0,0,1,1,0,1,1,1,0,0,0,1,0,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'), (E'1614', E'6', E'street/3ead248b9ac8f09644f242b5454d425a10bd2907.jpg', E'Look 335', E'321', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,0,1,1,1,0,1,1,1,0,1,0,1,0,1,0,1,1,1,1,0,0,0,0,0,1,1,1,0,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,0,0,1,1,1,0,1,1]', E'0', E'0', E'0', E'0'), - (E'1615', E'6', E'street/f1f338f6db7d836bb5fd8f0af448b4440e0458cb.jpg', E'Look 336', E'322', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'); + (E'1615', E'6', E'street/f1f338f6db7d836bb5fd8f0af448b4440e0458cb.jpg', E'Look 336', E'322', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'), + (E'1616', E'6', E'street/1bc5d0482eeba7de40d06eb69dcf3db2670c1518.jpg', E'Look 337', E'323', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,1,0,1,1,1,0,0,1,1,1,1,1,0,0,0,1,0,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1]', E'0', E'0', E'0', E'0'); INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES - (E'1616', E'6', E'street/1bc5d0482eeba7de40d06eb69dcf3db2670c1518.jpg', E'Look 337', E'323', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,1,0,1,1,1,0,0,1,1,1,1,1,0,0,0,1,0,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1]', E'0', E'0', E'0', E'0'), (E'1617', E'6', E'street/39cd988b752e66645bc1108220edd54883cfaf1d.jpg', E'Look 338', E'324', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,0,0,1,0,1,0,0,0,1,0,1,1,0,0,0,1,1,0,0,1,0,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,1,1,1,1]', E'0', E'0', E'0', E'0'), (E'1618', E'6', E'street/c2fb7bbe6fd55429e4e6882eaf227b2708799102.jpg', E'Look 339', E'325', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,1,1,0,0,1,1,0,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,1,0,0,0,1,0,0,0,1,0,0,1,1,1,0,0,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1]', E'0', E'0', E'0', E'0'), (E'1619', E'6', E'street/9698c65f27d4f691e49c3eeb0e15f188dd268312.jpg', E'Look 340', E'326', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,0,1,0,1,1,0,0,1,0,1,1,1,0,0,1,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,0,1,0,1,1,0,1,1,0,1,0,1,1,1,1,0,0,1,1,1,0,1,1,1,1,1,0,0]', E'0', E'0', E'0', E'0'), @@ -3037,14 +3050,15 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'1712', E'6', E'street/414b2581b7e274400ff7bd3d779795d053d10176.jpg', E'Look 20', E'419', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,0,1,0,1,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'1', E'1304'), (E'1713', E'6', E'street/a409cfbd15e235a57d55edaf923cac3957c2f60a.jpg', E'Look 21', E'420', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,1,0,1,1,0,0,1,0,1,1,1,1,0,1,1,0,1,1,1,0,1,1,1,0,1,0,0,0,1,0,1,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,1,1,0,0,1,1,0,0]', E'0', E'0', E'1', E'1304'), (E'1714', E'6', E'street/d5a02679fc9f5187d156403590cdf25488c90273.jpg', E'Look 24', E'421', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,1,1,0,1,1,0,1,0,1,1,0,1,0,0,1,0,0,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0]', E'0', E'0', E'1', E'1307'), - (E'1715', E'6', E'street/88fa4a79aab8087ec11086be4257e52bd5263a86.jpg', E'Look 25', E'422', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,1,0,0,1,0,1,0,0,1,0,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,1,0,0,1,1,0]', E'0', E'0', E'1', E'1307'); + (E'1715', E'6', E'street/88fa4a79aab8087ec11086be4257e52bd5263a86.jpg', E'Look 25', E'422', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,1,0,0,1,0,1,0,0,1,0,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,1,0,0,1,1,0]', E'0', E'0', E'1', E'1307'), + (E'1716', E'6', E'street/6539e151e4d152bea9f5f0ca944a2f10c915ba40.jpg', E'Look 28', E'423', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,1,0,0,1,1,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'1', E'1308'); INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES - (E'1716', E'6', E'street/6539e151e4d152bea9f5f0ca944a2f10c915ba40.jpg', E'Look 28', E'423', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,1,0,0,1,1,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'1', E'1308'), (E'1717', E'6', E'street/6c5aa016a75e33ca8cbfff66dbc91d1736294fd2.jpg', E'Look 32', E'424', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,1,0,1,0,0,1,1,0,1,0,1,1,1,0,0,1,1,1,1,1,1]', E'0', E'0', E'1', E'1311'), + (E'1885', E'146', E'street/7e3e6bc3312d66deb524e3430eac830245b3644b.jpg', E'Look 3', E'3', E'0', E'1789914056', E'1790226796', E'[0,1,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,1,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,0,1,1,0,0,0,1,1,1,0,1]', E'0', E'0', E'1', E'1884'), + (E'1886', E'146', E'street/868c6c56a10d076af39e8b47d9e49f0bd64bff0d.jpg', E'Look 4', E'4', E'0', E'1789914056', E'1790226796', E'[0,0,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,1,0,1,0,1,0,0,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0]', E'0', E'0', E'1', E'1884'), + (E'1318', E'6', E'street/c8c0107e64bd5394cba9cf57f350090ddd4e4997.jpg', E'Look 39', E'25', E'0', E'1790076009', E'1790226860', E'[0,0,1,0,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,1,1,0,0,1,0,1,0,0,0]', E'0', E'0', E'1', E'1317'), (E'1883', E'146', E'street/a9c1b744df1a9aaf7067d2e88835aead98fb4003.jpg', E'Look 1', E'1', E'0', E'1789914056', E'1789914056', E'[0,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,1,0,1,0,1,1,0,0,0,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0]', E'0', E'0', E'0', E'0'), (E'1884', E'146', E'street/30f5ea0b1609bf24084cb74b456189ad010d4841.jpg', E'Look 2', E'2', E'0', E'1789914056', E'1789914056', E'[1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0,0,1,0,0,1,1,1,0,0,1,0,1,0,1,1,1,0,1,0,1,1,0,0,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), - (E'1885', E'146', E'street/7e3e6bc3312d66deb524e3430eac830245b3644b.jpg', E'Look 3', E'3', E'0', E'1789914056', E'1789914056', E'[0,1,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,1,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,0,1,1,0,0,0,1,1,1,0,1]', E'0', E'0', E'0', E'0'), - (E'1886', E'146', E'street/868c6c56a10d076af39e8b47d9e49f0bd64bff0d.jpg', E'Look 4', E'4', E'0', E'1789914056', E'1789914056', E'[0,0,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,1,0,1,0,1,0,0,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0]', E'0', E'0', E'0', E'0'), (E'1887', E'146', E'street/aabb887c88460c18dca25bffc99ef48419607d71.jpg', E'Look 5', E'5', E'0', E'1789914056', E'1789914056', E'[0,0,0,1,1,0,1,0,0,0,0,0,1,0,0,1,0,0,1,0,1,0,1,1,0,0,1,0,0,1,1,1,0,0,0,0,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,1,0]', E'0', E'0', E'0', E'0'), (E'1888', E'146', E'street/7481ef54db0777e8e0176a00caea9518b3956f36.jpg', E'Look 6', E'6', E'0', E'1789914056', E'1789914056', E'[1,0,0,0,0,1,1,1,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,0,1,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,1,1,1,0,0,1,0,0,1,1,1,0,1,1,0]', E'0', E'0', E'0', E'0'), (E'1889', E'146', E'street/7c3bd0ca4a588ac98255868d2f6b2e55683e2257.jpg', E'Look 7', E'7', E'0', E'1789914056', E'1789914056', E'[1,0,1,0,0,1,0,1,0,1,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,1,1,0,1,0,0,0,1,1,0,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'), @@ -3674,10 +3688,10 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort (E'2507', E'149', E'street/0a32a3b64a43db4a6655a3ebcf5d65530e2ad904.jpg', E'Look 59', E'59', E'0', E'1789917788', E'1789917788', E'[1,1,1,1,1,0,0,0,1,1,0,1,1,1,0,0,1,0,0,0,0,1,1,0,1,0,1,0,0,0,1,0,1,1,1,1,0,0,1,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,0,1,1,1,0,1,0,0,0]', E'0', E'0', E'0', E'0'); INSERT INTO public."street_snaps" ("id", "title", "year", "city", "cover", "image_count", "is_deleted", "created_at", "updated_at", "source", "source_url", "status", "job_id", "reviewer", "reject_reason") VALUES - (E'6', E'London Street Style Spring 2027 Day 3', E'2027', E'London', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'424', E'0', E'1790006883', E'1790076009', E'', E'', E'published', E'0', E'', E''), (E'146', E'London Street Style Spring 2027 Day 2', E'2027', E'London', E'street/a9c1b744df1a9aaf7067d2e88835aead98fb4003.jpg', E'329', E'0', E'1789914056', E'1789914056', E'', E'', E'pending', E'100', E'', E''), (E'147', E'The Best Street Style of Milan Fashion Week Fall 2026', E'2026', E'Milan', E'street/751fbc91e3e5fba3c4ca3420928cc829cbf1ad20.jpg', E'156', E'0', E'1789914679', E'1789914679', E'', E'', E'pending', E'101', E'', E''), (E'148', E'The Best Street Style of London Fashion Week Fall 2026', E'2026', E'London', E'street/1bdd3d1c5256a9601b24373934cad5d60661f301.jpg', E'81', E'0', E'1789915003', E'1789915003', E'', E'', E'pending', E'102', E'', E''), + (E'6', E'London Street Style Spring 2027 Day 3', E'2027', E'London', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'422', E'0', E'1790006883', E'1790076009', E'', E'', E'published', E'0', E'', E''), (E'149', E'The Best Street Style From Copenhagen Fashion Week', E'0', E'Copenhagen', E'street/697a793538c53668ff288073b8e361c73578478f.jpg', E'59', E'0', E'1789917788', E'1790144662', E'', E'', E'published', E'104', E'admin', E''); INSERT INTO public."users" ("id", "created_at", "updated_at", "username", "email", "password_hash", "is_deleted", "tier") VALUES diff --git a/docs/superpowers/plans/2026-09-22-single-table-publish.md b/docs/superpowers/plans/2026-09-22-single-table-publish.md new file mode 100644 index 0000000..b4b02a9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-single-table-publish.md @@ -0,0 +1,2341 @@ +# 单表发布模型(取消草稿表)实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 把「草稿表 → 人工审核 → 晋升正式表」的两套表结构合并为单表,用 `status` 承载审核态,并让公开读只走只读视图。 + +**架构:** `brand_runways` / `street_snaps` 各加 `status`、`job_id`、`reviewer`、`reject_reason` 四列(默认 `pending`,fail-closed)。爬虫直接写入正式表(`status=pending`);审核通过只是把 `status` 改成 `published`(**不再重建图片**,图片行 id 终身不变);公开查询一律读 `public_*` 只读视图,视图内固化「已发布且未删除」,漏写过滤在物理上不可能。 + +**技术栈:** Go 1.x + Gin + GORM + PostgreSQL(+ pgvector);后台服务端渲染(`go:embed` 的 `.html` 模板,见 `internal/handler/templates.go`)。 + +**规格:** `docs/superpowers/specs/2026-09-22-single-table-publish-design.md`(执行者必须两份都读) + +## 全局约束 + +- **状态取值只有三个**:`pending` / `published` / `rejected`;常量定义在 `internal/model/content_status.go`,用 `model.StatusPending` 等引用,禁止散落字符串字面量。 +- **默认值 `pending`**:新增记录若漏赋值状态,结果必须是「不可见」而非「已发布」。 +- **公开读只读视图**:`public_brand_runways`、`public_brand_runway_images`、`public_street_snaps`、`public_street_snap_images`。公开仓储方法禁止直接读基表。 +- **后台读写基表**:`ListAdmin`、`GetForEdit`、所有 `Update*` / `Set*` / `Delete*` 一律针对基表。 +- **零 JS 交互不变**:后台表单仍是同步 POST,成功返回 302。 +- **不改公开 API 响应结构**:`images` / `groups` / 列表字段保持不变。 +- **命名**:新代码用 `Record*`(不再有 `Draft*` 概念);模板数据键 `Record` / `Records`。 +- **数据库结构以 `db_dump.sql` 为准**,`db/migrations/*.sql` 是手工执行的 DDL;迁移文件必须**幂等**(`IF NOT EXISTS` / `DROP VIEW IF EXISTS`)。 +- **集成测试**用 `//go:build integration` 隔离,通过 `testDB(t)` 连真实库(见 `internal/repository/dedup_integration_test.go:34`)。 +- **提交粒度**:每个任务一次 commit,消息用中文 Conventional Commits(`feat` / `refactor` / `fix` / `chore`)。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|---|---| +| 创建 `internal/model/content_status.go` | 三个状态常量,全局唯一来源 | +| 创建 `internal/repository/public_views.go` | 公开视图表名常量(含 `AS brand_runways` 别名技巧) | +| 创建 `db/migrations/2026-09-22-01-single-table-publish.sql` | 加列 + 状态索引 + 建 4 个视图(幂等,可重复执行) | +| 创建 `db/migrations/2026-09-22-01b-publish-existing-rows.sql` | **一次性**:把存量正式行置为 published(必须只执行一次) | +| 创建 `db/migrations/2026-09-22-05-drop-draft-tables.sql` | 删 4 张草稿表 | +| 创建 `scripts/migrate_single_table/main.go` | 历史草稿搬迁到正式表(含校验、`-dry-run`) | +| 创建 `internal/repository/publish_status_integration_test.go` | 视图屏蔽未发布内容 | +| 创建 `internal/repository/public_read_integration_test.go` | 公开读走视图、后台仍读基表 | +| 创建 `internal/repository/ingest_single_table_integration_test.go` | 入库三分支(新建 / 放弃 / 复用驳回) | +| 修改 `internal/model/runway.go` | `BrandRunway` 加 4 列;`RunwayRow` 加 `Status` | +| 修改 `internal/model/street_snap.go` | `StreetSnap` 加 4 列 | +| 修改 `internal/repository/ingest_repository.go` | 入库直写正式表(6 个草稿方法 → 6 个正式表方法) | +| 修改 `internal/repository/review_repository.go` | 审核改基表;删除全部晋升/聚合代码 | +| 修改 `internal/repository/article_repository.go` | 公开方法读视图;`ListImages` 拆出基表版本 | +| 修改 `internal/repository/street_snap_repository.go` | 公开方法读视图;`ListImages` 拆出基表版本 | +| 修改 `internal/repository/brand_repository.go` | `hasArticlesSubQuery` / `FeaturedIDs` / `PopularWithCover` 读视图 | +| 修改 `internal/service/ingest_service.go` | 入库三分支;去重比对表单表化 | +| 修改 `internal/service/review_service.go` | 改名 + `Approve` 只改状态 | +| 修改 `internal/handler/backstage_handler.go` | 改名 + 模板数据键 `Record`/`Records` | +| 修改 `internal/handler/templates/pages/review-list.html` | `.Records`、tab 值 `published` | +| 修改 `internal/handler/templates/pages/review-detail.html` | `.Record` | +| 删除 `internal/model/runway_draft.go`、`internal/model/street_snap_draft.go` | 草稿模型 | +| 修改 `internal/router/backstage_test.go` | `fakeReview` 跟随改名 | + +--- + +## 任务 1:状态地基(模型字段 + 迁移 + 公开视图) + +**文件:** +- 创建:`internal/model/content_status.go` +- 修改:`internal/model/runway.go:3-25`(`BrandRunway`)、`internal/model/runway.go:33-49`(`RunwayRow`) +- 修改:`internal/model/street_snap.go:9-19`(`StreetSnap`) +- 创建:`db/migrations/2026-09-22-01-single-table-publish.sql` +- 创建:`db/migrations/2026-09-22-01b-publish-existing-rows.sql` +- 测试:`internal/repository/publish_status_integration_test.go` + +- [ ] **步骤 1:写状态常量** + +```go +package model + +// 内容发布状态(单表发布模型)。 +// +// 历史上「爬虫草稿」与「正式记录」是两张表,审核 = 从草稿晋升到正式表。 +// 现在合并为一张表,审核态由本字段承载: +// - pending:刚入库,等待审核;公开视图不可见 +// - published:已通过,对外可见 +// - rejected:已驳回,留在库中备查;公开视图不可见 +// +// 新增记录默认 pending(见各自模型的 gorm default),漏赋值的结果是「不可见」, +// 即 fail-closed,而不是意外对外发布。 +const ( + StatusPending = "pending" + StatusPublished = "published" + StatusRejected = "rejected" +) +``` + +- [ ] **步骤 2:给 `BrandRunway` 加 4 列** + +在 `internal/model/runway.go` 的 `BrandRunway` 结构体末尾(`SeasonCode` 字段之后)追加: + +```go + // 审核态与溯源(单表发布模型;列由 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 账号) + RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"` +``` + +并在 `RunwayRow` 的 `SeasonCode` 之后追加(后台列表展示状态用): + +```go + Status string `gorm:"column:status"` +``` + +- [ ] **步骤 3:给 `StreetSnap` 加 4 列** + +在 `internal/model/street_snap.go` 的 `StreetSnap` 结构体末尾(`UpdatedAt` 之前)追加同样的 4 个字段: + +```go + // 审核态与溯源(单表发布模型;列由 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"` + RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"` +``` + +- [ ] **步骤 4:写失败的集成测试** + +创建 `internal/repository/publish_status_integration_test.go`: + +```go +//go:build integration + +// 集成测试:公开视图必须屏蔽未发布内容 —— 这是「单表发布模型」的核心保证。 +// +// 运行(库需先就绪,见 internal/repository/dedup_integration_test.go 顶部说明): +// +// go test -tags integration ./internal/repository/ -run TestPublicViews -v +package repository + +import ( + "context" + "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) + rw := model.BrandRunway{TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now} + 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) + } +} +``` + +- [ ] **步骤 5:运行测试,确认失败** + +运行:`go test -tags integration ./internal/repository/ -run TestPublicViews -v` +预期:FAIL —— `读取迁移失败 ../../db/migrations/2026-09-22-01-single-table-publish.sql: no such file or directory` + +- [ ] **步骤 6:写迁移文件** + +创建 `db/migrations/2026-09-22-01-single-table-publish.sql`: + +```sql +-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。 +-- +-- 本文件只做三件事:加列 / 存量行置已发布 / 建视图。幂等,可重复执行。 +-- 数据搬迁见 scripts/migrate_single_table;删草稿表见 2026-09-22-05-drop-draft-tables.sql。 + +-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed): +-- 任何漏赋值的行默认不可见,而不是意外对外发布。 +ALTER TABLE brand_runways + ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT ''; + +ALTER TABLE street_snaps + ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT ''; + +-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录 +-- 2026-09-22-01b-publish-existing-rows.sql。 +-- 为什么必须拆开:数据搬迁(scripts/migrate_single_table)会把 pending 草稿连原 created_at +-- 一起搬进正式表;若本文件含那条 UPDATE 且被重复执行(测试每次都会跑), +-- 这些待审内容会被误刷成已发布 —— 恰好是本设计要防的泄漏。 + +-- 3) 状态索引:审核列表与公开视图都按 status 过滤。 +CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status); +CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status); + +-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。 +-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。 +DROP VIEW IF EXISTS public_brand_runway_images; +DROP VIEW IF EXISTS public_brand_runways; +DROP VIEW IF EXISTS public_street_snap_images; +DROP VIEW IF EXISTS public_street_snaps; + +CREATE VIEW public_brand_runways AS + SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0; + +CREATE VIEW public_brand_runway_images AS + SELECT i.* FROM brand_runway_images i + JOIN brand_runways r ON r.id = i.runway_id + WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0; + +CREATE VIEW public_street_snaps AS + SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0; + +CREATE VIEW public_street_snap_images AS + SELECT i.* FROM street_snap_images i + JOIN street_snaps s ON s.id = i.snap_id + WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0; +``` + +- [ ] **步骤 7:运行测试,确认通过** + +运行:`go test -tags integration ./internal/repository/ -run TestPublicViews -v` +预期:PASS(两个用例均通过) + +- [ ] **步骤 8:编译与非集成测试无回归** + +运行:`go build ./... && go test -count=1 ./...` +预期:编译通过、全绿 + +- [ ] **步骤 9:写一次性数据迁移文件(不参与自动化测试)** + +创建 `db/migrations/2026-09-22-01b-publish-existing-rows.sql`: + +```sql +-- 一次性数据迁移:把存量正式行视为已发布。 +-- +-- ⚠️ 只执行一次;必须在部署本计划的新代码之前、且在 scripts/migrate_single_table 之前执行。 +-- 为什么单独成文件而不放进可重复执行的 2026-09-22-01: +-- 数据搬迁会把 pending 草稿连同旧的 created_at 一起搬进正式表; +-- 若这条 UPDATE 可被重复执行(集成测试每次都会跑 01 号文件), +-- 这些待审内容会被误刷成 published —— 恰好是本设计要防的泄漏。 +UPDATE brand_runways SET status = 'published' WHERE status = 'pending'; +UPDATE street_snaps SET status = 'published' WHERE status = 'pending'; +``` + +- [ ] **步骤 10:Commit** + +```bash +git add internal/model/content_status.go internal/model/runway.go internal/model/street_snap.go \ + db/migrations/2026-09-22-01-single-table-publish.sql \ + db/migrations/2026-09-22-01b-publish-existing-rows.sql \ + internal/repository/publish_status_integration_test.go +git commit -m "feat(publish): 正式表加发布状态与公开只读视图" +``` + +> **部署提示**(不在自动化步骤内): +> 1. 必须**手工执行一次** 01 号结构迁移,再一次 01b 数据迁移。漏执行 01b 的症状是前台整站内容为空(存量行仍是 `pending`)。 +> 2. ⚠️ 01/01b 与任务 1–6 的新代码**必须同一发布窗口上线**。执行 01b 之后、任务 4 上线之前, +> 旧审核路径「通过」写入的正式行会因列默认值落到 `pending`,即审核通过的内容对前台不可见。 +> 不要把结构迁移单独提前上线。 + +--- + +## 任务 2:公开读切到视图 + +后台仍读基表,公开读改读视图。`ListImages` 被后台 `GetForEdit` 复用,需要拆出基表版本,否则后台编辑待审内容时会看不到图。 + +**文件:** +- 创建:`internal/repository/public_views.go` +- 修改:`internal/repository/article_repository.go:140-167`(`List`)、`:199-214`(`FindByID`)、`:216-223`(`ListImages`)、`:225-244`(`ImagesByRunwayIDs`)、`:260-273`(`GetForEdit`) +- 修改:`internal/repository/street_snap_repository.go:78-103`(`List`)、`:105-117`(`FindByID`)、`:119-126`(`ListImages`)、`:128-147`(`ImagesBySnapIDs`)、`:151-161`(`Popular`)、`:173-186`(`GetForEdit`) +- 修改:`internal/repository/brand_repository.go:76-80`(`hasArticlesSubQuery`)、`:151-177`(`FeaturedIDs`)、`:268-289`(`PopularWithCover` 的原始 SQL) +- 修改:`internal/service/street_snap_service.go`(新增 `AdminList` + 接口声明) +- 修改:`internal/handler/backstage_handler.go:254`(街拍后台列表改调 `AdminList`) +- 测试:`internal/repository/public_read_integration_test.go` + +- [ ] **步骤 1:写视图表名常量** + +创建 `internal/repository/public_views.go`: + +```go +package repository + +// 公开只读视图的表名。 +// +// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql), +// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。 +// +// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用 +// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。 +// 后台读基表时**不要**使用这些常量。 +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" +) +``` + +⚠️ 两个别名(`AS brand_runways` / `AS street_snaps`)都是必需的,不是可选优化: +`article_repository` 用 `brand_runways.xxx` 限定列,`street_snap_repository` 的 `snapOrderBy` / `snapFilterScope` +用 `street_snaps.xxx` 限定列。视图若无同名别名,会直接报 +`ERROR: missing FROM-clause entry for table "..." (SQLSTATE 42P01)`。 +起别名后这些既有 SQL **一行都不用改**,也不必去动后台共享的 `snapOrderBy`。 + +- [ ] **步骤 2:写失败的集成测试** + +创建 `internal/repository/public_read_integration_test.go`: + +```go +//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)) + } + + // 置为已发布后,公开侧才可见。 + 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) + } + + 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) + } +} +``` + +- [ ] **步骤 3:运行测试,确认失败** + +运行:`go test -tags integration ./internal/repository/ -run TestPublicRead -v` +预期:FAIL —— `pending 走秀详情应 ErrNotFound,实际 `(公开方法仍在读基表,能读到 pending) + +- [ ] **步骤 4:`article_repository.go` 公开方法改读视图** + +四处把 `Model(&model.BrandRunway{})` 换成 `Table(publicRunways)`: + +- `List`(第 145 行与第 156 行各一处) +- `FindByID`(第 202 行) +- `ListAdmin`(第 175、186 行)**保持 `Model(&model.BrandRunway{})` 不变**(后台读基表) + +`ListImages` 与 `ImagesByRunwayIDs` 加 `Table(publicRunwayImages)`: + +```go +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). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + return imgs, err +} +``` + +`ImagesByRunwayIDs` 里 `.Select(...).Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids)` 加 `.Table(publicRunwayImages)`,并把 `is_deleted = 0` 从 WHERE 去掉(视图已含)。 + +`GetForEdit`(第 268 行)改调基表版本: + +```go + imgs, err := r.listImagesBase(ctx, strconv.FormatUint(uint64(id), 10)) +``` + +- [ ] **步骤 5:`street_snap_repository.go` 公开方法改读视图** + +- `List`:两处 `Model(&model.StreetSnap{})` → `Table(publicStreetSnaps)`。 +- `FindByID`:加 `Table(publicStreetSnaps)`,WHERE 保留 `id = ?`(去掉 `is_deleted = 0` 亦可,保留无害)。 +- `Popular`:加 `Table(publicStreetSnaps)`。 +- `ListImages` → `Table(publicStreetSnapImages)`,WHERE 去掉 `is_deleted = 0`。 +- `ImagesBySnapIDs` → `Table(publicStreetSnapImages)`,WHERE 去掉 `is_deleted = 0`。 +- 新增 `listImagesBase`(与 article 同构,`Where("snap_id = ? AND is_deleted = 0", snapID)`),`GetForEdit`(第 181 行)改调它。 + +`GetForEdit` 读记录本身用 `First(&snap)`(基表),**不动**。 + +- [ ] **步骤 5b:补上街拍的后台列表方法(否则后台看不到待审内容)** + +⚠️ 这一步是必需的。后台街拍列表(`internal/handler/backstage_handler.go:254`)调用的正是 `h.street.List(...)` +→ `streetSnapRepository.List`。步骤 5 把 `List` 改成读视图后,**后台街拍列表将只剩已发布内容**, +待审记录在后台「消失」,审核无从下手。走秀侧没有这个问题,因为它本来就有独立的 `ListAdmin` +(`internal/repository/article_repository.go:170`,读基表)。街拍侧缺这个对称方法。 + +1)在 `StreetSnapRepository` 接口里、`List` 之后新增声明: + +```go + // ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。 + ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) +``` + +2)实现(与 `List` 同构,只是 `Model(&model.StreetSnap{})` 读基表): + +```go +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{}). + 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). + Model(&model.StreetSnap{}). + 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 +} +``` + +3)`internal/service/street_snap_service.go` 新增 `AdminList`(与 `List` 同构,只把仓储调用换成 `ListAdmin`): + +```go +// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。 +func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) { + rows, total, err := s.repo.ListAdmin(ctx, q) + if err != nil { + return nil, 0, err + } + return s.toPublicSnaps(rows), total, nil +} +``` + +(`toPublicSnaps` 用 `List` 里现有的那个映射函数名;若实际叫别的名字,按现状沿用,不要新建重复映射。) + +并在 `StreetSnapService` 接口里加同签名声明。 + +4)`internal/handler/backstage_handler.go:254` 改为: + +```go + items, total, err := h.street.AdminList(c.Request.Context(), q) +``` + +- [ ] **步骤 6:`brand_repository.go` 改读视图** + +```go +// hasArticlesSubQuery 子查询:所有「至少有一篇已发布走秀档案」的品牌 id。 +// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。 +func (r *brandRepository) hasArticlesSubQuery() *gorm.DB { + return r.db.Table(publicRunways). + Select("DISTINCT brand_id") +} +``` + +`FeaturedIDs`:`Model(&model.BrandRunway{})` → `Table(publicRunways)`,并去掉 `Where("is_deleted = 0")`。 + +`PopularWithCover` 原始 SQL 里第 284 行 `FROM brand_runways` 改为 `FROM public_brand_runways brand_runways`。 + +- [ ] **步骤 7:运行测试,确认通过** + +运行:`go test -tags integration ./internal/repository/ -run TestPublicRead -v` +预期:PASS + +- [ ] **步骤 8:全量编译与非集成测试** + +运行:`go build ./... && go test -count=1 ./...` +预期:编译通过、全绿 + +- [ ] **步骤 9:Commit** + +```bash +git add internal/repository/public_views.go internal/repository/article_repository.go \ + internal/repository/street_snap_repository.go internal/repository/brand_repository.go \ + internal/repository/public_read_integration_test.go \ + internal/service/street_snap_service.go internal/handler/backstage_handler.go \ + internal/router/backstage_test.go +git commit -m "refactor(publish): 公开读改走只读视图,后台仍读基表" +``` + +> **执行中补做的后台基表路径(已落地,后续任务勿当缺陷「修掉」)** +> +> 审查发现同一条「公开查询被后台复用」的坑还有两处(与步骤 5b 同类),已一并拆开: +> `ArticleRepository.FindByIDBase` / `ListImagesBase`、`StreetSnapRepository.FindByIDBase` / `ListImagesBase`、 +> `ArticleService.DetailAdmin`、`StreetSnapService.DetailAdmin`、`BrandService.AdminList`、 +> `BrandRepository.hasArticlesSubQueryBase` 与 `brandFilterScope(..., admin bool)`。 +> 后台**详情页**与后台**品牌列表**已改走这些基表路径。 +> 原因:`FindByID` 被后台详情处理器复用(`backstage_handler.go:237` / `:290`), +> 而详情页是通往编辑页的唯一入口——切视图后待审内容在后台直接 404; +> `brandFilterScope` 同样同时服务公开与后台品牌列表。 +> 这两条都有断言锚定:`router/backstage_test.go` 断言后台详情页 200 且渲染待审标记; +> `TestBrandIndexSplitsPublicAndAdmin` 断言「只有待审走秀的品牌」不在公开索引、但在后台列表。 + +--- + +## 任务 3:入库直写正式表(含驳回行复用) + +**文件:** +- 修改:`internal/repository/ingest_repository.go:23-62`(接口)、`:242-330`(6 个草稿方法) +- 修改:`internal/service/ingest_service.go:232-328`(`processRunway`)、`:411-470`(`processStreet`)、`:600-626`(`dedupImage`)、`:339-409`(`fetchLookImages` 返回类型) +- 测试:`internal/repository/ingest_single_table_integration_test.go` +- 测试:`internal/service/ingest_service_test.go`(新增分支决策单测) + +- [ ] **步骤 1:写失败的集成测试** + +创建 `internal/repository/ingest_single_table_integration_test.go`: + +```go +//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, LookIndex: 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", "reject_reason": "图片缺失"}).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 != "" || rw.RejectReason != "" { + t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason) + } + + 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("旧图应被软删") + } +} +``` + +同时创建 `internal/service/ingest_service_test.go`,覆盖三分支的**决策逻辑**(不需要数据库,因为它是纯函数): + +```go +package service + +import ( + "testing" + + "fashionapi/internal/model" +) + +// 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) + } + }) + } +} +``` + +- [ ] **步骤 2:运行测试,确认失败** + +运行:`go test -tags integration ./internal/repository/ -run TestIngest -v; go test -count=1 ./internal/service/ -run TestDecideIngestAction -v` +预期:仓库侧 FAIL —— 编译错误 `repo.RunwayEntityState undefined`;service 侧 FAIL —— `undefined: decideIngestAction` + +- [ ] **步骤 3:替换 `IngestRepository` 接口中的草稿方法** + +在 `internal/repository/ingest_repository.go` 中,删除这 6 个声明:`RunwayIDByEntity`、`CreateRunwayDraft`、`CreateRunwayDraftImages`、`StreetSnapIDByEntity`、`CreateStreetSnapDraft`、`CreateStreetSnapDraftImages`,替换为: + +```go + // RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。 + // worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。 + RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found 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 + // StreetSnapEntityState 按实体键(city + year)查正式表既有行,返回其状态。 + StreetSnapEntityState(ctx context.Context, city string, year uint16) (id uint32, status string, found 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 +``` + +同时删除这 6 个方法的实现体(第 242-330 行区间),新增下面步骤 4 的实现。 + +- [ ] **步骤 4:实现 6 个方法** + +在 `ingest_repository.go` 中原草稿方法位置写入: + +```go +// ---- 入库直写正式表(单表发布模型)---- + +func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) { + var row struct { + ID uint32 `gorm:"column:id"` + Status string `gorm:"column:status"` + } + err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Select("id, status"). + 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 + } + if row.ID == 0 { + return 0, "", false, nil + } + return row.ID, row.Status, true, nil +} + +func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) { + now := uint32(time.Now().Unix()) + rw.CreatedAt, rw.UpdatedAt = now, now + if rw.Status == "" { + rw.Status = model.StatusPending + } + 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) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error { + now := uint32(time.Now().Unix()) + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", id).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 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) StreetSnapEntityState(ctx context.Context, city string, year uint16) (uint32, string, bool, error) { + var row struct { + ID uint32 `gorm:"column:id"` + Status string `gorm:"column:status"` + } + err := r.db.WithContext(ctx). + Model(&model.StreetSnap{}). + Select("id, status"). + Where("city = ? AND year = ? AND is_deleted = 0", city, year). + Limit(1). + Scan(&row).Error + if err != nil { + return 0, "", false, err + } + if row.ID == 0 { + return 0, "", false, nil + } + return row.ID, row.Status, true, nil +} + +func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) { + now := uint32(time.Now().Unix()) + snap.CreatedAt, snap.UpdatedAt = now, now + if snap.Status == "" { + snap.Status = model.StatusPending + } + 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 snap.ID, nil +} + +func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error { + now := uint32(time.Now().Unix()) + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", id).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 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 + }) +} +``` + +- [ ] **步骤 5:`fetchLookImages` 改返回正式图片类型** + +`internal/service/ingest_service.go:339` 的签名与内部两处 `model.BrandRunwayDraftImage` 改为 `model.BrandRunwayImage`: + +```go +func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) { +``` + +内部 `rows := make([]model.BrandRunwayImage, 0)` 与 `rows = append(rows, model.BrandRunwayImage{...})`(第 395 行)同步改类型;`BrandID` 不在此设置(由 `processRunway` 统一填)。 + +- [ ] **步骤 6:加入分支决策纯函数** + +在 `internal/service/ingest_service.go` 末尾追加: + +```go +// 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 + } +} +``` + +- [ ] **步骤 7:`processRunway` 改为直写正式表** + +`internal/service/ingest_service.go:245-256` 的去重段替换为: + +```go + // 2) 实体键查重并决定分支(单表模型) + seasonCode := season.Derive(p.Year, p.CollectionType, p.Season) + reuseID, existStatus, exist, _ := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType) + action := decideIngestAction(exist, existStatus) + if action == ingestSkip { + log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus) + _ = s.repo.MarkDone(ctx, job.ID) + return + } +``` + +第 263 行的 `var draftImages []model.BrandRunwayDraftImage` 改为 `var images []model.BrandRunwayImage`;第 269 行 `s.fetchLookImages` 赋值给 `images`;第 276-285 行的 `append(draftImages, model.BrandRunwayDraftImage{...})` 改为 `append(images, model.BrandRunwayImage{...})`。 + +第 297-327 行的写草稿段整体替换为: + +```go + // 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, + Season: p.Season, + CollectionType: p.CollectionType, + SeasonCode: seasonCode, + Cover: cover, + ImageCount: imageCount, + Status: model.StatusPending, + } + 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 + } + 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) +``` + +- [ ] **步骤 8:`processStreet` 改造(与步骤 7 同构)** + +第 413-422 行去重段替换为: + +```go + // 1) 实体键查重并决定分支(单表模型,同 runway) + reuseID, existStatus, exist, _ := s.repo.StreetSnapEntityState(ctx, p.City, p.Year) + 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 + } +``` + +第 435-469 行写草稿段替换为: + +```go + // 3) 直写正式表(status=pending) + writeStart := time.Now() + snap := &model.StreetSnap{ + JobID: job.ID, + Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en + Year: p.Year, + City: p.City, + Cover: cover, + ImageCount: uint16(len(fimgs)), + Status: model.StatusPending, + } + rows := make([]model.StreetSnapImage, 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), + Phash: sqlNull(fi.phash), + IsDuplicate: fi.isDup, + DupOf: strconv.FormatUint(uint64(fi.dupID), 10), + }) + } + 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 + } + 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) +``` + +- [ ] **步骤 9:`dedupImage` 的比对表单表化** + +`internal/service/ingest_service.go:606-614`: + +```go + var tables []string + switch kind { + case dto.IngestKindRunway: + tables = []string{"brand_runway_images"} + case dto.IngestKindStreet: + tables = []string{"street_snap_images"} + default: + return 0, 0 + } +``` + +- [ ] **步骤 10:运行测试,确认通过** + +运行:`go test -tags integration ./internal/repository/ -run TestIngest -v; go test -count=1 ./internal/service/ -run TestDecideIngestAction -v` +预期:两侧均 PASS + +- [ ] **步骤 11:全量编译与非集成测试** + +运行:`go build ./... && go test -count=1 ./...` +预期:编译通过。若 `internal/repository/ingest_repository_test.go` 因方法改名报错,按同样语义更新其桩:把 `CreateRunwayDraft` 等调用改为 `CreateRunwayWithImages` / `RunwayEntityState`。 + +- [ ] **步骤 12:Commit** + +```bash +git add internal/repository/ingest_repository.go internal/service/ingest_service.go \ + internal/service/ingest_service_test.go \ + internal/repository/ingest_single_table_integration_test.go internal/repository/ingest_repository_test.go +git commit -m "feat(publish): 爬虫入库直写正式表,驳回行可复用重审" +``` + +--- + +## 任务 3b:实体键唯一索引(结构性保证「一个实体一行」) + +**背景(为什么必须做)**:旧流程是**自愈**的——同一实体若产生两条草稿,晋升时的实体键 upsert 会把它们并成一条正式记录。 +单表之后这个自愈机制消失了,而 `brand_runways` / `street_snaps` 的实体键上**没有任何唯一索引**, +于是「一个实体一行」只靠入库时的「先查后插」维持:一次瞬时读失败、或两个后端实例并发处理同一实体的两条任务, +就会留下两行;两行都会通过审核进入公开视图,前台出现重复内容。 + +**文件:** +- 创建:`db/migrations/2026-09-22-03-entity-key-unique.sql` +- 修改:`internal/repository/ingest_repository.go`(复用守卫补 `is_deleted = 0`) +- 修改:`internal/repository/dedup_integration_test.go:97`(修掉空转的负向断言) +- 测试:`internal/repository/entity_key_unique_integration_test.go` + +- [ ] **步骤 1:写失败的集成测试** + +创建 `internal/repository/entity_key_unique_integration_test.go`: + +```go +//go:build integration + +// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」, +// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。 +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("同一实体键的第二行应被唯一索引拒绝,实际插入成功") + } + + // 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。 + 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)。 +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") + repo := NewIngestRepository(db) + ctx := context.Background() + + const city = "UniqTestCity" + const year = 1994 + snap := &model.StreetSnap{JobID: 1, Title: "uniq", 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: "uniq", 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("同一实体键的第二行应被唯一索引拒绝,实际插入成功") + } +} +``` + +- [ ] **步骤 2:运行测试,确认失败** + +运行:`go test -tags integration ./internal/repository/ -run TestRunwayEntityKeyUnique -v` +预期:FAIL —— `执行迁移失败 ...2026-09-22-03-entity-key-unique.sql: The system cannot find the file specified.` + +- [ ] **步骤 3:写迁移** + +创建 `db/migrations/2026-09-22-03-entity-key-unique.sql`: + +```sql +-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。 +-- +-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。 +-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发, +-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。 +-- +-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致, +-- 软删的旧实体键不再占用,允许同实体重新入库。 + +-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。 +DO $$ +DECLARE dup int; +BEGIN + SELECT count(*) INTO dup FROM ( + SELECT brand_id, season_code, collection_type + FROM brand_runways WHERE is_deleted = 0 + GROUP BY 1, 2, 3 HAVING count(*) > 1 + ) t; + IF dup > 0 THEN + RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup; + END IF; + + SELECT count(*) INTO dup FROM ( + SELECT city, year + FROM street_snaps WHERE is_deleted = 0 + GROUP BY 1, 2 HAVING count(*) > 1 + ) t; + IF dup > 0 THEN + RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_br_entity + ON brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity + ON street_snaps (city, year) WHERE is_deleted = 0; +``` + +- [ ] **步骤 4:运行测试,确认通过** + +运行:`go test -tags integration ./internal/repository/ -run 'TestRunwayEntityKeyUnique|TestStreetEntityKeyUnique' -v` +预期:PASS(两个用例均通过) + +- [ ] **步骤 5:确认入库侧的竞争会收敛到「跳过」而不是重试风暴** + +唯一索引把并发竞争变成一次写库错误 → `CreateRunwayWithImages` 返回 err → `failOrRetry` 安排重试 → +重试时 `RunwayEntityState` 已能查到那一行(`pending`)→ 走 `ingestSkip` → `MarkDone`。 +**这是既有重试机制的自然收敛,不需要为它写新代码。** 只需人工确认这条链路成立: + +阅读 `internal/service/ingest_service.go` 的 `processRunway` 写库分支与 `failOrRetry`, +确认「写库失败 → ScheduleRetry → 重跑 → 实体键命中 pending → MarkDone」这条路径没有断点。 +把确认结论写进报告(不需要为此写测试)。 + +- [ ] **步骤 6:全量回归** + +运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-Object -Last 5` +预期:全绿 + +- [ ] **步骤 6b:顺带修两处同区域的小缺陷(由任务 3 的审查带出)** + +**6b-1 修掉空转的负向断言** —— `internal/repository/dedup_integration_test.go` 里原有的「不应命中」断言实际从未生效: + +```go + // 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。 + _, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold) + if found2 { + t.Fatalf("汉明距离 64 的图不应被误判为近重复") + } +``` + +问题:`phash.ToVectorBits(0)` 对 `h == 0` 返回**空串**(见 `internal/pkg/phash/phash.go:95-98`), +SQL 因此退化成 `phash <-> ''::vector`,PostgreSQL 报 `22P02`;而这里用 `_` 丢弃了 err, +于是 `if found2` 恒不触发 —— 断言永远通过,等于没有覆盖负向路径。 + +改为用「距离 = 64 的非空探针」并检查 err: + +```go + // 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题), + // 这里改用「首位为 0、其余为 1」的探针并先断言查询本身不报错。 + probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold) + if errSame != nil { + t.Fatalf("去重查询不应报错: %v", errSame) + } + if !foundSame { + t.Fatalf("与库中那条完全相同的 phash 应命中") + } + if probeSame == 0 { + t.Fatalf("命中的行 id 不应为 0") + } +``` + +(原「汉明距离 64 不应命中」的意图由既有正向断言 + `is_deleted` 过滤测试覆盖; +若要保留一个真正的远距离负向用例,请用与库中那条距离确实大于阈值的**非空** phash 向量, +并在断言前先检查 `err == nil`。) + +**6b-2 复用守卫补 `is_deleted = 0`** —— `internal/repository/ingest_repository.go` 的 +`ReuseRejectedRunway` / `ReuseRejectedStreetSnap`: + +```go + upd := tx.Model(&model.BrandRunway{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected) +``` + +(street 侧同理。)理由:只加 `status` 守卫仍留着一条边——若该行在窗口期被管理员软删(状态仍是 `rejected`), +复用会照常覆盖内容并写入新图,而内容停在 `is_deleted = 1` 的行上永远不可见。 + +- [ ] **步骤 6c:跑测试确认 6b 没破坏东西** + +运行:`go test -tags integration ./internal/repository/ -run 'TestDedup|TestIngest' -count=1 -v` +预期:全绿(含 6b-1 改写后的断言与既有的 `TestDedupIgnoresSoftDeletedImage`) + +- [ ] **步骤 7:Commit** + +```bash +git add db/migrations/2026-09-22-03-entity-key-unique.sql \ + internal/repository/entity_key_unique_integration_test.go \ + internal/repository/ingest_repository.go \ + internal/repository/dedup_integration_test.go +git commit -m "feat(publish): 实体键唯一索引,保证一个实体一行" +``` + +--- + +## 任务 3c:街拍实体键细化(city + year + title) + +**背景(用户在任务 5 的数据冲突上作出的决策)**:同城同年可以有多个专题——开发库实例如 +「London / 2027 / *…Spring 2027 Day 2*」(草稿 4,329 图)与「London / 2027 / *…Spring 2027 Day 3*」 +(正式行 id=6,424 主图)。用户要求**分成两个文章**。 + +而当前街拍实体键是 `(city, year)`,比内容粒度粗,导致两个后果: +1. `uq_ss_entity`(任务 3b 加的唯一索引)不允许两者共存 —— 搬迁直接撞键中止; +2. 入库查重 `StreetSnapEntityState` 会把 Day 3 当成 Day 2 的重复而**直接放弃**,第二个专题根本进不来。 + +**实体键改为 `(city, year, title)`**。理由:`title` 是数据里唯一能区分专题的字段,且入库时已知 +(爬虫把 `p.TitleEn` 直接作为快照标题)。这与既定决策「实体键命中即放弃」兼容——同城同年**同标题**的重爬仍被放弃。 + +**文件:** +- 创建:`db/migrations/2026-09-22-04-street-entity-key-title.sql` +- 修改:`internal/repository/ingest_repository.go`(`StreetSnapEntityState` 增加 title 条件) +- 修改:`internal/service/ingest_service.go`(`processStreet` 的调用处传 `p.TitleEn`) +- 测试:`internal/repository/entity_key_unique_integration_test.go`(街拍用例改为按 title 区分) + +- [ ] **步骤 1:先改测试(红)** + +把 `internal/repository/entity_key_unique_integration_test.go` 的 `TestStreetEntityKeyUnique` 改成两段断言: + +```go +// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性)。 +// ② 同城同年**不同标题**的第二行必须允许(这就是「Day 2 / Day 3 分成两个文章」)。 +``` + +具体做法:把现有用例的第二次插入改成「同 city + 同 year + **同 title**」并断言失败; +再新增一次「同 city + 同 year + **不同 title**」的插入并断言成功。 + +- [ ] **步骤 2:运行测试,确认失败** + +运行:`go test -tags integration ./internal/repository/ -run TestStreetEntityKeyUnique -v` +预期:FAIL —— 迁移文件缺失(`applyMigration` 报找不到 `2026-09-22-04-...sql`) + +- [ ] **步骤 3:写迁移** + +创建 `db/migrations/2026-09-22-04-street-entity-key-title.sql`: + +```sql +-- 街拍实体键细化:由 (city, year) 改为 (city, year, title)。 +-- +-- 背景:同城同年可能有多个专题(如 London 2027 Day 2 / Day 3),原键把它们判为同一实体, +-- 导致唯一索引不允许共存、且入库查重把第二个专题当成重复直接放弃。 +-- +-- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title, +-- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title, +-- 这里只是把「NULL 也要参与唯一性」这件事钉死。 +DROP INDEX IF EXISTS uq_ss_entity; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity + ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0; +``` + +(`brand_runways` 的 `uq_br_entity` **不动**:走秀实体键已有 `collection_type` 区分系列。) + +- [ ] **步骤 4:改入库查重** + +`internal/repository/ingest_repository.go` 的 `StreetSnapEntityState` 增加 `title` 参数与条件, +并与索引表达式保持同一口径: + +```go +// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。 +// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。 +func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) { + // ... Select("id, status") 不变 + Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title). + // ... 其余不变 +} +``` + +接口声明与 `internal/service/ingest_service.go` 的 `processStreet` 调用处同步(传 `p.TitleEn`, +与同函数里构造 `snap.Title` 用的是同一个值)。 + +- [ ] **步骤 5:运行测试,确认通过** + +运行:`go test -tags integration ./internal/repository/ -run TestStreetEntityKeyUnique -v` +预期:PASS + +- [ ] **步骤 6:全量回归** + +运行:`go build ./... && go test -count=1 ./... && go vet -tags integration ./internal/repository/ && go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-Object -Last 5` +预期:全绿 + +- [ ] **步骤 7:Commit** + +```bash +git add db/migrations/2026-09-22-04-street-entity-key-title.sql \ + internal/repository/ingest_repository.go internal/service/ingest_service.go \ + internal/repository/entity_key_unique_integration_test.go +git commit -m "feat(publish): 街拍实体键细化为 city+year+title,支持同城同年多专题" +``` + +--- + +## 任务 4:审核侧切到正式表(含改名与模板) + +**文件:** +- 修改:`internal/repository/review_repository.go`(整体重写) +- 修改:`internal/service/review_service.go` +- 修改:`internal/handler/backstage_handler.go:300-500` 区间与 `:690-830` 区间 +- 修改:`internal/router/backstage.go`(三条图片路由的 handler 引用改名) +- 修改:`internal/handler/templates/pages/review-list.html`、`review-detail.html` +- 修改:`internal/router/backstage_test.go`(`fakeReview`) + +- [ ] **步骤 1:写失败的测试** + +在 `internal/router/backstage_test.go` 末尾追加(`fakeReview` 已在该文件,需同步改名): + +```go +// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。 +// 断言 service 收到的是 published 状态写入,而不是「先晋升再置 approved」。 +func TestBackstageApprovePublishes(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/admin/reviews/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) + } +} + +// TestBackstageStreetRenderGroups 详情页按组渲染,且模板数据键已更名为 Record。 +func TestBackstageStreetRenderGroups(t *testing.T) { + r, rev := newReviewRouter(t) + tok := reviewToken(t, r) + rev.view = &service.RecordDetailView{ + Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending", + Images: []service.RecordImageRef{ + {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0}, + {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, + }, + Groups: []service.ImageGroup{ + {Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}}, + }, + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", 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, `id="batch"`) || !strings.Contains(body, `form="batch"`) { + t.Fatalf("应有勾选批量并入当前主图的表单") + } +} +``` + +`fakeReview` 的字段与桩函数同步改名: + +```go +type fakeReview struct { + service.ReviewService + attachOK bool + last struct { + kind string + recordID uint32 + mainID uint32 + imgIDs []uint32 + imgID uint32 + op string + } + view *service.RecordDetailView +} + +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 +} + +func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, _ string) (uint32, error) { + f.last.kind, f.last.recordID, f.last.op = kind, id, "approve" + return id, 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 +} +``` + +原有 `TestBackstageStreetAttachRedirect` / `TestBackstageStreetAttachPrev` / `TestBackstageStreetDetach` 三处把 `rev.last.draftID` 改为 `rev.last.recordID`;`attach-prev` 那条路由与测试**删除**(该能力已不在代码中)。 + +- [ ] **步骤 2:运行测试,确认失败** + +运行:`go test -count=1 ./internal/router/ -run TestBackstage -v` +预期:FAIL —— 编译错误 `service.RecordDetailView undefined`、`unknown backstage page` 相继出现 + +- [ ] **步骤 3:重写 `ReviewRepository` 接口** + +`internal/repository/review_repository.go` 的接口整体替换为: + +```go +// ReviewRepository 审核数据访问接口。 +// +// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表): +// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。 +type ReviewRepository interface { + // 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 设置状态(通过/驳回)与审核人/驳回理由。 + SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason 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(ctx context.Context, id uint32, status, reviewer, reason 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 + // 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) +} +``` + +- [ ] **步骤 4:实现新方法、删除晋升代码** + +逐条改写实现(对象从草稿表换成正式表): + +- `ListRecords`:`Model(&model.BrandRunway{}).Where("is_deleted = 0")`,`Order("CASE status WHEN '"+model.StatusPending+"' THEN 0 ELSE 1 END ASC, id DESC")`。 +- `RecordDetail`:`First(&model.BrandRunway{})`。 +- `RecordImages`:`Where("runway_id = ? AND is_deleted = 0", recordID)`。 +- `UpdateRecord`:白名单 `draftEditable` 更名为 `recordEditable`(内容与原 `draftEditable` 相同),`Model(&model.BrandRunway{})`。 +- `SetRecordStatus`:`Model(&model.BrandRunway{})`,字段 `status` / `reviewer` / `reject_reason` / `updated_at`。 +- `ListStreetRecords` / `StreetRecordDetail` / `StreetRecordImages` / `UpdateStreetRecord`(白名单 `streetRecordEditable` = title/year/city)/ `SetStreetRecordStatus`:同上换 `StreetSnap`。 +- `SoftDeleteRunwayImage`:`Model(&model.BrandRunwayImage{}).Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID)`,随后 `Exec("UPDATE brand_runways SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", recordID)`。 +- `SoftDeleteStreetImage`:同上,表名换 `street_snap_images` / `street_snaps`。 +- `AttachStreetSnapImages` / `DetachStreetSnapImage`:把 `model.StreetSnapDraftImage` 换成 `model.StreetSnapImage`、`draft_id` 换成 `snap_id`;`draftID` 参数改名 `snapID`。事务与两阶段顺序(先改挂孙辈、再标自身为副图)**保持不变**。 +- `GetImageKey`:两张表换成 `brand_runway_images` / `street_snap_images`,`draft_id` → `runway_id` / `snap_id`。 +- `CountByStatus`:原 `CountDraftPending` 改名,`Model(dest).Where("status = ? AND is_deleted = 0", status)`。 + +**删除**以下设施(连同 `runwayApprovedSiblingImages`、`streetApprovedSiblingImages`、`unionRunwayDraftImages`、`unionStreetDraftImages`、`SaveRunwayFromDraft`、`SaveStreetSnapFromDraft`):单表模型下图片不再于审核通过时重建,多来源聚合由入库时的实体键唯一化取代。 + +- [ ] **步骤 5:`review_service.go` 改名与 `Approve` 语义** + +类型与接口改名(用编辑器全局重命名,编译期兜底): +`DraftCard`→`RecordCard`、`DraftImageRef`→`RecordImageRef`、`DraftDetailView`→`RecordDetailView`、`DraftImageGroup`→`ImageGroup`;`ReviewModule` 的 `ListDrafts`→`ListRecords`、`DraftDetail`→`RecordDetail`、`UpdateDraft`→`UpdateRecord`;`ReviewService` 的 `ListDrafts`→`ListRecords`、`DraftDetail`→`RecordDetail`、`UpdateDraft`→`UpdateRecord`、`DeleteDraftImage`→`DeleteImage`、`AttachDraftImages`→`AttachImages`、`DetachDraftImage`→`DetachImage`。 + +两个模块的方法体改为操作正式表(`repo.ListRecords` / `repo.RecordDetail` / `repo.RecordImages` / `repo.ListStreetRecords` / `repo.StreetRecordDetail` / `repo.StreetRecordImages`),`toRunwayCards(rows []model.BrandRunway)` / `toStreetCards(rows []model.StreetSnap)`。 + +两个 `Approve` 改为: + +```go +func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { + if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil { + return 0, err + } + return id, nil +} +``` + +```go +func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { + if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil { + return 0, err + } + return id, nil +} +``` + +两个 `Reject` 把状态常量换成 `model.StatusRejected`,分别调 `SetRecordStatus` / `SetStreetRecordStatus`;两个 `CountPending` 调 `CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})` / `&model.StreetSnap{}`。 + +`DeleteImage` 里把 `SoftDeleteRunwayDraftImage` / `SoftDeleteStreetDraftImage` 换成 `SoftDeleteRunwayImage` / `SoftDeleteStreetImage`;`AttachImages` / `DetachImage` 换成 `AttachStreetSnapImages` / `DetachStreetSnapImage`;`GetDraftImageKey` → `GetImageKey`。 + +`streetModule.RecordDetail` 里的分组算法(`resolveRoot` 链式上浮、`detailCount`、`groups` 组装)**逐行保留**,只把 `model.StreetSnapDraftImage` 换成 `model.StreetSnapImage`、`byID` 的键类型不变。 + +- [ ] **步骤 6:`backstage_handler.go` 改名与模板键** + +- `h.review.ListDrafts` → `h.review.ListRecords`;`h.review.DraftDetail` → `h.review.RecordDetail`;`h.review.UpdateDraft` → `h.review.UpdateRecord`;`h.review.DeleteDraftImage` → `h.review.DeleteImage`;`h.review.AttachDraftImages` → `h.review.AttachImages`;`h.review.DetachDraftImage` → `h.review.DetachImage`。 +- `draftFormFields(c)` → `reviewFormFields(c)`。 +- `ReviewList`(第 335 行)的 `"Drafts": drafts` → `"Records": drafts`。 +- `ReviewDetail`(第 375 行)的 `"Draft": draft` → `"Record": draft`。 +- handler 方法名去 `Draft`(编译期兜底):`ReviewDraftImageDelete`→`ReviewImageDelete`、`ReviewAttachDraftImages`→`ReviewAttachImages`、`ReviewDetachDraftImage`→`ReviewDetachImage`。 + +同步更新 `internal/router/backstage.go` 里这三条路由的 handler 引用(URL 路径 `/images/attach`、`/images/:img/detach`、`/images/:img/delete` **不变**): + +```go + admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete) + admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages) + admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage) +``` + +- [ ] **步骤 7:模板同步** + +`review-list.html`: +- 第 27 行 `{{range .Drafts}}` → `{{range .Records}}` +- 第 15 行 tab:`?status=approved` → `?status=published`,`eq .Status "approved"` → `eq .Status "published"`,文案保持「已通过」 +- 第 35 行提示文案 `按当前内容直接晋升到正式表` → `按当前内容直接发布` + +`review-detail.html`:全文 `.Draft.` → `.Record.`(含 `{{reviewDetailURL .Draft.Kind .Draft.ID}}`、`.Draft.Fields`、`.Draft.Images`、`.Draft.Groups` 等)。 + +- [ ] **步骤 7b:后台走秀列表补状态列** + +任务 1 给 `RunwayRow` 加了 `Status` 字段,但 `articleAdminColumns`(`internal/repository/article_repository.go:66-70`)没有 `SELECT` 它,所以字段永远是零值、状态列会是空的。这里补上: + +1)`articleAdminColumns` 末尾加一列: + +```go +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` +``` + +2)`internal/handler/templates/pages/runways.html` 的表头与行各加一格: + +```html +状态 +``` +```html +{{.Status}} +``` + +(`.badge` 样式已由 `admin.css` 提供,见 `review-list.html:34` 的既有用法。街拍后台列表 `street-snaps.html` 同理加状态列——它渲染的是 `model.StreetSnap`,任务 1 已加 `Status` 字段。) + +- [ ] **步骤 8:运行测试,确认通过** + +运行:`go test -count=1 ./internal/router/ ./internal/handler/ ./internal/service/ -v 2>&1 | Select-String 'FAIL|ok '` +预期:全绿。`internal/handler/backstage_handler_test.go` 的「逐页渲染」用例是模板键改名的安全网——漏改会导致模板执行报错 → 500 → 该用例红。 + +- [ ] **步骤 9:集成测试与全量回归** + +运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ 2>&1 | Select-String 'FAIL|ok '` +预期:全绿 + +- [ ] **步骤 10:Commit** + +```bash +git add internal/repository/review_repository.go internal/service/review_service.go \ + internal/handler/backstage_handler.go internal/handler/templates/pages/review-list.html \ + internal/handler/templates/pages/review-detail.html internal/router/backstage_test.go +git commit -m "refactor(publish): 审核侧切到正式表,通过即改状态不再晋升" +``` + +--- + +## 任务 5:历史草稿搬迁脚本 + +> **执行顺序(重要)**:本任务必须在**任务 3c 之后**执行。 +> 任务 3c 把街拍实体键细化为 `(city, year, title)`,因此开发库里那条「London 2027 Day 2」草稿 +> 不会再与既有的「London 2027 Day 3」正式行撞键,搬迁可以正常完成。 +> +> **并且必须在跑搬迁之前先执行一次 `db/migrations/2026-09-22-01b-publish-existing-rows.sql`。** +> 原因有两条: +> 1. 该文件把存量正式行置为 `published`。不跑的后果是公开视图返回 0 行(前台整站为空)。 +> 2. 它只能在搬迁**之前**跑:搬迁会把 pending 草稿插进正式表,之后再跑 01b 会把那些待审内容一并刷成已发布。 +> +> 顺序:**01b → `go run ./scripts/migrate_single_table` → 三项核对 → 任务 6 删表**。 + +**文件:** +- 创建:`scripts/migrate_single_table/main.go` + +- [ ] **步骤 1:实现搬迁命令** + +创建 `scripts/migrate_single_table/main.go`: + +```go +// migrate_single_table 把历史草稿数据搬迁进正式表(单表发布模型的数据迁移,一次性执行)。 +// +// 用法: +// +// go run ./scripts/migrate_single_table -dry-run # 只统计与校验,不写库 +// go run ./scripts/migrate_single_table # 真正搬迁 +// +// 搬迁规则: +// - 正式表既有行:status 已由迁移 2026-09-22-01 置为 published,本脚本不动。 +// - pending / rejected 草稿:插入正式表并保留原状态,草稿图片插入正式图片表。 +// - approved 草稿:其内容当年已由「晋升」写入正式表,**不重复插入**,只丢弃元数据。 +// - 街拍副图:parent_image_id 从「草稿图片 id」改写为「新正式图片 id」;映射缺失则降级为主图。 +// +// 校验不通过即中止(退出码 1),且不删任何数据;删草稿表由后续迁移人工执行。 +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "fashionapi/internal/config" + "fashionapi/internal/database" + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +func main() { + dryRun := flag.Bool("dry-run", false, "只统计与校验,不写库") + flag.Parse() + + cfg, err := config.Load("") + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + db, err := database.New(cfg.Database) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + defer database.Close(db) + + ctx := context.Background() + if err := run(ctx, db, *dryRun); err != nil { + log.Fatalf("搬迁失败: %v", err) + } + if *dryRun { + fmt.Println("dry-run 结束:未写入任何数据") + } +} + +func run(ctx context.Context, db *gorm.DB, dryRun bool) error { + // 1) 前置校验:每个 approved 草稿都必须能在正式表找到对应实体行, + // 否则说明有「只存在于草稿」的内容,需要人工处理,不能盲目删表。 + var orphans int64 + if err := db.WithContext(ctx).Raw(` +SELECT count(*) FROM brand_runway_drafts d +WHERE d.status = 'approved' AND d.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM brand_runways r + WHERE r.brand_id = d.brand_id AND r.season_code = d.season_code + AND r.collection_type = d.collection_type AND r.is_deleted = 0)`).Scan(&orphans).Error; err != nil { + return fmt.Errorf("校验走秀孤儿草稿: %w", err) + } + if orphans > 0 { + return fmt.Errorf("有 %d 条 approved 走秀草稿在正式表找不到对应记录,请人工处理后重跑", orphans) + } + if err := db.WithContext(ctx).Raw(` +SELECT count(*) FROM street_snap_drafts d +WHERE d.status = 'approved' AND d.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM street_snaps s + WHERE s.city = d.city AND s.year = d.year AND s.is_deleted = 0)`).Scan(&orphans).Error; err != nil { + return fmt.Errorf("校验街拍孤儿草稿: %w", err) + } + if orphans > 0 { + return fmt.Errorf("有 %d 条 approved 街拍草稿在正式表找不到对应记录,请人工处理后重跑", orphans) + } + + total, err := migrateRunways(ctx, db, dryRun) + if err != nil { + return err + } + streetTotal, err := migrateStreets(ctx, db, dryRun) + if err != nil { + return err + } + fmt.Printf("搬迁完成:走秀草稿 %d 条,街拍草稿 %d 条\n", total, streetTotal) + return nil +} +``` + +同文件追加两个搬迁函数: + +```go +// migrateRunways 搬迁 pending / rejected 的走秀草稿(含图片),返回搬迁条数。 +func migrateRunways(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) { + var drafts []model.BrandRunwayDraft + if err := db.WithContext(ctx). + Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}). + Order("id ASC").Find(&drafts).Error; err != nil { + return 0, err + } + fmt.Printf("走秀待搬迁草稿:%d 条\n", len(drafts)) + if dryRun { + for _, d := range drafts { + var n int64 + db.WithContext(ctx).Model(&model.BrandRunwayDraftImage{}). + Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n) + fmt.Printf(" draft=%d status=%s title=%q 图片=%d\n", d.ID, d.Status, d.TitleEn, n) + } + return len(drafts), nil + } + + moved := 0 + for _, d := range drafts { + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + rw := model.BrandRunway{ + JobID: d.JobID, BrandID: d.BrandID, + TitleEn: d.TitleEn, TitleCn: d.TitleCn, + DescriptionEn: d.DescriptionEn, DescriptionCn: d.DescriptionCn, + Year: d.Year, Season: d.Season, + CollectionType: d.CollectionType, SeasonCode: d.SeasonCode, + Cover: d.Cover, ImageCount: d.ImageCount, + Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason, + IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, + } + if cErr := tx.Create(&rw).Error; cErr != nil { + return cErr + } + var imgs []model.BrandRunwayDraftImage + if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil { + return fErr + } + if len(imgs) == 0 { + return nil + } + rows := make([]model.BrandRunwayImage, 0, len(imgs)) + for _, im := range imgs { + rows = append(rows, model.BrandRunwayImage{ + RunwayID: rw.ID, BrandID: d.BrandID, + Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, + LookIndex: im.LookIndex, IsDetail: im.IsDetail, + IsDeleted: im.IsDeleted, Phash: im.Phash, + IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, + CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, + }) + } + return tx.Create(&rows).Error + }) + if err != nil { + return moved, fmt.Errorf("搬迁走秀草稿 %d: %w", d.ID, err) + } + moved++ + } + return moved, nil +} + +// migrateStreets 搬迁 pending / rejected 的街拍草稿,并改写副图父引用。 +func migrateStreets(ctx context.Context, db *gorm.DB, dryRun bool) (int, error) { + var drafts []model.StreetSnapDraft + if err := db.WithContext(ctx). + Where("status IN ? AND is_deleted = 0", []string{model.StatusPending, model.StatusRejected}). + Order("id ASC").Find(&drafts).Error; err != nil { + return 0, err + } + fmt.Printf("街拍待搬迁草稿:%d 条\n", len(drafts)) + if dryRun { + for _, d := range drafts { + var n int64 + db.WithContext(ctx).Model(&model.StreetSnapDraftImage{}). + Where("draft_id = ? AND is_deleted = 0", d.ID).Count(&n) + fmt.Printf(" draft=%d status=%s title=%q city=%s 图片=%d\n", d.ID, d.Status, d.Title, d.City, n) + } + return len(drafts), nil + } + + moved := 0 + for _, d := range drafts { + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + snap := model.StreetSnap{ + JobID: d.JobID, Title: d.Title, Year: d.Year, City: d.City, + Cover: d.Cover, ImageCount: d.ImageCount, + Status: d.Status, Reviewer: d.Reviewer, RejectReason: d.RejectReason, + IsDeleted: d.IsDeleted, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, + } + if cErr := tx.Create(&snap).Error; cErr != nil { + return cErr + } + var imgs []model.StreetSnapDraftImage + if fErr := tx.Where("draft_id = ?", d.ID).Order("sort_order ASC, id ASC").Find(&imgs).Error; fErr != nil { + return fErr + } + if len(imgs) == 0 { + return nil + } + + // 第一遍:插入主图,记下「草稿图片 id → 新正式图片 id」。 + idMap := make(map[uint32]uint32, len(imgs)) + mains := make([]model.StreetSnapImage, 0, len(imgs)) + for _, im := range imgs { + if im.IsDetail == 1 { + continue + } + mains = append(mains, model.StreetSnapImage{ + SnapID: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, + IsDetail: 0, IsDeleted: im.IsDeleted, Phash: im.Phash, + IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, + CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, + }) + } + for i := range mains { + if cErr := tx.Create(&mains[i]).Error; cErr != nil { + return cErr + } + } + mi := 0 + for _, im := range imgs { + if im.IsDetail == 1 { + continue + } + idMap[im.ID] = mains[mi].ID + mi++ + } + + // 第二遍:插入副图并把父引用从小表 id 改写成新正式行 id;映射缺失则降级为主图。 + details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains)) + for _, im := range imgs { + if im.IsDetail != 1 { + continue + } + row := model.StreetSnapImage{ + SnapID: snap.ID, Image: im.Image, Name: im.Name, SortOrder: im.SortOrder, + IsDetail: 1, IsDeleted: im.IsDeleted, Phash: im.Phash, + IsDuplicate: im.IsDuplicate, DupOf: im.DupOf, + CreatedAt: im.CreatedAt, UpdatedAt: im.UpdatedAt, + } + if newParent, ok := idMap[im.ParentImageID]; ok { + row.ParentImageID = newParent + } else { + row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错一致 + } + details = append(details, row) + } + if len(details) > 0 { + if cErr := tx.Create(&details).Error; cErr != nil { + return cErr + } + } + return nil + }) + if err != nil { + return moved, fmt.Errorf("搬迁街拍草稿 %d: %w", d.ID, err) + } + moved++ + } + return moved, nil +} +``` + +- [ ] **步骤 2:编译** + +运行:`go build ./... && go vet ./scripts/migrate_single_table/` +预期:无输出(通过) + +- [ ] **步骤 3:dry-run 核对影响面** + +运行:`go run ./scripts/migrate_single_table -dry-run` +预期:打印待搬迁的走秀/街拍草稿条数与各自图片数。核对条数与库里 `SELECT status, count(*) FROM street_snap_drafts GROUP BY status` 一致;若报「孤儿草稿」,先人工处理再继续。 + +- [ ] **步骤 4:真实搬迁并核对守恒** + +运行:`go run ./scripts/migrate_single_table` +预期:`搬迁完成:走秀草稿 N 条,街拍草稿 M 条`,无错误。 + +随后核对(在库里执行): +- `SELECT count(*) FROM street_snap_drafts WHERE status IN ('pending','rejected') AND is_deleted=0` 与搬迁条数一致; +- 街拍副图父引用无悬空:`SELECT count(*) FROM street_snap_images c WHERE c.is_detail=1 AND c.parent_image_id <> 0 AND NOT EXISTS (SELECT 1 FROM street_snap_images p WHERE p.id=c.parent_image_id)` 结果应为 0。 + +- [ ] **步骤 5:Commit** + +```bash +git add scripts/migrate_single_table/main.go +git commit -m "chore(publish): 增加历史草稿搬迁脚本(含校验与 dry-run)" +``` + +--- + +## 任务 6:删除草稿表与死代码 + +**文件:** +- 创建:`db/migrations/2026-09-22-05-drop-draft-tables.sql` +- 删除:`internal/model/runway_draft.go`、`internal/model/street_snap_draft.go` +- 删除:`scripts/migrate_single_table/`(**必需**:它 import 草稿模型,草稿模型一删它就无法编译) + +> ⚠️ **顺序**:`scripts/migrate_single_table` 必须在**本任务之前**跑完(它对草稿表的依赖只在那一次运行里需要)。 +> 本任务第一步就要先把它删掉,否则 `go build ./...` 会因它引用已删的草稿模型而失败。 + +- [ ] **步骤 1:确认已无代码引用草稿模型** + +运行:`go build ./... 2>&1 | Select-Object -First 20` +预期:无输出。若仍有草稿模型/草稿约束的引用,先补完改名再继续。 + +再运行:`Get-ChildItem -Recurse -Filter *.go -Path internal | Select-String -Pattern 'BrandRunwayDraft|StreetSnapDraft|DraftStatus' | Select-Object -First 20` +预期:无输出(没有任何草稿模型/常量引用;仅剩的中文「草稿」字样注释不影响)。 + +- [ ] **步骤 2:写删表迁移** + +创建 `db/migrations/2026-09-22-05-drop-draft-tables.sql`: + +```sql +-- 单表发布模型收尾:删除 4 张草稿表。 +-- +-- ⚠️ 执行前置条件:scripts/migrate_single_table 已跑完且校验通过(否则草稿数据会丢失)。 +-- 本迁移不可逆:执行前请确认 db/backups 有可用备份。 + +DROP TABLE IF EXISTS brand_runway_draft_images; +DROP TABLE IF EXISTS brand_runway_drafts; +DROP TABLE IF EXISTS street_snap_draft_images; +DROP TABLE IF EXISTS street_snap_drafts; +``` + +- [ ] **步骤 3:删除草稿模型文件与一次性搬迁脚本** + +删除 `internal/model/runway_draft.go`、`internal/model/street_snap_draft.go`, +以及 `scripts/migrate_single_table/`(整个目录)。 + +后者是**必需**的:它 import 草稿模型,模型一删它就无法编译,`go build ./...` 会直接失败。 +搬迁脚本是一次性工具,跑完(见任务 5 步骤 4)就没有存在价值了。 + +- [ ] **步骤 4:编译与全量测试** + +运行:`go build ./... && go vet ./... && go test -count=1 ./...` +预期:编译通过、vet 无输出、测试全绿 + +- [ ] **步骤 5:集成测试(迁移 05 生效后)** + +先在库上执行迁移 05: + +```bash +psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db/migrations/2026-09-22-05-drop-draft-tables.sql +``` + +(本机无 `psql` 时用 WSL / docker:见 `scripts/setup_pg_wsl.sh`、`scripts/pgvector/docker-compose.yml`) + +再运行:`go test -tags integration ./internal/repository/ -count=1 2>&1 | Select-String 'FAIL|ok '` +预期:全绿(集成测试已不依赖草稿表) + +- [ ] **步骤 6:刷新 schema dump** + +运行:`go build -o bin/dbtool ./cmd/dbtool && ./bin/dbtool dump -out db_dump.sql` +预期:`db_dump.sql` 更新——不再含 4 张草稿表,含 4 个 `public_*` 视图与新增列。 + +- [ ] **步骤 7:Commit** + +```bash +git add db/migrations/2026-09-22-05-drop-draft-tables.sql db_dump.sql +git rm internal/model/runway_draft.go internal/model/street_snap_draft.go +git commit -m "chore(publish): 删除草稿表与草稿模型" +``` + +--- + +## 任务 7:收尾验收 + +- [ ] **步骤 0:两处由前序审查带出的小修** + +**0-1 给备份补上 4 张草稿表的 `PRIMARY KEY`** —— `db/backups/draft-tables-before-drop.sql` 数据完整(1618 行)、 +索引与序列复位已补,但 4 张表**都没有主键**(原文全文 0 处 `PRIMARY KEY`)。而删表前的库是有主键的 +(旧 dump 里的 `brand_runway_drafts_pkey` / `brand_runway_draft_images_pkey` / +`street_snap_drafts_pkey` / `street_snap_draft_images_pkey`)。 +后果:按该备份还原出的表**唯一性约束与原库不等价**,严格说不是「删表前一模一样」。 +从 git 历史取回这 4 条约束定义并补进备份:`git show HEAD:cmd/dbtool/db_dump.sql`(这份**陈旧的第二份 dump 仍在版本控制里**,正好含旧 schema 的主键定义)。 + +**0-2 清掉陈旧的第二份 dump** —— `cmd/dbtool/db_dump.sql` 是一份**旧产物**(仍含 4 张草稿表与旧 schema), +与权威的根 `db_dump.sql` 同名、容易混淆,且未被 `.gitignore` 覆盖。 +修法:删除它,或重新生成(若它已不再被任何流程引用,删除更干净——请在报告里说明你的选择与理由)。 + +- [ ] **步骤 1:格式与静态检查** + +运行:`gofmt -l internal scripts cmd` 与 `go vet ./...` +预期:无输出 + +- [ ] **步骤 2:全量测试** + +运行:`go build ./... && go test -count=1 ./... && go test -tags integration ./internal/repository/ 2>&1 | Select-String 'FAIL|ok '` +预期:全绿 +(`repository` 集成套件中的错误日志是刻意构造的负向用例,属预期输出。) + +- [ ] **步骤 3:端到端走查(真实库 + 真实 HTTP 请求,不需要浏览器)** + +本机没有可用的浏览器,用 HTTP 请求(`curl` 或 PowerShell `Invoke-WebRequest`)等价验证。 +先起服务(`go run ./cmd/server` 或已有的启动方式),再做这 6 项: + +1. 后台审核列表 `/admin/reviews`(需先 `POST /admin/login` 拿会话 cookie):应能看到待审记录 + (当前库里应有 **4 条街拍 pending**,即搬迁进来的 id=146–149),「已通过」tab 的链接应带 `?status=published`。 +2. 打开其中一条街拍待审记录的审核页 `/admin/reviews/street/`:响应 200 且 HTML 里应含 + 图片网格与「合并为一组」表单(`id="batch"` / `form="batch"`)。 +3. `POST /admin/reviews/street//approve` → 应 **302** 回列表;随后该记录的 `status` 应为 `published`。 + ⚠️ 这一步会真的改数据:**只对一条**记录做,并在报告里写明是哪一条(我会据此核对)。 +4. 公开 API 列表 `GET /api/v1/public/street-snaps`:**刚通过的那条应出现**;另外 3 条 pending 的**不应出现**。 +5. 公开 API 详情 `GET /api/v1/public/street-snaps/`(uid 用 hashid 编码): + 对已发布的那条 → 200 且能读到;对仍 pending 的那条 → **404**。 +6. 后台编辑页 `/admin/street-snaps/` 打开一条**仍 pending** 的记录 → 图片**应能显示** + (这是任务 2 把 `ListImages` 拆出基表版本要保证的;若这里为空,说明后台读到了公开视图)。 + +> 若服务起不来或缺少必要配置(如 S4/签名密钥),**不要**硬凑:把具体的阻塞错误写进报告并标注为未完成项, +> 我会据此决定是否需要补做。 + +- [ ] **步骤 4:确认工作区干净** + +运行:`git status --short` +预期:无未提交的代码改动(仅可能有用户自己的文档改动) + +--- + +## 附:明确不做的事 + +1. **不合并后台的「审核列表」与「正式列表」两个入口**(`/admin/reviews` 与 `/admin/runways`·`/admin/street-snaps`)——单表之后它们数据重叠,但合并属独立重构,留待后续。 +2. **不改 `image_count` 口径**:runway 计主图、street 计全部图,现状保留。 +3. **不清理死物**:`street_snaps.source/source_url`、`image_duplicates`、`image_embeddings` 仍为零引用,另起迁移处理。 +4. **不修 `favorites` 唯一索引**(实建 `uniq_user_target(target_uid)` 单列,疑似缺陷)。 +5. ~~不给实体键加唯一索引~~ → **已改为任务 3b 实现**。原理由「改动前即如此」是错的:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈),单表后这个自愈机制消失,只剩「先查后插」,挡不住并发或一次瞬时读失败。故补部分唯一索引(`uq_br_entity` / `uq_ss_entity`)。 diff --git a/docs/superpowers/specs/2026-09-22-single-table-publish-design.md b/docs/superpowers/specs/2026-09-22-single-table-publish-design.md index 5194914..def5ff1 100644 --- a/docs/superpowers/specs/2026-09-22-single-table-publish-design.md +++ b/docs/superpowers/specs/2026-09-22-single-table-publish-design.md @@ -254,5 +254,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts, 1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。 2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。 3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。 -4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。 +4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。 + - ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。 5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。 + - 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。 diff --git a/internal/dto/article.go b/internal/dto/article.go index 5d391d4..7d96360 100644 --- a/internal/dto/article.go +++ b/internal/dto/article.go @@ -79,12 +79,10 @@ type PublicArticleImage struct { Thumb string `json:"thumb,omitempty"` Name string `json:"name"` IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下) - LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值 Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级) - // Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。 - // 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份 - // 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。 + // Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。 + // 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。 Detail []PublicArticleImage `json:"detail,omitempty"` } diff --git a/internal/handler/assets/admin.css b/internal/handler/assets/admin.css index 439e3a6..bd5c562 100644 --- a/internal/handler/assets/admin.css +++ b/internal/handler/assets/admin.css @@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; } display: flex; flex-direction: column; } -.cell.det { background: #f8faff; border-color: #c7d2fe; } +/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */ +.cell.det { background: #eef2ff; border: 2px solid #818cf8; } +/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */ +.cell.det { display: none; } +.cell.det.shown { display: flex; } .cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); } /* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */ .cell .thumb { @@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 0; } transition: opacity .15s; } .cell .thumb:hover::after { opacity: 1; } -.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; } -.cell.det .role { color: #3b4bb5; } +/* 角色标签统一做成胶囊徽标:主图绿、细节图靛蓝,颜色编码一眼可分 */ +.cell .role { + align-self: flex-start; + margin: 6px 8px 0; + padding: 2px 9px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + color: #1a7f43; + background: #e6f7ec; +} +.cell.det .role { color: #fff; background: #4338ca; } .cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; } .cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; } .cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; } @@ -339,6 +353,44 @@ tbody tr:last-child td { border-bottom: 0; } cursor: pointer; } .btn:hover { border-color: #111; } +.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; } + +/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */ +/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮, + 收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */ +.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.ops .inline { display: inline-flex; margin: 0; padding: 0; } +.ops .view { + display: inline-block; + padding: 5px 10px; + border: 1px solid #ccc; + border-radius: 6px; + text-decoration: none; + font-size: 13px; + color: #111; + background: #fff; +} +.ops .view:hover { border-color: #111; } +.op { + margin: 0; + padding: 5px 11px; + font-size: 13px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + font-family: inherit; + line-height: 1.4; + white-space: nowrap; +} +.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; } +.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; } +.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; } +.op-primary:hover, .op-danger:hover { opacity: .9; } +.op-warn:hover { background: #fff8e6; } + +/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */ +.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; } +.review-actions .inline { display: inline-flex; margin: 0; padding: 0; } .chk { display: flex; align-items: center; @@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; } .chk:hover { border-color: #111; } .chk input { margin: 0; } +/* ---------- 看大图弹层(,initZoom 依赖 #zoom-modal / #zoom-img) ---------- */ +#zoom-modal { + padding: 0; + border: 0; + background: transparent; + max-width: 94vw; + max-height: 94vh; +} +#zoom-modal::backdrop { background: rgba(0, 0, 0, .82); } +#zoom-img { + display: block; + max-width: 94vw; + max-height: 94vh; + width: auto; + height: auto; + object-fit: contain; + cursor: zoom-out; +} + /* ---------- 底部批量操作条 ---------- */ .batchbar { position: sticky; diff --git a/internal/handler/assets/admin.js b/internal/handler/assets/admin.js index f0fa9cb..894862f 100644 --- a/internal/handler/assets/admin.js +++ b/internal/handler/assets/admin.js @@ -1,402 +1,209 @@ -/* 管理后台渐进增强脚本:无框架、无依赖、无构建。 - * - * 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底 - * (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事: - * 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需; - * 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次); - * 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。 - * - * 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。 - */ +// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js +// 两个入口页面: +// - 文章管理混合列表(/admin/articles):批量通过/驳回 +// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组) + (function () { - 'use strict'; - - var doc = document; - var $ = function (sel, root) { return (root || doc).querySelector(sel); }; - var $$ = function (sel, root) { return Array.prototype.slice.call((root || doc).querySelectorAll(sel)); }; - - /* ---------------- 通用:提示与请求 ---------------- */ - - var toastTimer = null; - function toast(msg, ok) { - var el = doc.getElementById('toast'); - if (!el) { if (!ok) window.alert(msg); return; } - el.textContent = msg; - el.className = 'flash ' + (ok === false ? 'err' : 'ok'); - el.hidden = false; - clearTimeout(toastTimer); - toastTimer = setTimeout(function () { el.hidden = true; }, 4000); + // 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。 + function apiOf(action) { + return action.replace('/admin/articles/', '/admin/api/articles/'); } - function postJSON(url, body) { - return fetch(url, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}), - }).then(function (res) { - return res.json().catch(function () { return null; }).then(function (data) { - if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')')); - return data; + // ── 看大图弹层(所有页面通用)──────────────────────────────────────────── + function initZoom() { + var modal = document.getElementById('zoom-modal'); + if (!modal) return; + var img = document.getElementById('zoom-img'); + document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) { + btn.addEventListener('click', function () { + img.src = btn.querySelector('img').src; + modal.showModal(); }); }); + modal.addEventListener('click', function (e) { + if (e.target === modal) modal.close(); + }); } - // 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...) - function apiURL(action) { - return String(action).replace('/admin/reviews/', '/admin/api/reviews/'); - } - - /* ---------------- 大图查看层 ---------------- */ - - var lb = null, lbList = [], lbIndex = 0; - - function lightboxNodes() { - return $$('.cell .thumb img, .cell > img').filter(function (img) { return img.getAttribute('src'); }); - } - - function ensureLightbox() { - if (lb) return lb; - lb = doc.createElement('div'); - lb.className = 'lightbox'; - lb.hidden = true; - lb.innerHTML = - '' + - '' + - '' + - '
'; - doc.body.appendChild(lb); - lb.querySelector('.lb-close').addEventListener('click', closeLightbox); - lb.querySelector('.lb-prev').addEventListener('click', function () { stepLightbox(-1); }); - lb.querySelector('.lb-next').addEventListener('click', function () { stepLightbox(1); }); - lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); }); - return lb; - } - - function showLightbox() { - var box = ensureLightbox(); - var img = lbList[lbIndex]; - if (!img) return; - var box2 = box.querySelector('img'); - box2.src = img.getAttribute('src'); - box2.alt = img.getAttribute('alt') || ''; - var cell = img.closest ? img.closest('.cell') : null; - var nm = cell ? $('.nm', cell) : null; - box.querySelector('.lb-cap').textContent = - (nm ? nm.textContent.trim() + ' · ' : '') + (lbIndex + 1) + ' / ' + lbList.length; - box.hidden = false; - doc.body.style.overflow = 'hidden'; - } - - function closeLightbox() { - if (lb) lb.hidden = true; - doc.body.style.overflow = ''; - } - - function stepLightbox(delta) { - if (!lbList.length) return; - lbIndex = (lbIndex + delta + lbList.length) % lbList.length; - showLightbox(); - } - - function initLightbox() { - var imgs = lightboxNodes(); - if (!imgs.length) return; - imgs.forEach(function (img) { - var trigger = img.closest ? (img.closest('.thumb') || img) : img; - trigger.addEventListener('click', function (e) { - e.preventDefault(); - lbList = lightboxNodes(); - lbIndex = lbList.indexOf(img); - if (lbIndex < 0) lbIndex = 0; - showLightbox(); - }); - }); - } - - /* ---------------- 审核列表:多选 + 批量通过/驳回 ---------------- */ - - function initReviewList() { - var bar = doc.getElementById('batchbar'); - var table = $('table'); - if (!bar || !table) return; - var rows = $$('tbody tr', table).filter(function (r) { return r.dataset && r.dataset.id; }); - if (!rows.length) return; - - var selAll = doc.getElementById('sel-all'); - var counter = doc.getElementById('sel-count'); - - function picked() { - return rows.filter(function (r) { - var c = $('input.row-pick', r); - return c && c.checked; - }); - } + // ── 文章管理列表:批量通过 / 驳回 ────────────────────────────────────── + function initArticleList() { + var selAll = document.getElementById('sel-all'); + var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); }; + var bar = document.getElementById('batchbar'); + var count = document.getElementById('sel-count'); function refresh() { - var n = picked().length; - bar.hidden = n === 0; - if (counter) counter.textContent = '已选 ' + n + ' 条'; - rows.forEach(function (r) { - var c = $('input.row-pick', r); - r.classList.toggle('selected', !!(c && c.checked)); - }); - if (selAll) { - selAll.checked = n > 0 && n === rows.length; - selAll.indeterminate = n > 0 && n < rows.length; - } + var n = picks().filter(function (c) { return c.checked; }).length; + if (bar) bar.hidden = n === 0; + if (count) count.textContent = '已选 ' + n + ' 条'; } - - table.addEventListener('change', function (e) { - if (e.target.classList.contains('row-pick')) refresh(); + if (selAll) selAll.addEventListener('change', function () { + picks().forEach(function (c) { c.checked = selAll.checked; }); + refresh(); }); - if (selAll) { - selAll.addEventListener('change', function () { - rows.forEach(function (r) { - var c = $('input.row-pick', r); - if (c) c.checked = selAll.checked; - }); - refresh(); - }); - } + picks().forEach(function (c) { c.addEventListener('change', refresh); }); - function submit(action, reason) { - var items = picked().map(function (r) { - return { kind: r.dataset.kind, id: Number(r.dataset.id) }; - }); - if (!items.length) return; - var btns = $$('#batchbar button'); - btns.forEach(function (b) { b.disabled = true; }); - postJSON('/admin/api/reviews/batch', { action: action, reason: reason || '', items: items }) - .then(function (res) { - // 成功的行直接从表格移除;失败的行标红保留,便于逐条重试 - var failed = {}; - (res.failed || []).forEach(function (f) { failed[f.kind + '/' + f.id] = f.error; }); - items.forEach(function (it) { - var row = rows.filter(function (r) { - return r.dataset.kind === it.kind && Number(r.dataset.id) === it.id; - })[0]; - if (!row) return; - if (failed[it.kind + '/' + it.id]) { - row.classList.add('failed-row'); - row.title = failed[it.kind + '/' + it.id]; - return; - } - row.parentNode.removeChild(row); - rows = rows.filter(function (r) { return r !== row; }); - }); - var msg = (action === 'approve' ? '已通过 ' : '已驳回 ') + res.ok + ' 条'; - if ((res.failed || []).length) msg += ',' + res.failed.length + ' 条失败'; - toast(msg, !(res.failed || []).length); - refresh(); - }) - .catch(function (err) { toast(err.message, false); }) - .then(function () { btns.forEach(function (b) { b.disabled = false; }); }); - } - - var approveBtn = doc.getElementById('batch-approve'); - var rejectBtn = doc.getElementById('batch-reject'); - var clearBtn = doc.getElementById('batch-clear'); - if (approveBtn) { - approveBtn.addEventListener('click', function () { - var n = picked().length; - if (n && window.confirm('通过所选 ' + n + ' 条草稿并发布到正式表?')) submit('approve'); - }); - } - if (rejectBtn) { - rejectBtn.addEventListener('click', function () { - var n = picked().length; - if (!n) return; - var reason = window.prompt('驳回所选 ' + n + ' 条的理由(必填,会记录到审核留痕)'); - if (reason === null) return; - if (!reason.trim()) { toast('驳回理由不能为空', false); return; } - submit('reject', reason.trim()); - }); - } - if (clearBtn) { - clearBtn.addEventListener('click', function () { - rows.forEach(function (r) { - var c = $('input.row-pick', r); - if (c) c.checked = false; - }); - refresh(); - }); - } - - // 行内快捷通过(免进详情页) - $$('button[data-quick-approve]', table).forEach(function (btn) { - btn.addEventListener('click', function () { - var row = btn.closest('tr'); - if (!row || !window.confirm('通过并发布这条草稿?')) return; - btn.disabled = true; - postJSON('/admin/api/reviews/batch', { - action: 'approve', - items: [{ kind: row.dataset.kind, id: Number(row.dataset.id) }], - }).then(function (res) { - if ((res.failed || []).length) { - toast(res.failed[0].error || '操作失败', false); - btn.disabled = false; - return; - } - toast('已通过', true); - row.parentNode.removeChild(row); - rows = rows.filter(function (r) { return r !== row; }); - refresh(); - }).catch(function (err) { - toast(err.message, false); - btn.disabled = false; - }); - }); + var clear = document.getElementById('batch-clear'); + if (clear) clear.addEventListener('click', function () { + picks().forEach(function (c) { c.checked = false; }); + if (selAll) selAll.checked = false; + refresh(); }); - refresh(); - } - - /* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */ - - function initReviewDetail() { - var grid = $('.grid'); - if (!grid) return; - var cells = $$('.cell', grid); - var attachForm = doc.getElementById('batch'); - - // 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆) - function applyGroups(res) { - var images = res.images || []; - var byId = {}; - images.forEach(function (i) { byId[i.id] = i; }); - cells.forEach(function (cell) { - var st = byId[Number(cell.dataset.imgId)]; - if (!st) return; - cell.classList.toggle('det', st.isDetail === 1); - var role = $('.role', cell); - if (role) { - role.textContent = st.isDetail === 1 - ? ('副图 → #' + st.parentId) - : ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : '')); + function postBatch(action) { + var items = picks().filter(function (c) { + return c.checked; + }).map(function (c) { + var tr = c.closest('tr'); + return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) }; + }); + if (items.length === 0) { alert('请先勾选要操作的记录'); return; } + if (!confirm('确认' + (action === 'approve' ? '通过' : '驳回') + '选中的 ' + items.length + ' 条?')) return; + fetch('/admin/api/articles/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action, items: items }) + }).then(function (r) { return r.json(); }).then(function (d) { + if (d.failed && d.failed.length) { + alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error); } - $$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; }); - $$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; }); + location.reload(); + }).catch(function (e) { alert('请求失败:' + e); }); + } + var approve = document.getElementById('batch-approve'); + var reject = document.getElementById('batch-reject'); + if (approve) approve.addEventListener('click', function () { postBatch('approve'); }); + if (reject) reject.addEventListener('click', function () { postBatch('reject'); }); + } + + // ── 街拍编辑页:主副图分组(合并 / 拆出)────────────────────────────── + function initArticleDetail() { + var batchForm = document.getElementById('batch'); + var stat = document.getElementById('img-stat'); + var hint = document.getElementById('hint-dismiss'); + if (hint) hint.addEventListener('click', function () { + var h = hint.closest('.hint'); if (h) h.style.display = 'none'; + }); + + // 每个主图各自的「显示副图」开关:点一下只展开/收起该主图紧邻的副图(不会一次全开)。 + var grid = document.querySelector('.grid'); + if (grid) { + grid.addEventListener('click', function (e) { + var btn = e.target.closest('.toggle-det'); + if (!btn) return; + var pid = btn.getAttribute('data-parent'); + var dets = grid.querySelectorAll('.cell.det[data-parent="' + pid + '"]'); + var expand = false; + dets.forEach(function (d) { if (!d.classList.contains('shown')) expand = true; }); + dets.forEach(function (d) { d.classList.toggle('shown', expand); }); + btn.textContent = expand + ? ('隐藏副图(' + dets.length + ' 张)') + : ('显示副图(' + dets.length + ' 张)'); }); - var stat = doc.getElementById('img-stat'); - if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组'; } - // 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用) - function intercept(form, buildBody) { - form.addEventListener('submit', function (e) { + // 合并选中为一组(AJAX,成功后就地更新标签) + if (batchForm) batchForm.addEventListener('submit', function (e) { + e.preventDefault(); + // 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在 + //
内部(包在 .cell 里)。它们虽能随表单正确提交,却不在 form 子树中, + // 故必须用 document 级查询(而非 batchForm.querySelector)才能取到已勾选的项。 + var main = document.querySelector('input[name="main"]:checked'); + var picks = document.querySelectorAll('input[name="img"]:checked'); + if (!main) { alert('请先在其中一张主图上点「主图」'); return; } + if (!picks.length) { alert('请勾选要合并的图片'); return; } + // 接口 ArticleAttachImagesAPI 收 JSON(main + imgs),不能发 FormData: + // multipart 会让 ShouldBindJSON 失败、返回「请求格式错误」。 + var data = { + main: parseInt(main.value, 10), + imgs: Array.prototype.slice.call(picks).map(function (c) { return parseInt(c.value, 10); }) + }; + fetch(apiOf(batchForm.getAttribute('action')), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { applyImages(d.images, kindOf(batchForm.getAttribute('action')), main.value); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; }) + .catch(function (err) { alert('合并失败:' + err); }); + }); + + // 单张拆出(AJAX) + document.querySelectorAll('form[data-only="detail"]').forEach(function (f) { + f.addEventListener('submit', function (e) { e.preventDefault(); - var body = buildBody(form); - if (body === null) return; // 校验未过:交给原生提交去报错 - postJSON(apiURL(form.getAttribute('action')), body) - .then(function (res) { - applyGroups(res); - cells.forEach(function (c) { - var chk = $('input[type="checkbox"]', c); - if (chk) chk.checked = false; - var radio = $('input[type="radio"]', c); - if (radio) radio.checked = false; - }); - toast('已更新分组', true); - }) - .catch(function (err) { toast(err.message, false); }); + fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) }) + .then(function (r) { return r.json(); }) + .then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; }) + .catch(function (err) { alert('拆出失败:' + err); }); }); - } - - if (attachForm) { - intercept(attachForm, function (form) { - var main = $('input[type="radio"]:checked', grid); - var imgs = $$('input[type="checkbox"]:checked', grid).map(function (c) { return Number(c.value); }); - if (!main || !imgs.length) { toast('请勾选图片,并在其中一张主图上点「主图」', false); return null; } - return { main: Number(main.value), imgs: imgs }; - }); - } - $$('form[action*="/detach"]', grid).forEach(function (form) { - intercept(form, function () { return {}; }); }); - - // ---- 键盘流(审核图片时手不离键盘) ---- - var focusIndex = -1; - // scroll=false 用于初始化:只标记第一格,不要因为 scrollIntoView 把页面顶端的 - // 字段表单(标题/年份/城市 + 通过按钮)滚出视野 —— 进页面必须先看到待填字段。 - function setFocus(i, scroll) { - if (!cells.length) return; - focusIndex = Math.max(0, Math.min(cells.length - 1, i)); - cells.forEach(function (c, j) { c.classList.toggle('focus', j === focusIndex); }); - var el = cells[focusIndex]; - if (scroll !== false && el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); - } - function inField(t) { - return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); - } - doc.addEventListener('keydown', function (e) { - if (!lb.hidden && lb) { - if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); } - else if (e.key === 'ArrowLeft') { stepLightbox(-1); e.preventDefault(); } - else if (e.key === 'ArrowRight') { stepLightbox(1); e.preventDefault(); } - return; - } - if (inField(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; - var cur = cells[focusIndex]; - switch (e.key) { - case 'j': setFocus(focusIndex + 1); e.preventDefault(); break; - case 'k': setFocus(focusIndex - 1); e.preventDefault(); break; - case 'Enter': - if (cur) { - var t = $('.thumb', cur) || $('img', cur); - if (t) { t.click(); e.preventDefault(); } - } - break; - case 'x': - if (cur) { - var chk = $('input[type="checkbox"]', cur); - if (chk && !chk.disabled) { chk.checked = !chk.checked; e.preventDefault(); } - } - break; - case 'm': - if (cur) { - var radio = $('input[type="radio"]', cur); - if (radio) { radio.checked = true; e.preventDefault(); } - } - break; - case 'd': - if (cur) { - var df = $('form[action*="/detach"]', cur); - if (df) { df.requestSubmit ? df.requestSubmit() : df.submit(); e.preventDefault(); } - } - break; - case 'a': - // 通过 = 提交页面上那个「通过并发布」表单(复用当前输入框里的字段微调),故加一次确认 - var af = $('form[action$="/approve"]'); - if (af && window.confirm('通过并发布这条草稿?')) { - af.requestSubmit ? af.requestSubmit() : af.submit(); - e.preventDefault(); - } - break; - } - }); - - // 进页面先标记第一格(不滚动),键盘可直接用 - if (cells.length) setFocus(0, false); } - /* ---------------- 启动 ---------------- */ + // 从 /admin/articles//... 形式的 action 取出实体类型(street / runway)。 + function kindOf(action) { + var m = /^\/admin\/articles\/(\w+)\//.exec(action || ''); + return m ? m[1] : 'street'; + } - function boot() { - // 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题) - doc.documentElement.setAttribute('data-admin-js', 'ready'); - initLightbox(); - initReviewList(); - initReviewDetail(); - var hint = doc.getElementById('hint-dismiss'); - if (hint) { - hint.addEventListener('click', function () { - var box = hint.closest('.hint'); - if (box) box.hidden = true; - }); + // 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。 + function updateToggle(mainId, detailCount) { + var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]'); + var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]'); + if (detailCount > 0) { + if (!btn && cell) { + btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'toggle-det'; + btn.setAttribute('data-parent', mainId); + var acts = cell.querySelector('.acts'); + if (acts) acts.appendChild(btn); + } + var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]'); + var expanded = dets.length > 0 && dets[0].classList.contains('shown'); + if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + detailCount + ' 张)'; + } else if (btn) { + btn.remove(); } } - if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot); - else boot(); + // 展开/收起某主图的全部副图,并同步按钮文案。 + function setExpanded(mainId, expanded) { + var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]'); + dets.forEach(function (d) { d.classList.toggle('shown', expanded); }); + var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]'); + if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + dets.length + ' 张)'; + } + + // 用服务端返回的最新图片投影,重写网格里的角色标签、副图开关与统计。 + // kind 决定细节图文案;focusMain 为合并操作的目标主图 id,合并后自动展开其副图。 + function applyImages(images, kind, focusMain) { + if (!images) return; + images.forEach(function (im) { + var cell = document.querySelector('.cell[data-img-id="' + im.id + '"]'); + if (!cell) return; + cell.classList.toggle('det', im.isDetail === 1); + if (im.isDetail === 1) { + cell.setAttribute('data-parent', im.parentId); + } else { + cell.removeAttribute('data-parent'); + updateToggle(im.id, im.detailCount); + } + var role = cell.querySelector('.role'); + if (role) { + if (im.isDetail === 1) { + role.textContent = (kind === 'runway' ? '细节图' : '副图') + ' → #' + im.parentId; + } else { + role.textContent = '主图' + (im.detailCount ? (' · ' + im.detailCount + ' 副图') : ''); + } + } + }); + if (focusMain) setExpanded(focusMain, true); + } + + document.addEventListener('DOMContentLoaded', function () { + initZoom(); + if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList(); + if (document.getElementById('batch')) initArticleDetail(); + }); })(); diff --git a/internal/handler/backstage_api.go b/internal/handler/backstage_api.go index ecfc89b..991e4f0 100644 --- a/internal/handler/backstage_api.go +++ b/internal/handler/backstage_api.go @@ -3,7 +3,7 @@ // 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路, // 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。 // 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」, -// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。 +// 同时表单式路由(/admin/articles/... 下的 approve/reject/attach/detach)原样保留:关掉 JS 后台依然可用。 // // 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。 package handler @@ -32,13 +32,13 @@ type reviewBatchReq struct { Items []reviewBatchItem `json:"items"` } -// ReviewBatch 批量通过 / 驳回审核记录。 +// ArticleBatch 文章管理批量通过 / 驳回(替代原审核批量接口)。 // // 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]} -// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17} +// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17} // // 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。 -func (h *BackstageHandler) ReviewBatch(c *gin.Context) { +func (h *BackstageHandler) ArticleBatch(c *gin.Context) { var req reviewBatchReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) @@ -99,10 +99,10 @@ type reviewAttachReq struct { Imgs []uint32 `json:"imgs"` } -// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。 +// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。 // 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。 -// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。 -func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) { +// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。 +func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) { kind := c.Param("kind") id := atoiDefault(c.Param("id"), 0) if id == 0 { @@ -129,8 +129,8 @@ func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) { h.respondRecordImages(c, kind, uint32(id)) } -// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。 -func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) { +// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。 +func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) { kind := c.Param("kind") id := atoiDefault(c.Param("id"), 0) imgID := atoiDefault(c.Param("img"), 0) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 81f8244..c493092 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -1,6 +1,7 @@ package handler import ( + "context" "fmt" "net/http" "strconv" @@ -8,7 +9,9 @@ import ( "fashionapi/internal/dto" "fashionapi/internal/middleware" + "fashionapi/internal/model" "fashionapi/internal/pkg/hashid" + "fashionapi/internal/repository" "fashionapi/internal/pkg/pagination" "fashionapi/internal/service" @@ -24,17 +27,18 @@ const adminCookie = "admin_session" // 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权, // 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。 type BackstageHandler struct { - auth service.AuthService - brand service.BrandService - article service.ArticleService - street service.StreetSnapService - review service.ReviewService - ingest *service.IngestService + auth service.AuthService + brand service.BrandService + article service.ArticleService + street service.StreetSnapService + review service.ReviewService + ingest *service.IngestService + adminRepo repository.AdminRepository } // NewBackstageHandler 创建后台 handler。 -func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler { - return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest} +func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler { + return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo} } // 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed), @@ -296,7 +300,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) return } - snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10)) + snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en") if err != nil { renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap}) } -// ── 审核记录 ─────────────────────────────────────────────────────────────────── +// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)───────────── -// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。 -// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。 -func (h *BackstageHandler) ReviewList(c *gin.Context) { - kind := c.Param("kind") - if kind == "" { - kind = c.Query("kind") - } +// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。 +// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。 +func (h *BackstageHandler) Articles(c *gin.Context) { status := c.Query("status") + kind := c.Query("kind") + if kind != "runway" && kind != "street" { + kind = "" // 非法的 kind 回落为「全部」 + } page := atoiDefault(c.Query("page"), 1) - if page < 1 { - page = 1 - } - // 校验 kind 是否在注册表中;未知 kind 退回「全部」。 - kinds := h.review.KindTabs(c.Request.Context()) - registered := map[string]bool{} - for _, t := range kinds { - if t.Kind != "" { - registered[t.Kind] = true - } - } - if kind != "" && kind != "all" && !registered[kind] { - kind = "" - } - records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20) + const size = 24 + cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size) if err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - // 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews - listBase := "" - if kind != "" && kind != "all" { - listBase = "/" + kind + // 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。 + dupStats := h.dupStatsByCards(c.Request.Context(), cards) + items := make([]gin.H, 0, len(cards)) + for _, card := range cards { + var t byte + if card.Kind == "runway" { + t = hashid.TypeRunway + } else { + t = hashid.TypeSnap + } + ds := dupStats[card.ID] + dupPct := 0.0 + if ds.Total > 0 { + dupPct = float64(ds.Dup) / float64(ds.Total) * 100 + } + items = append(items, gin.H{ + "Kind": card.Kind, + "UID": hashid.EncodeWithType(card.ID, t), + "ID": card.ID, + "Title": card.Title, + "Subtitle": card.Subtitle, + "ImageCount": card.ImageCount, + "Status": card.Status, + "DupCount": ds.Dup, + "DupPct": dupPct, + }) } - p := pagination.Params{Page: page, PageSize: 20} + // 状态 tab 角标(空串=全部)。 + tabCounts := map[string]int64{} + for _, s := range []string{"", "pending", "published", "rejected"} { + n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind) + if e == nil { + tabCounts[s] = n + } + } + p := pagination.Params{Page: page, PageSize: size} meta := p.NewMeta(total) - renderPage(c, http.StatusOK, "review-list", gin.H{ - "Records": records, - "Total": total, - "Status": status, - "Kind": kind, - "Kinds": kinds, - "ListBase": listBase, + renderPage(c, http.StatusOK, "articles", gin.H{ + "Items": items, + "Status": status, + "Kind": kind, + "TabCounts": tabCounts, "Pager": pagination.Pager{ PrevURL: pagination.PageLink(c, meta.PrevPage), NextURL: pagination.PageLink(c, meta.NextPage), @@ -358,96 +377,74 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) { }) } -// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。 -func (h *BackstageHandler) ReviewDetail(c *gin.Context) { - kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) - if id == 0 { - renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")}) - return +// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。 +// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。 +func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat { + out := make(map[uint32]repository.DupStat) + runwayIDs := make([]uint32, 0, len(cards)) + streetIDs := make([]uint32, 0, len(cards)) + for _, c := range cards { + if c.Kind == "runway" { + runwayIDs = append(runwayIDs, c.ID) + } else { + streetIDs = append(streetIDs, c.ID) + } } - record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id)) - if err != nil { - renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")}) - return - } - // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 - brandName := "" - if record.BrandID > 0 { - if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil { - brandName = b.NameEn - if brandName == "" { - brandName = b.NameCn + if len(runwayIDs) > 0 { + if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil { + for id, s := range m { + out[id] = s } } } - renderPage(c, http.StatusOK, "review-detail", gin.H{ - "Record": record, - "BrandName": brandName, - }) + if len(streetIDs) > 0 { + if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil { + for id, s := range m { + out[id] = s + } + } + } + return out } -// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。 -func (h *BackstageHandler) ReviewApprove(c *gin.Context) { +// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。 +// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。 +func (h *BackstageHandler) ArticleApprove(c *gin.Context) { kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) + id := uint32(atoiDefault(c.Param("id"), 0)) if id == 0 { - renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")}) - return - } - fields := reviewFormFields(c) - if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil { - renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) + renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) return } username, _ := c.Get(middleware.ContextUsername) - if _, err := h.review.Approve(c.Request.Context(), kind, uint32(id), usernameString(username)); err != nil { + if _, err := h.review.Approve(c.Request.Context(), kind, id, usernameString(username)); err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, reviewListURL(kind)) + c.Redirect(http.StatusFound, "/admin/articles?status=pending") } -// ReviewReject 驳回:仅记录状态与理由。 -func (h *BackstageHandler) ReviewReject(c *gin.Context) { +// ArticleReject 驳回:记录状态与理由(理由用于审核留痕)。 +func (h *BackstageHandler) ArticleReject(c *gin.Context) { kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) + id := uint32(atoiDefault(c.Param("id"), 0)) if id == 0 { - renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")}) + renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) return } reason := c.PostForm("reason") username, _ := c.Get(middleware.ContextUsername) - if err := h.review.Reject(c.Request.Context(), kind, uint32(id), usernameString(username), reason); err != nil { + if err := h.review.Reject(c.Request.Context(), kind, id, usernameString(username), reason); err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, reviewListURL(kind)) + c.Redirect(http.StatusFound, "/admin/articles?status=pending") } -// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。 -// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。 -func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) { +// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。 +func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) { kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) - imgID := atoiDefault(c.Param("img"), 0) - if id == 0 || imgID == 0 { - renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) - return - } - if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { - renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) - return - } - c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) -} - -// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。 -// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。 -// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。) -func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) { - kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) + id := uint32(atoiDefault(c.Param("id"), 0)) if id == 0 { renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) return @@ -459,48 +456,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) { imgIDs = append(imgIDs, uint32(v)) } } - if len(imgIDs) == 0 { - renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"}) + if len(imgIDs) == 0 || mainID == 0 { + renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"}) return } - if mainID == 0 { - renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"}) - return - } - if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil { + if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) + c.Redirect(http.StatusFound, articleEditURL(kind, id)) } -// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。 -func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) { +// ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。 +func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) { kind := c.Param("kind") - id := atoiDefault(c.Param("id"), 0) - imgID := atoiDefault(c.Param("img"), 0) - if id == 0 || imgID == 0 { + id := uint32(atoiDefault(c.Param("id"), 0)) + if id == 0 { renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) return } - if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil { + imgID := atoiDefault(c.Param("img"), 0) + if imgID == 0 { + renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) + return + } + if err := h.review.DetachImage(c.Request.Context(), kind, id, uint32(imgID)); err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) + c.Redirect(http.StatusFound, articleEditURL(kind, id)) } -// reviewDetailURL 生成审核详情页跳转地址。 -func reviewDetailURL(kind string, id uint32) string { - return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10) -} - -// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 -func reviewListURL(kind string) string { - if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet { - return "/admin/reviews/" + kind +// articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。 +// 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。 +func articleEditURL(kind string, id uint32) string { + var t byte + if kind == "runway" { + t = hashid.TypeRunway + } else { + t = hashid.TypeSnap } - return "/admin/reviews" + if kind == "runway" { + return "/admin/runways/" + hashid.EncodeWithType(id, t) + "/edit" + } + return "/admin/street-snaps/" + hashid.EncodeWithType(id, t) + "/edit" } // ── 爬虫入库任务监控 ────────────────────────────────────────────────────────── @@ -637,12 +636,76 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) { brandName = b.NameCn } } + cells := make([]gin.H, 0, len(imgs)) + var dupCount int + // 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。 + detailsByParent := map[uint32][]model.BrandRunwayImage{} + var mains []model.BrandRunwayImage + for _, im := range imgs { + if im.IsDuplicate == 1 { + dupCount++ + } + if im.IsDetail == 1 { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) + } else { + mains = append(mains, im) + } + } + mainIDs := map[uint32]bool{} + for _, m := range mains { + mainIDs[m.ID] = true + cells = append(cells, gin.H{ + "id": m.ID, + "image": m.Image, + "name": m.Name, + "isDetail": 0, + "role": "主图", + "DetCount": len(detailsByParent[m.ID]), + }) + for _, d := range detailsByParent[m.ID] { + cells = append(cells, gin.H{ + "id": d.ID, + "image": d.Image, + "name": d.Name, + "isDetail": 1, + "role": fmt.Sprintf("细节图 → #%d", d.ParentImageID), + "parent": d.ParentImageID, + }) + } + } + // 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。 + for pid, ds := range detailsByParent { + if mainIDs[pid] { + continue + } + for _, d := range ds { + cells = append(cells, gin.H{ + "id": d.ID, + "image": d.Image, + "name": d.Name, + "isDetail": 1, + "role": fmt.Sprintf("细节图 → #%d", d.ParentImageID), + "parent": d.ParentImageID, + "orphan": true, + }) + } + } + dupPct := 0.0 + if n := len(imgs); n > 0 { + dupPct = float64(dupCount) / float64(n) * 100 + } renderPage(c, http.StatusOK, "runway-edit", gin.H{ "UID": raw, + "Kind": "runway", + "RecordID": rw.ID, + "Status": rw.Status, "Runway": rw, "Images": imgs, + "Cells": cells, "BrandName": brandName, "IsDown": rw.IsDeleted == 1, + "DupCount": dupCount, + "DupPct": dupPct, }) } @@ -674,7 +737,7 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/runways/"+raw) + c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit") } // RunwayImageDelete 软删单张走秀图片。 @@ -693,7 +756,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/runways/"+raw) + c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit") } // RunwaySetCover 把某张图片设为封面。 @@ -713,7 +776,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/runways/"+raw) + c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit") } // RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。 @@ -732,7 +795,7 @@ func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/runways/"+raw) + c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit") } // ── 街拍编辑 ───────────────────────────────────────────────────────────────────── @@ -750,11 +813,75 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) { renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) return } + cells := make([]gin.H, 0, len(imgs)) + var dupCount int + // 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。 + detailsByParent := map[uint32][]model.StreetSnapImage{} + var mains []model.StreetSnapImage + for _, im := range imgs { + if im.IsDuplicate == 1 { + dupCount++ + } + if im.IsDetail == 1 { + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) + } else { + mains = append(mains, im) + } + } + mainIDs := map[uint32]bool{} + for _, m := range mains { + mainIDs[m.ID] = true + cells = append(cells, gin.H{ + "id": m.ID, + "image": m.Image, + "name": m.Name, + "isDetail": 0, + "role": "主图", + "DetCount": len(detailsByParent[m.ID]), + }) + for _, d := range detailsByParent[m.ID] { + cells = append(cells, gin.H{ + "id": d.ID, + "image": d.Image, + "name": d.Name, + "isDetail": 1, + "role": fmt.Sprintf("副图 → #%d", d.ParentImageID), + "parent": d.ParentImageID, + }) + } + } + // 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。 + for pid, ds := range detailsByParent { + if mainIDs[pid] { + continue + } + for _, d := range ds { + cells = append(cells, gin.H{ + "id": d.ID, + "image": d.Image, + "name": d.Name, + "isDetail": 1, + "role": fmt.Sprintf("副图 → #%d", d.ParentImageID), + "parent": d.ParentImageID, + "orphan": true, + }) + } + } + dupPct := 0.0 + if n := len(imgs); n > 0 { + dupPct = float64(dupCount) / float64(n) * 100 + } renderPage(c, http.StatusOK, "snap-edit", gin.H{ - "UID": raw, - "Snap": snap, - "Images": imgs, - "IsDown": snap.IsDeleted == 1, + "UID": raw, + "Kind": "street", + "RecordID": snap.ID, + "Status": snap.Status, + "Snap": snap, + "Images": imgs, + "Cells": cells, + "IsDown": snap.IsDeleted == 1, + "DupCount": dupCount, + "DupPct": dupPct, }) } @@ -773,15 +900,16 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) { } } fields := map[string]any{ - "title": c.PostForm("title"), - "city": c.PostForm("city"), - "year": year, + "title": c.PostForm("title"), + "title_cn": c.PostForm("title_cn"), + "city": c.PostForm("city"), + "year": year, } if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit") } // StreetSnapImageDelete 软删单张街拍图片。 @@ -800,7 +928,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit") } // StreetSnapSetCover 把某张图片设为封面。 @@ -820,7 +948,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit") } // StreetSnapToggleDeleted 上下架。 @@ -839,7 +967,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) { renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) return } - c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) + c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit") } // atoiDefault 把字符串转 int,失败回落默认值。 diff --git a/internal/handler/backstage_handler_test.go b/internal/handler/backstage_handler_test.go index 681b95c..40be88e 100644 --- a/internal/handler/backstage_handler_test.go +++ b/internal/handler/backstage_handler_test.go @@ -24,20 +24,17 @@ import ( // (renderPage 会 500 "unknown backstage page: xxx")。 func TestBackstageTemplatesParsed(t *testing.T) { want := []string{ + "articles", "brand-form", "brands", "dashboard", "error", "ingest-jobs", "login", - "review-detail", - "review-list", "runway-detail", "runway-edit", - "runways", "snap-edit", "street-snap-detail", - "street-snaps", "users", } got := PageNames() @@ -74,7 +71,7 @@ func TestBackstageTemplatesRender(t *testing.T) { "UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "", "Images": []any{}, // 列表数据 - "Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, + "Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{}, // 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖) "Article": &dto.PublicArticleDetail{UID: "abc123"}, "Snap": &dto.PublicStreetSnapDetail{UID: "abc123"}, @@ -88,19 +85,20 @@ func TestBackstageTemplatesRender(t *testing.T) { for _, page := range PageNames() { for _, kind := range []string{"street", "runway"} { - if page != "review-detail" && kind == "runway" { - continue // 只有审核详情页按 kind 分流 - } - name := page - if page == "review-detail" { - name = page + "/" + kind - } + name := page + "/" + kind t.Run(name, func(t *testing.T) { data := cloneData(base) for k, v := range perPage[page] { data[k] = v } data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"} + // 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段: + data["Kind"] = kind + data["RecordID"] = uint32(7) + data["UID"] = "abc123" + data["Status"] = "pending" + data["Cells"] = []gin.H{} + data["BrandName"] = "Brand" w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 78b7766..c8b64bc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -58,9 +58,13 @@ func queryBool(c *gin.Context, key string, def bool) bool { return v == "1" || v == "true" } -// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。 -// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。 +// parseLocale 解析请求语言,优先级:X-Locale 头 > ?locale=cn|en > Accept-Language > 默认 en。 +// 与前端 i18n 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。 +// X-Locale 由前端统一经请求头透传「当前页面语言」,显式且不受浏览器语言设置干扰。 func parseLocale(c *gin.Context) string { + if l := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Locale"))); l == "cn" || l == "en" { + return l + } if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" { return l } diff --git a/internal/handler/ssg_handler.go b/internal/handler/ssg_handler.go index c6c8b8d..a05ae0f 100644 --- a/internal/handler/ssg_handler.go +++ b/internal/handler/ssg_handler.go @@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) { // GET /api/v1/ssg/street-snaps/popular func (h *SSGHandler) StreetSnapPopular(c *gin.Context) { const limit = 20 - list, err := h.snaps.Popular(c.Request.Context(), limit) + list, err := h.snaps.Popular(c.Request.Context(), limit, parseLocale(c)) if err != nil { fail(c, err) return diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go index ec4f656..3ceb02a 100644 --- a/internal/handler/street_snap_handler.go +++ b/internal/handler/street_snap_handler.go @@ -82,12 +82,12 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) { return } - detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10)) + detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c)) if err != nil { // 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。 // 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。 if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap { - if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10)); e2 == nil { + if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil { detail, err = d2, nil } } diff --git a/internal/handler/templates.go b/internal/handler/templates.go index 2c760f0..1e9c337 100644 --- a/internal/handler/templates.go +++ b/internal/handler/templates.go @@ -24,9 +24,23 @@ import ( "net/http" "strings" + "fashionapi/internal/pkg/imgurl" + "github.com/gin-gonic/gin" ) +// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。 +// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。 +var imgComposer = imgurl.New("", "high", "thumb") + +// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。 +// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。 +func SetImgComposer(c *imgurl.Composer) { + if c != nil { + imgComposer = c + } +} + //go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/* var backstageFS embed.FS @@ -42,8 +56,10 @@ var assetFS fs.FS func init() { funcs := template.FuncMap{ - // reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。 - "reviewDetailURL": reviewDetailURL, + // imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL; + // imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。 + "imgURL": func(stored string) string { return imgComposer.Compose(stored) }, + "imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) }, } base, err := template.New("layout.html").Funcs(funcs). @@ -94,7 +110,7 @@ func PageNames() []string { // renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。 // -// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上 +// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上 // NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。 func renderPage(c *gin.Context, status int, page string, data gin.H) { tpl, ok := pageTemplates[page] diff --git a/internal/handler/templates/layout.html b/internal/handler/templates/layout.html index 732f1eb..ccc7426 100644 --- a/internal/handler/templates/layout.html +++ b/internal/handler/templates/layout.html @@ -20,6 +20,7 @@ {{template "content" .}} + diff --git a/internal/handler/templates/pages/articles.html b/internal/handler/templates/pages/articles.html new file mode 100644 index 0000000..8da083b --- /dev/null +++ b/internal/handler/templates/pages/articles.html @@ -0,0 +1,59 @@ +{{define "title"}}文章管理 · 管理后台{{end}} +{{define "barLabel"}}管理后台 · 文章管理{{end}} +{{define "barRight"}}退出{{end}} +{{define "content"}} +{{template "bar" .}} +
+

文章管理

+

走秀与街拍合并管理:通过 / 驳回 / 编辑 / 下架都在此处发起。待审核优先排在前面。

+ + + +
+ + + + + + +{{range .Items}} + + + + + + + + + +{{end}} + +
类型ID标题副标题图数重复率状态操作
{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.ID}}{{.Title}}{{.Subtitle}}{{.ImageCount}}{{if .DupCount}}{{printf "%.0f" .DupPct}}%({{.DupCount}}){{else}}—{{end}}{{.Status}} +
+{{if eq .Kind "runway"}}编辑{{else}}编辑{{end}} +{{if eq .Status "pending"}}{{end}} +{{if eq .Status "pending"}}
{{end}} +{{if eq .Kind "runway"}}
{{else}}
{{end}} +
+
+
+{{template "pager" .Pager}} + + +
+{{end}} diff --git a/internal/handler/templates/pages/dashboard.html b/internal/handler/templates/pages/dashboard.html index 07bc597..1655342 100644 --- a/internal/handler/templates/pages/dashboard.html +++ b/internal/handler/templates/pages/dashboard.html @@ -8,9 +8,7 @@ {{if .Kinds}}
{{range .Kinds}}{{if ne .Kind ""}}{{.Label}}:待审 {{.Pending}}{{end}}{{end}}
{{end}} diff --git a/internal/handler/templates/pages/review-detail.html b/internal/handler/templates/pages/review-detail.html deleted file mode 100644 index ecb2d03..0000000 --- a/internal/handler/templates/pages/review-detail.html +++ /dev/null @@ -1,56 +0,0 @@ -{{define "title"}}草稿审核 · 管理后台{{end}} -{{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}} -{{define "barRight"}}返回列表 · 退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

类型 {{.Record.Kind}}{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 {{.Record.Status}}

- -{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}} -
-{{range .Record.Fields}}
{{if eq .Type "textarea"}}{{else}}{{end}}
{{end}} - -
- -{{if eq .Record.Kind "street"}} -
-操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
-快捷键:j/k 上下移动 · x 勾选 · m 设为主图 · d 拆出 · Enter 看大图 · a 通过 - -
- -

图片({{len .Record.Images}} 张 · {{len .Record.Groups}} 组)

-{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}} -
-
-{{range .Record.Images}}
- -{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}} -{{.Name}} -
-{{if eq .IsDetail 0}}{{end}} - -{{if eq .IsDetail 1}}
{{end}} -
-
-
{{end}} -
- -
- -勾选同一人的图,并在其中一张主图上点「主图」 -
-{{else}} -

图片管理({{len .Record.Images}} 张)

-
-{{range .Record.Images}}
- -{{.Name}} -
-
-
-
{{end}} -
-{{end}} -
-{{end}} diff --git a/internal/handler/templates/pages/review-list.html b/internal/handler/templates/pages/review-list.html deleted file mode 100644 index 61d2ffb..0000000 --- a/internal/handler/templates/pages/review-list.html +++ /dev/null @@ -1,50 +0,0 @@ -{{define "title"}}待审草稿 · 管理后台{{end}} -{{define "barLabel"}}待审草稿{{end}} -{{define "barRight"}}退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

待审草稿(共 {{.Total}} 条)

- - - -{{/* 多选 + 批量操作:勾选后底部出现操作条(admin.js 接管;无 JS 时单条「查看/审核」照常可用) */}} -
- - - - - - -{{range .Records}} - - - - - - - - -{{end}} - -
ID类型标题副标题图数状态操作
{{.ID}}{{if eq .Kind "street"}}{{.Kind}}{{else}}{{.Kind}}{{end}}{{.Title}}{{.Subtitle}}{{.ImageCount}}{{.Status}}查看/审核{{if eq .Status "pending"}} · {{end}}
-
-{{template "pager" .Pager}} - - -
-{{end}} diff --git a/internal/handler/templates/pages/runway-detail.html b/internal/handler/templates/pages/runway-detail.html index 2263570..d03b775 100644 --- a/internal/handler/templates/pages/runway-detail.html +++ b/internal/handler/templates/pages/runway-detail.html @@ -1,13 +1,13 @@ {{define "title"}}走秀详情 · 管理后台{{end}} {{define "barLabel"}}管理后台 · 走秀详情{{end}} -{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} {{define "content"}} {{template "bar" .}}

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

{{.Article.Title}}

-{{range .Article.Images}}
{{.Name}}
{{end}} +{{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 index 6c2489f..914b351 100644 --- a/internal/handler/templates/pages/runway-edit.html +++ b/internal/handler/templates/pages/runway-edit.html @@ -13,16 +13,14 @@ -

图片管理({{len .Images}} 张)

-
-{{range .Images}}
- -
-
-
-
-
{{end}} +{{template "imageGrid" .}} + +{{if eq .Runway.Status "pending"}} +
+
+
+{{end}}
{{if .IsDown}}{{else}}{{end}} diff --git a/internal/handler/templates/pages/runways.html b/internal/handler/templates/pages/runways.html deleted file mode 100644 index 05de100..0000000 --- a/internal/handler/templates/pages/runways.html +++ /dev/null @@ -1,39 +0,0 @@ -{{define "title"}}走秀档案 · 管理后台{{end}} -{{define "barLabel"}}管理后台 · 走秀档案{{end}} -{{define "barRight"}}退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

走秀列表

- - - - - - -清除 - - - - -{{range .Items}}{{end}} - -
编码 ID品牌标题系列季节年份季节码图数状态
{{.UID}}{{.BrandName}}{{.Title}}{{.CollectionType}}{{.Season}}{{.Year}}{{.SeasonCode}}{{.ImageCount}}{{.Status}}查看
-{{template "pager" .Pager}} -
-{{end}} diff --git a/internal/handler/templates/pages/snap-edit.html b/internal/handler/templates/pages/snap-edit.html index 8a7a981..7513137 100644 --- a/internal/handler/templates/pages/snap-edit.html +++ b/internal/handler/templates/pages/snap-edit.html @@ -6,21 +6,19 @@

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

-
+
-

图片管理({{len .Images}} 张)

-
-{{range .Images}}
- -
-
-
-
-
{{end}} +{{template "imageGrid" .}} + +{{if eq .Snap.Status "pending"}} +
+
+
+{{end}}
{{if .IsDown}}{{else}}{{end}} diff --git a/internal/handler/templates/pages/street-snap-detail.html b/internal/handler/templates/pages/street-snap-detail.html index b70fa94..851496d 100644 --- a/internal/handler/templates/pages/street-snap-detail.html +++ b/internal/handler/templates/pages/street-snap-detail.html @@ -1,13 +1,13 @@ {{define "title"}}街拍详情 · 管理后台{{end}} {{define "barLabel"}}管理后台 · 街拍详情{{end}} -{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} +{{define "barRight"}}返回列表 · 编辑 · 退出{{end}} {{define "content"}} {{template "bar" .}}

编码 {{.Snap.UID}}

{{.Snap.Title}}

-{{range .Snap.Images}}
{{.Name}}
{{end}} +{{range .Snap.Images}}
{{.Name}}
{{range .Detail}}
{{.Name}} · 副图
{{end}}{{end}}
{{end}} diff --git a/internal/handler/templates/pages/street-snaps.html b/internal/handler/templates/pages/street-snaps.html deleted file mode 100644 index e182b14..0000000 --- a/internal/handler/templates/pages/street-snaps.html +++ /dev/null @@ -1,27 +0,0 @@ -{{define "title"}}街拍 · 管理后台{{end}} -{{define "barLabel"}}管理后台 · 街拍{{end}} -{{define "barRight"}}退出{{end}} -{{define "content"}} -{{template "bar" .}} -
-

街拍列表

- - - - - -清除 - - - - -{{range .Items}}{{end}} - -
编码 ID标题图片数状态
{{.UID}}{{.Title}}{{.ImageCount}}{{.Status}}查看
-{{template "pager" .Pager}} -
-{{end}} diff --git a/internal/handler/templates/partials.html b/internal/handler/templates/partials.html index e82ebd7..443e2cd 100644 --- a/internal/handler/templates/partials.html +++ b/internal/handler/templates/partials.html @@ -16,9 +16,7 @@ {{template "barLabel" .}}
{{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 .DupCount}} · 重复率 {{printf "%.0f" .DupPct}}%({{.DupCount}} 张){{end}})

+
+ 操作:勾选属于同一个人的图片 → 在其中一张上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。
+ {{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}} + +
+{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}} +
+
+{{range .Cells}}
+ + {{.role}} + {{.name}} +
+ {{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/model/runway_image.go b/internal/model/runway_image.go index f2b5927..e0079b9 100644 --- a/internal/model/runway_image.go +++ b/internal/model/runway_image.go @@ -13,8 +13,12 @@ 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"` // 拖拽排序用,由迁移脚本新增 - LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look) - IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图 + 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"` // 去重字段(近重复 + 语义 embedding 留痕): // phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索; diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index a10ef41..627ef49 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -8,7 +8,8 @@ import "database/sql" // 与 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"` diff --git a/internal/pkg/phash/phash.go b/internal/pkg/phash/phash.go index 517d43c..47f3a36 100644 --- a/internal/pkg/phash/phash.go +++ b/internal/pkg/phash/phash.go @@ -22,7 +22,8 @@ import ( const Size = 64 // DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。 -const DefaultThreshold = 10 +// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。 +const DefaultThreshold = 4 // Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。 // 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。 diff --git a/internal/repository/admin_mixed_integration_test.go b/internal/repository/admin_mixed_integration_test.go new file mode 100644 index 0000000..b465d50 --- /dev/null +++ b/internal/repository/admin_mixed_integration_test.go @@ -0,0 +1,174 @@ +//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) + + all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100) + 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, 100) + 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 条") + } + + all, _, err := repo.ListMixed(ctx, "", "", 1, 50) + if err != nil { + t.Fatalf("kind='' 失败: %v", err) + } + if len(all) != len(runwayOnly)+len(streetOnly) { + t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly)) + } +} diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go new file mode 100644 index 0000000..ca4c5fb --- /dev/null +++ b/internal/repository/admin_repository.go @@ -0,0 +1,172 @@ +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 +} + +// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。 +type DupStat struct { + Total int // 存活图片总数(含主图与细节图) + Dup int // is_duplicate=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) + // DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。 + // 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。 + DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, 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 +} + +// DupRateByRecords 批量统计若干记录的图片去重情况。 +// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。 +// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。 +func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) { + out := make(map[uint32]DupStat) + 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 int `gorm:"column:total"` + Dup int `gorm:"column:dup"` + } + var rows []agg + if err := r.db.WithContext(ctx). + Table(table). + Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup"). + 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] = DupStat{Total: row.Total, Dup: row.Dup} + } + 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 494d696..d51f593 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -331,21 +331,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var img model.BrandRunwayImage - err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error + 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 } - return tx.Exec(`UPDATE brand_runways SET image_count = + 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 + 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 }) } @@ -383,6 +411,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 } diff --git a/internal/repository/editor_image_delete_integration_test.go b/internal/repository/editor_image_delete_integration_test.go index b0947e0..ec6e684 100644 --- a/internal/repository/editor_image_delete_integration_test.go +++ b/internal/repository/editor_image_delete_integration_test.go @@ -196,3 +196,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) { 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/ingest_repository.go b/internal/repository/ingest_repository.go index 7e83dc7..dfa028d 100644 --- a/internal/repository/ingest_repository.go +++ b/internal/repository/ingest_repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "strings" "time" "fashionapi/internal/model" @@ -42,6 +43,8 @@ type IngestRepository interface { // ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。 // 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。 ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error + // RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。 + RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。 // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。 StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error) @@ -335,11 +338,49 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r }) } +// 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 + } + 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 +} + // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。 // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突, // 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、 // 进而插入一行注定被索引拒绝的记录。 +// +// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致, +// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。 func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) { + title = NormalizeStreetTitle(title) var row struct { ID uint32 `gorm:"column:id"` Status string `gorm:"column:status"` @@ -359,6 +400,17 @@ func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city strin return row.ID, row.Status, 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) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) { now := uint32(time.Now().Unix()) snap.CreatedAt, snap.UpdatedAt = now, now diff --git a/internal/repository/ingest_single_table_integration_test.go b/internal/repository/ingest_single_table_integration_test.go index 7b62888..da46ee2 100644 --- a/internal/repository/ingest_single_table_integration_test.go +++ b/internal/repository/ingest_single_table_integration_test.go @@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType } func newRunwayImages(cover string) []model.BrandRunwayImage { - return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}} + return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}} } // TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。 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/review_repository.go b/internal/repository/review_repository.go index f0ee621..4e60b81 100644 --- a/internal/repository/review_repository.go +++ b/internal/repository/review_repository.go @@ -46,6 +46,11 @@ type ReviewRepository interface { 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{})中指定状态的行数。 @@ -412,3 +417,89 @@ func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, im 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/street_snap_repository.go b/internal/repository/street_snap_repository.go index fd93626..ac6dac7 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -223,10 +223,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) { @@ -274,21 +275,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var img model.StreetSnapImage - err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error + 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 } - return tx.Exec(`UPDATE street_snaps SET image_count = + 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 + 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 }) } @@ -326,6 +355,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 } 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..e1bdc83 --- /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/router/backstage.go b/internal/router/backstage.go index b511efe..973671f 100644 --- a/internal/router/backstage.go +++ b/internal/router/backstage.go @@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { // 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度 admin.Use(opt.Backstage.NavStats()) { - // 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 + // 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 // 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。 - admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch) - admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI) - admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI) + 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("/", opt.Backstage.Dashboard) admin.GET("/brands", opt.Backstage.Brands) @@ -89,31 +89,29 @@ func NewBackstage(opt BackstageOptions) *gin.Engine { 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) + // 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/驳回/下架的统一入口。 + admin.GET("/articles", opt.Backstage.Articles) + admin.POST("/articles/batch", opt.Backstage.ArticleBatch) + admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove) + admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject) + // 街拍主副图分组(零 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.ReviewImageDelete) - // 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。 - admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages) - admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage) // 爬虫入库任务监控:列出处理进度 + 失败重试 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 bad67ce..de4e307 100644 --- a/internal/router/backstage_test.go +++ b/internal/router/backstage_test.go @@ -13,7 +13,9 @@ 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" @@ -164,12 +166,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt // Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到, // 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。 -func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { +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 string) (*dto.PublicStreetSnapDetail, error) { +func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) { return &dto.PublicStreetSnapDetail{ UID: id, Title: "Paris Fashion Week Street(待审)", @@ -178,11 +180,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr }, }, 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 @@ -198,13 +205,63 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error { return nil } +// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。 +type fakeAdminRepo struct{} + +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{}, &fakeReview{attachOK: true}, nil) + h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{}) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h}) // 1) 登录页可访问 @@ -281,13 +338,17 @@ 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) 后台走秀详情渲染图集。 @@ -301,13 +362,14 @@ func TestBackstage(t *testing.T) { 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) 后台街拍详情渲染图集。 @@ -420,7 +482,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) { auth := &fakeAuth{jwt: m} brand := &fakeBrand{} rev := &fakeReview{attachOK: true} - h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil) + h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{}) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h}) return r, rev } @@ -444,14 +506,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) { tok := reviewToken(t, r) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&img=2&img=3")) + 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/reviews/street/7") { + 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 { @@ -465,7 +527,7 @@ func TestBackstageStreetAttachNeedsMain(t *testing.T) { tok := reviewToken(t, r) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3")) + 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) @@ -483,13 +545,13 @@ func TestBackstageStreetDetach(t *testing.T) { tok := reviewToken(t, r) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil) + 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/reviews/street/7") { + 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 { @@ -499,33 +561,23 @@ func TestBackstageStreetDetach(t *testing.T) { // TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。 func TestBackstageStreetRenderGroups(t *testing.T) { + hashid.Init("test-salt") r, rev := newReviewRouter(t) tok := reviewToken(t, r) - rev.view = &service.RecordDetailView{ - Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending", - Images: []service.RecordImageRef{ - {ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1}, - {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, - {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, - }, - Groups: []service.ImageGroup{ - {Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}}, - {Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}}, - }, - } + _ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务 w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil) + 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()) + 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("副图应显示所属主图") } @@ -552,7 +604,7 @@ func TestBackstageApprovePublishes(t *testing.T) { tok := reviewToken(t, r) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil) + 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) @@ -605,7 +657,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) { t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body) } // 导航已在共享 partial 里,各模块入口都应在 - for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} { + for _, label := range []string{"文章", "品牌", "入库任务", "用户"} { if !strings.Contains(body, ">"+label) { t.Fatalf("导航应包含入口 %q", label) } @@ -619,7 +671,7 @@ func TestBackstageReviewBatchApprove(t *testing.T) { body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}` w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body)) + 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) @@ -652,7 +704,7 @@ func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) { body := `{"action":"reject","items":[{"kind":"street","id":11}]}` w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body)) + 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) @@ -672,7 +724,7 @@ func TestBackstageReviewBatchPartialFailure(t *testing.T) { body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}` w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body)) + 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) diff --git a/internal/service/article_service.go b/internal/service/article_service.go index ba46b6a..f0a3ae0 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -194,25 +194,22 @@ func (s *articleService) detailErr(err error) error { // buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail { - // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index - // 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致, - // 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。 + // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id + // 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。 mains := make([]model.BrandRunwayImage, 0, len(imgs)) - byLook := make(map[uint32][]model.BrandRunwayImage) + detailsByParent := make(map[uint32][]model.BrandRunwayImage) for _, im := range imgs { - if im.IsDetail == 0 { + if im.IsDetail == 0 || im.ParentImageID == 0 { mains = append(mains, im) } else { - byLook[im.LookIndex] = append(byLook[im.LookIndex], im) + detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im) } } pubMains := s.toPublicImages(mains) for i := range pubMains { - // 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批 - // 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。 - if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 { + // 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。 + if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 { pubMains[i].Detail = s.toPublicImages(kids) - delete(byLook, pubMains[i].LookIndex) } } @@ -231,7 +228,45 @@ func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRun // ── 后台编辑支持 ────────────────────────────────────────────────────────────── 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 { @@ -306,7 +341,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub Thumb: s.img.ComposeThumb(im.Image), Name: im.Name, IsDetail: im.IsDetail, - LookIndex: im.LookIndex, }) } return out @@ -325,7 +359,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ Image: s.img.ComposeThumb(im.Image), Name: im.Name, IsDetail: im.IsDetail, - LookIndex: im.LookIndex, }) } return out diff --git a/internal/service/ingest_image_count_test.go b/internal/service/ingest_image_count_test.go index de8c34d..7ca9eaa 100644 --- a/internal/service/ingest_image_count_test.go +++ b/internal/service/ingest_image_count_test.go @@ -37,6 +37,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [ 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 } diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index c6cc262..dd98595 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -286,7 +286,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, Image: fi.url, Name: fmt.Sprintf("Look %d", i+1), SortOrder: uint32(i + 1), - LookIndex: uint32(i + 1), IsDetail: 0, Phash: sqlNull(fi.phash), IsDuplicate: fi.isDup, @@ -333,13 +332,21 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error()) return } + // 入库后按 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) } // fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的 -// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把 +// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把 // failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。 // cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计 //(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。 @@ -408,7 +415,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL Image: r.url, Name: name, SortOrder: uint32(order), - LookIndex: uint32(task.lookIdx), IsDetail: isDetail, Phash: sqlNull(r.phashBits), IsDuplicate: isDup, @@ -435,9 +441,11 @@ func countMainImages(images []model.BrandRunwayImage) uint16 { // processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。 func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { // 1) 实体键查重并决定分支(单表模型,同 runway)。 - // 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致, + // 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致, // 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。 - reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn) + // 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。 + title := repository.NormalizeStreetTitle(p.TitleEn) + reuseID, existStatus, exist, 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()) @@ -465,7 +473,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, writeStart := time.Now() snap := &model.StreetSnap{ JobID: job.ID, - Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en + Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致) Year: p.Year, City: p.City, Cover: cover, @@ -695,7 +703,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d } // lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、 -// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。 +// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。 type lookImageTask struct { url string lookIdx int // 1-based look 序号 diff --git a/internal/service/review_service.go b/internal/service/review_service.go index 65d8be4..60a24b3 100644 --- a/internal/service/review_service.go +++ b/internal/service/review_service.go @@ -28,8 +28,8 @@ type RecordCard struct { // RecordImageRef 记录图片引用(统一结构)。 // -// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用); -// DetailCount 让模板不用自己数副图。 +// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0), +// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。 type RecordImageRef struct { ID uint32 `json:"id"` Image string `json:"image"` @@ -70,8 +70,9 @@ type RecordDetailView struct { Images []RecordImageRef `json:"images"` // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 Fields []EditField `json:"fields"` - // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 - // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 + // Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。 + // 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。 + // 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。 Groups []ImageGroup `json:"groups,omitempty"` } @@ -286,20 +287,28 @@ func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, } func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error { - if kind != dto.IngestKindStreet { - return ErrInvalidImageGroup - } if len(imgIDs) == 0 { return ErrInvalidImageGroup } - return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs)) + 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 { - if kind != dto.IngestKindStreet { + 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 } - return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID)) } // translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 @@ -367,12 +376,58 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta {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, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), 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) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { return m.repo.UpdateRecord(ctx, id, fields) } diff --git a/internal/service/review_street_group_test.go b/internal/service/review_street_group_test.go index 39e6ac6..dafb306 100644 --- a/internal/service/review_street_group_test.go +++ b/internal/service/review_street_group_test.go @@ -14,9 +14,10 @@ import ( // fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 type fakeGroupRepo struct { repository.ReviewRepository - images []model.StreetSnapImage - detail *model.StreetSnap - attachFn func(snapID, mainID uint32, imgIDs []uint32) error + 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) { @@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin 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 { @@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) { } } -// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。 +// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。 func TestStreetGroupNonStreetRejected(t *testing.T) { repo := &fakeGroupRepo{} svc := newGroupService(repo) - if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) { - t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err) + 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) } } diff --git a/internal/service/street_snap_group_test.go b/internal/service/street_snap_group_test.go index a2c5945..82a5643 100644 --- a/internal/service/street_snap_group_test.go +++ b/internal/service/street_snap_group_test.go @@ -12,7 +12,7 @@ import ( // // 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A) // -// 期望 images 只有 2 张主图:A(Detail=[B,E], LookIndex=1)、C(Detail=[D], LookIndex=2)。 +// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。 func TestBuildSnapImages(t *testing.T) { s := &streetSnapService{img: imgurl.New("", "", "")} imgs := []model.StreetSnapImage{ @@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) { } a := out[0] - if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 { - t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex) + 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 || d.LookIndex != 1 { - t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex) + if d.IsDetail != 1 { + t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail) } } c := out[1] - if c.Name != "C" || c.LookIndex != 2 { - t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex) + 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)) 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 9b7bdb8..dd878e4 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" @@ -19,11 +20,11 @@ type StreetSnapService interface { List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) // AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。 AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) - Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) + Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) // DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。 - DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) + 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 编辑后更新字段(字段白名单在仓储层强制)。 @@ -70,7 +71,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] for _, r := range rows { ids = append(ids, r.ID) } - list := s.toPublicSnaps(rows) + list := s.toPublicSnaps(rows, q.Locale) if q.Images > 0 && len(list) > 0 { if err := s.attachImages(ctx, list, ids, q.Images); err != nil { @@ -91,17 +92,17 @@ func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery if err != nil { return nil, 0, internalErr(err.Error()) } - return s.toPublicSnaps(rows), total, nil + return s.toPublicSnaps(rows, q.Locale), total, nil } // toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。 // 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。 -func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap { +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: r.Title, + Title: pickLocale(r.Title, r.TitleCn, locale), Cover: s.img.ComposeThumb(r.Cover), ImageCount: r.ImageCount, Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应) @@ -149,7 +150,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA // Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。 // 图片统一走展示样式(VIP 与免费同质量)。 -func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { +func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) { snap, err := s.repo.FindByID(ctx, id) if err != nil { return nil, s.detailErr(err) @@ -158,14 +159,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS if err != nil { return nil, internalErr(err.Error()) } - return s.buildSnapDetail(snap, imgs), nil + return s.buildSnapDetail(snap, imgs, locale), nil } // DetailAdmin 后台详情:读基表,能渲染 pending / rejected。 // // 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口; // 若后台详情也走公开视图,待审项会 404、审核无从下手。 -func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { +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) @@ -174,7 +175,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu if err != nil { return nil, internalErr(err.Error()) } - return s.buildSnapDetail(snap, imgs), nil + return s.buildSnapDetail(snap, imgs, locale), nil } // detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。 @@ -186,10 +187,10 @@ func (s *streetSnapService) detailErr(err error) error { } // buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 -func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail { +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: snap.Title, + Title: pickLocale(snap.Title, snap.TitleCn, locale), Cover: s.img.Compose(snap.Cover), Images: s.buildSnapImages(imgs), } @@ -198,7 +199,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model // buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): // Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。 -// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。 +// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。 func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { if len(imgs) == 0 { return nil @@ -237,12 +238,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto. continue } pub := s.toPublicSnapImage(im) - pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号 + index[im.ID] = len(out) out = append(out, pub) } - // 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。 + // 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。 for _, im := range imgs { root := resolveRoot(im.ID) if root == im.ID { @@ -254,14 +255,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto. } pub := s.toPublicSnapImage(im) pub.IsDetail = 1 - pub.LookIndex = out[gi].LookIndex + out[gi].Detail = append(out[gi].Detail, pub) } return out } // 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()) @@ -270,7 +271,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi for _, r := range rows { list = append(list, dto.PublicStreetSnap{ UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), - Title: r.Title, + Title: pickLocale(r.Title, r.TitleCn, locale), Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) ImageCount: r.ImageCount, }) @@ -323,7 +324,50 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt // ── 后台编辑支持 ────────────────────────────────────────────────────────────── 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 {