This commit is contained in:
toom1996
2026-09-25 11:31:52 +08:00
parent 4f4c11d508
commit 42f6316125
51 changed files with 4489 additions and 998 deletions

View File

@ -94,6 +94,8 @@ func main() {
histRepo = repository.NewHistoryRepository(db) histRepo = repository.NewHistoryRepository(db)
ingestRepo = repository.NewIngestRepository(db) ingestRepo = repository.NewIngestRepository(db)
reviewRepo = repository.NewReviewRepository(db) reviewRepo = repository.NewReviewRepository(db)
// 文章管理混合列表(跨 brand_runways / street_snaps 的统一只读查询)
adminRepo = repository.NewAdminRepository(db)
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用) // 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
mediaRepo = repository.NewMediaRepository(db) mediaRepo = repository.NewMediaRepository(db)
@ -124,6 +126,9 @@ func main() {
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel) reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
) )
// 把图片 URL 拼装器交给后台模板:编辑/详情页用 imgURL 渲染图片,S4 模式下也能打开。
handler.SetImgComposer(imgComposer)
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表) // 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。 // 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。 // imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
@ -156,7 +161,7 @@ func main() {
}) })
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。 // 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 验签入口) // 爬虫上报 handler(HMAC 验签入口)
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc) ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)

View File

@ -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 status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0, 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 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) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录 -- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
-- 2026-09-22-01b-publish-existing-rows.sql。 -- 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); CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。 -- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。 -- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
DROP VIEW IF EXISTS public_brand_runway_images; DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways; DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images; DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps; DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS 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 CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i 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; WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS 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 CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i SELECT i.* FROM street_snap_images i

View File

@ -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;

View File

@ -1,15 +1,34 @@
-- dbtool 导出:127.0.0.1:5432/fashion -- dbtool 导出:127.0.0.1:5432/fashion
-- 生成时间:2026-09-23T15:56:58+08:00 -- 生成时间:2026-09-25T10:41:14+08:00
-- --
-- 导入(二选一): -- 导入(二选一):
-- dbtool import -in <本文件> -- dbtool import -in <本文件>
-- psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f <本文件> -- psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f <本文件>
-- --
-- 注意:本文件不含 DROP,目标库必须是空库;重复执行会主键冲突。 -- 注意:本文件由 dump -clean 生成,结构段前会 DROP 同名表 —— 目标库中这些表的既有数据将丢失。
SET client_encoding = 'UTF8'; SET client_encoding = 'UTF8';
SET client_min_messages = warning; 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" ( CREATE TABLE IF NOT EXISTS public."brand_runway_images" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY, "id" bigint GENERATED BY DEFAULT AS IDENTITY,
"created_at" bigint, "created_at" bigint,
@ -192,24 +211,21 @@ SELECT i.id,
CREATE OR REPLACE VIEW public."public_brand_runways" AS CREATE OR REPLACE VIEW public."public_brand_runways" AS
SELECT brand_runways.id, SELECT brand_runways.id,
brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_en,
brand_runways.title_cn, brand_runways.title_cn,
brand_runways.description_en, brand_runways.description_en,
brand_runways.description_cn, 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.cover,
brand_runways.image_count,
brand_runways.year,
brand_runways.collection_type, brand_runways.collection_type,
brand_runways.season, brand_runways.season,
brand_runways.season_code, brand_runways.season_code,
brand_runways.status, brand_runways.status,
brand_runways.job_id, brand_runways.is_deleted,
brand_runways.reviewer, brand_runways.created_at,
brand_runways.reject_reason brand_runways.updated_at
FROM brand_runways FROM brand_runways
WHERE brand_runways.status::text = 'published'::text AND brand_runways.is_deleted = 0; 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.city,
street_snaps.cover, street_snaps.cover,
street_snaps.image_count, street_snaps.image_count,
street_snaps.status,
street_snaps.is_deleted, street_snaps.is_deleted,
street_snaps.created_at, street_snaps.created_at,
street_snaps.updated_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
FROM street_snaps FROM street_snaps
WHERE street_snaps.status::text = 'published'::text AND street_snaps.is_deleted = 0; 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'104', E'8', E'6161a1aa71bc1292b81fb10ae67549a329e738fac5ed80f2fad37fc8a91a0cc9', E'1791787806', E'1', E'1789195806'),
(E'105', E'8', E'66e6b349950fe0ed32b96ba129238ae8539bea2a4f8be014636930b6053fe903', E'1791792765', E'1', E'1789200765'), (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'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'109', E'8', E'657e2fbc46a6e907b3f032e810616482e9c3fb7ffde7970ec7fdd2727d9a9e9d', E'1791890744', E'1', E'1789298744'),
(E'110', E'8', E'ee359442e4c5d117e02afe91bb122b48d887e4fca5d1e6a8e5a179bd3434a249', E'1791891010', E'1', E'1789299010'), (E'110', E'8', E'ee359442e4c5d117e02afe91bb122b48d887e4fca5d1e6a8e5a179bd3434a249', E'1791891010', E'1', E'1789299010'),
(E'116', E'8', E'4ede0f57245a9b63b746cc4bafdb61157a7e2d1a9f1dca6cd65037aa020d905c', E'1792570859', E'1', E'1789978859'), (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'60', E'8', E'6c3e41b17da8c0734df4d389e996f648bf9d4fba7a718929e118f51b01f79d43', E'1791127836', E'1', E'1788535836'),
(E'61', E'8', E'1b7ea57d082cd7668ec7ef8a5060188db0d93d4afcfc4d8eff7ed423e2661e25', E'1791127861', E'1', E'1788535861'), (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'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'1', E'7', E'a6d40efa41eb7184626615404d62eec90837387fd9c0fab4b2e4998f99116641', E'1790768077', E'1', E'1788176077'),
(E'2', E'7', E'294f4f0e9a3933bc0a346a650e9c2229c73351f23073f4826de864c60aae7ab3', E'1790768092', E'1', E'1788176092'), (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'3', E'7', E'1b5cb599d3cb5f34e154e1a30ed531f6bb60899a0f4eb7baa1ece97a2b0b8922', E'1790769528', E'1', E'1788177528'),
(E'4', E'7', E'4f53b8a0dcf701a2935e2616ca183e1538fab999fd2d282ec5efb347c130772f', 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'9', E'7', E'43e9ab9901f7e3a3ca79e581195666c0a298a0cdaaa08655ccdd7b1e8995e68a', E'1790907621', E'1', E'1788315621'), (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'10', E'7', E'ee69982285607bcda68c297be3826cf17aebe16ee5d5eb354846d5f848ce1644', E'1790913746', E'1', E'1788321746'),
(E'11', E'7', E'82980a5eb43432b294fe98e0ace622da74e8b60349a8a82be416972d19505017', E'1790913779', E'1', E'1788321779'), (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'29', E'7', E'22af98693b2caec27e958713b914414d8cf5f306101d53226c9d04dc6754b671', E'1790930929', E'1', E'1788338929'),
(E'34', E'7', E'09bdc1a3388e2b09114339ac5a798b3b060a0d880d4d5a3b9e358a1a976dc703', E'1790934593', E'1', E'1788342593'), (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'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 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'), (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'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'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'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'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'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'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'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'), (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'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'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'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'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'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'), (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'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'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'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 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'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'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'), (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'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'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'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 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'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'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'), (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'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'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'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 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'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'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'), (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'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'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'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 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'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'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'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'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'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'), (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'); (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 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'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'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'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''); (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 INSERT INTO public."users" ("id", "created_at", "updated_at", "username", "email", "password_hash", "is_deleted", "tier") VALUES

File diff suppressed because it is too large Load Diff

View File

@ -254,5 +254,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。 1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。 2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。 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. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。

View File

@ -79,12 +79,10 @@ type PublicArticleImage struct {
Thumb string `json:"thumb,omitempty"` Thumb string `json:"thumb,omitempty"`
Name string `json:"name"` Name string `json:"name"`
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下) IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级) Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。 // Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份 // 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
Detail []PublicArticleImage `json:"detail,omitempty"` Detail []PublicArticleImage `json:"detail,omitempty"`
} }

View File

@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; }
display: flex; display: flex;
flex-direction: column; 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); } .cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */ /* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
.cell .thumb { .cell .thumb {
@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 0; }
transition: opacity .15s; transition: opacity .15s;
} }
.cell .thumb:hover::after { opacity: 1; } .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 .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 { 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; } .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; cursor: pointer;
} }
.btn:hover { border-color: #111; } .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 { .chk {
display: flex; display: flex;
align-items: center; align-items: center;
@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; }
.chk:hover { border-color: #111; } .chk:hover { border-color: #111; }
.chk input { margin: 0; } .chk input { margin: 0; }
/* ---------- 看大图弹层(<dialog>,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 { .batchbar {
position: sticky; position: sticky;

View File

@ -1,402 +1,209 @@
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。 // 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
* // 两个入口页面:
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底 // - 文章管理混合列表(/admin/articles):批量通过/驳回
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事: // - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
*
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
*/
(function () { (function () {
'use strict'; // 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
function apiOf(action) {
var doc = document; return action.replace('/admin/articles/', '/admin/api/articles/');
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);
} }
function postJSON(url, body) { // ── 看大图弹层(所有页面通用)────────────────────────────────────────────
return fetch(url, { function initZoom() {
method: 'POST', var modal = document.getElementById('zoom-modal');
credentials: 'same-origin', if (!modal) return;
headers: { 'Content-Type': 'application/json' }, var img = document.getElementById('zoom-img');
body: JSON.stringify(body || {}), document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
}).then(function (res) { btn.addEventListener('click', function () {
return res.json().catch(function () { return null; }).then(function (data) { img.src = btn.querySelector('img').src;
if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')')); modal.showModal();
return data;
}); });
}); });
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.close();
});
} }
// 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...) // ── 文章管理列表:批量通过 / 驳回 ──────────────────────────────────────
function apiURL(action) { function initArticleList() {
return String(action).replace('/admin/reviews/', '/admin/api/reviews/'); 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');
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 =
'<button class="lb-close" type="button" aria-label="关闭">✕</button>' +
'<button class="lb-prev" type="button" aria-label="上一张">‹</button>' +
'<button class="lb-next" type="button" aria-label="下一张">›</button>' +
'<img alt=""><div class="lb-cap"></div>';
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 refresh() { function refresh() {
var n = picked().length; var n = picks().filter(function (c) { return c.checked; }).length;
bar.hidden = n === 0; if (bar) bar.hidden = n === 0;
if (counter) counter.textContent = '已选 ' + n + ' 条'; if (count) count.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;
}
} }
if (selAll) selAll.addEventListener('change', function () {
table.addEventListener('change', function (e) { picks().forEach(function (c) { c.checked = selAll.checked; });
if (e.target.classList.contains('row-pick')) refresh(); refresh();
}); });
if (selAll) { picks().forEach(function (c) { c.addEventListener('change', refresh); });
selAll.addEventListener('change', function () {
rows.forEach(function (r) {
var c = $('input.row-pick', r);
if (c) c.checked = selAll.checked;
});
refresh();
});
}
function submit(action, reason) { var clear = document.getElementById('batch-clear');
var items = picked().map(function (r) { if (clear) clear.addEventListener('click', function () {
return { kind: r.dataset.kind, id: Number(r.dataset.id) }; picks().forEach(function (c) { c.checked = false; });
}); if (selAll) selAll.checked = false;
if (!items.length) return; refresh();
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;
});
});
}); });
refresh(); function postBatch(action) {
} var items = picks().filter(function (c) {
return c.checked;
/* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */ }).map(function (c) {
var tr = c.closest('tr');
function initReviewDetail() { return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
var grid = $('.grid'); });
if (!grid) return; if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
var cells = $$('.cell', grid); if (!confirm('确认' + (action === 'approve' ? '通过' : '驳回') + '选中的 ' + items.length + ' 条?')) return;
var attachForm = doc.getElementById('batch'); fetch('/admin/api/articles/batch', {
method: 'POST',
// 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆) headers: { 'Content-Type': 'application/json' },
function applyGroups(res) { body: JSON.stringify({ action: action, items: items })
var images = res.images || []; }).then(function (r) { return r.json(); }).then(function (d) {
var byId = {}; if (d.failed && d.failed.length) {
images.forEach(function (i) { byId[i.id] = i; }); alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
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 + ' 副图' : ''));
} }
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; }); location.reload();
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; }); }).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) + ' 组';
} }
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用) // 合并选中为一组(AJAX,成功后就地更新标签)
function intercept(form, buildBody) { if (batchForm) batchForm.addEventListener('submit', function (e) {
form.addEventListener('submit', function (e) { e.preventDefault();
// 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在
// <form id="batch"> 内部(包在 .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(); e.preventDefault();
var body = buildBody(form); fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
if (body === null) return; // 校验未过:交给原生提交去报错 .then(function (r) { return r.json(); })
postJSON(apiURL(form.getAttribute('action')), body) .then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
.then(function (res) { .catch(function (err) { alert('拆出失败:' + err); });
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); });
}); });
}
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/<kind>/... 形式的 action 取出实体类型(street / runway)。
function kindOf(action) {
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
return m ? m[1] : 'street';
}
function boot() { // 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
// 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题) function updateToggle(mainId, detailCount) {
doc.documentElement.setAttribute('data-admin-js', 'ready'); var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
initLightbox(); var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
initReviewList(); if (detailCount > 0) {
initReviewDetail(); if (!btn && cell) {
var hint = doc.getElementById('hint-dismiss'); btn = document.createElement('button');
if (hint) { btn.type = 'button';
hint.addEventListener('click', function () { btn.className = 'toggle-det';
var box = hint.closest('.hint'); btn.setAttribute('data-parent', mainId);
if (box) box.hidden = true; 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();
});
})(); })();

View File

@ -3,7 +3,7 @@
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路, // 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。 // 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」, // 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。 // 同时表单式路由(/admin/articles/... 下的 approve/reject/attach/detach)原样保留:关掉 JS 后台依然可用。
// //
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。 // 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
package handler package handler
@ -32,13 +32,13 @@ type reviewBatchReq struct {
Items []reviewBatchItem `json:"items"` Items []reviewBatchItem `json:"items"`
} }
// ReviewBatch 批量通过 / 驳回审核记录。 // ArticleBatch 文章管理批量通过 / 驳回(替代原审核批量接口)。
// //
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]} // 请求体:{"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 var req reviewBatchReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
@ -99,10 +99,10 @@ type reviewAttachReq struct {
Imgs []uint32 `json:"imgs"` Imgs []uint32 `json:"imgs"`
} }
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。 // ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。 // 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。 // 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) { func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
@ -129,8 +129,8 @@ func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
h.respondRecordImages(c, kind, uint32(id)) h.respondRecordImages(c, kind, uint32(id))
} }
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。 // ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) { func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)

View File

@ -1,6 +1,7 @@
package handler package handler
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -8,7 +9,9 @@ import (
"fashionapi/internal/dto" "fashionapi/internal/dto"
"fashionapi/internal/middleware" "fashionapi/internal/middleware"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid" "fashionapi/internal/pkg/hashid"
"fashionapi/internal/repository"
"fashionapi/internal/pkg/pagination" "fashionapi/internal/pkg/pagination"
"fashionapi/internal/service" "fashionapi/internal/service"
@ -24,17 +27,18 @@ const adminCookie = "admin_session"
// 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权, // 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权,
// 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。 // 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。
type BackstageHandler struct { type BackstageHandler struct {
auth service.AuthService auth service.AuthService
brand service.BrandService brand service.BrandService
article service.ArticleService article service.ArticleService
street service.StreetSnapService street service.StreetSnapService
review service.ReviewService review service.ReviewService
ingest *service.IngestService ingest *service.IngestService
adminRepo repository.AdminRepository
} }
// NewBackstageHandler 创建后台 handler。 // NewBackstageHandler 创建后台 handler。
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler { 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} return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
} }
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed), // 模板渲染机制见 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}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return 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 { if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return return
@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap}) renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
} }
// ── 审核记录 ─────────────────────────────────────────────────────────────────── // ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。 // Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。 // 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。
func (h *BackstageHandler) ReviewList(c *gin.Context) { func (h *BackstageHandler) Articles(c *gin.Context) {
kind := c.Param("kind")
if kind == "" {
kind = c.Query("kind")
}
status := c.Query("status") status := c.Query("status")
kind := c.Query("kind")
if kind != "runway" && kind != "street" {
kind = "" // 非法的 kind 回落为「全部」
}
page := atoiDefault(c.Query("page"), 1) page := atoiDefault(c.Query("page"), 1)
if page < 1 { const size = 24
page = 1 cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size)
}
// 校验 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)
if err != nil { if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
// 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews // 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
listBase := "" dupStats := h.dupStatsByCards(c.Request.Context(), cards)
if kind != "" && kind != "all" { items := make([]gin.H, 0, len(cards))
listBase = "/" + kind 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) meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "review-list", gin.H{ renderPage(c, http.StatusOK, "articles", gin.H{
"Records": records, "Items": items,
"Total": total, "Status": status,
"Status": status, "Kind": kind,
"Kind": kind, "TabCounts": tabCounts,
"Kinds": kinds,
"ListBase": listBase,
"Pager": pagination.Pager{ "Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage), PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage), NextURL: pagination.PageLink(c, meta.NextPage),
@ -358,96 +377,74 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
}) })
} }
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。 // dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
func (h *BackstageHandler) ReviewDetail(c *gin.Context) { // 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
kind := c.Param("kind") func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
id := atoiDefault(c.Param("id"), 0) out := make(map[uint32]repository.DupStat)
if id == 0 { runwayIDs := make([]uint32, 0, len(cards))
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")}) streetIDs := make([]uint32, 0, len(cards))
return 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 len(runwayIDs) > 0 {
if err != nil { if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")}) for id, s := range m {
return out[id] = s
}
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 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
} }
} }
} }
renderPage(c, http.StatusOK, "review-detail", gin.H{ if len(streetIDs) > 0 {
"Record": record, if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
"BrandName": brandName, for id, s := range m {
}) out[id] = s
}
}
}
return out
} }
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。 // ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
func (h *BackstageHandler) ReviewApprove(c *gin.Context) { // 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := uint32(atoiDefault(c.Param("id"), 0))
if 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
}
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()})
return return
} }
username, _ := c.Get(middleware.ContextUsername) 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewListURL(kind)) c.Redirect(http.StatusFound, "/admin/articles?status=pending")
} }
// ReviewReject 驳回:仅记录状态与理由。 // ArticleReject 驳回:记录状态与理由(理由用于审核留痕)。
func (h *BackstageHandler) ReviewReject(c *gin.Context) { func (h *BackstageHandler) ArticleReject(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := uint32(atoiDefault(c.Param("id"), 0))
if 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 return
} }
reason := c.PostForm("reason") reason := c.PostForm("reason")
username, _ := c.Get(middleware.ContextUsername) 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewListURL(kind)) c.Redirect(http.StatusFound, "/admin/articles?status=pending")
} }
// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。 // ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。 func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := uint32(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)
if id == 0 { if id == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return return
@ -459,48 +456,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
imgIDs = append(imgIDs, uint32(v)) imgIDs = append(imgIDs, uint32(v))
} }
} }
if len(imgIDs) == 0 { if len(imgIDs) == 0 || mainID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"})
return return
} }
if mainID == 0 { if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return
}
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) c.Redirect(http.StatusFound, articleEditURL(kind, id))
} }
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。 // ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) { func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := uint32(atoiDefault(c.Param("id"), 0))
imgID := atoiDefault(c.Param("img"), 0) if id == 0 {
if id == 0 || imgID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) c.Redirect(http.StatusFound, articleEditURL(kind, id))
} }
// reviewDetailURL 生成审核详情页跳转地址。 // articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。
func reviewDetailURL(kind string, id uint32) string { // 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10) func articleEditURL(kind string, id uint32) string {
} var t byte
if kind == "runway" {
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 t = hashid.TypeRunway
func reviewListURL(kind string) string { } else {
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet { t = hashid.TypeSnap
return "/admin/reviews/" + kind
} }
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 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{ renderPage(c, http.StatusOK, "runway-edit", gin.H{
"UID": raw, "UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw, "Runway": rw,
"Images": imgs, "Images": imgs,
"Cells": cells,
"BrandName": brandName, "BrandName": brandName,
"IsDown": rw.IsDeleted == 1, "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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/runways/"+raw) c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
} }
// RunwayImageDelete 软删单张走秀图片。 // RunwayImageDelete 软删单张走秀图片。
@ -693,7 +756,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/runways/"+raw) c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
} }
// RunwaySetCover 把某张图片设为封面。 // RunwaySetCover 把某张图片设为封面。
@ -713,7 +776,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/runways/"+raw) c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
} }
// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。 // 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return 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}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return 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{ renderPage(c, http.StatusOK, "snap-edit", gin.H{
"UID": raw, "UID": raw,
"Snap": snap, "Kind": "street",
"Images": imgs, "RecordID": snap.ID,
"IsDown": snap.IsDeleted == 1, "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{ fields := map[string]any{
"title": c.PostForm("title"), "title": c.PostForm("title"),
"city": c.PostForm("city"), "title_cn": c.PostForm("title_cn"),
"year": year, "city": c.PostForm("city"),
"year": year,
} }
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil { if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
} }
// StreetSnapImageDelete 软删单张街拍图片。 // StreetSnapImageDelete 软删单张街拍图片。
@ -800,7 +928,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
} }
// StreetSnapSetCover 把某张图片设为封面。 // StreetSnapSetCover 把某张图片设为封面。
@ -820,7 +948,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
} }
// StreetSnapToggleDeleted 上下架。 // StreetSnapToggleDeleted 上下架。
@ -839,7 +967,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw) c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
} }
// atoiDefault 把字符串转 int,失败回落默认值。 // atoiDefault 把字符串转 int,失败回落默认值。

View File

@ -24,20 +24,17 @@ import (
// (renderPage 会 500 "unknown backstage page: xxx")。 // (renderPage 会 500 "unknown backstage page: xxx")。
func TestBackstageTemplatesParsed(t *testing.T) { func TestBackstageTemplatesParsed(t *testing.T) {
want := []string{ want := []string{
"articles",
"brand-form", "brand-form",
"brands", "brands",
"dashboard", "dashboard",
"error", "error",
"ingest-jobs", "ingest-jobs",
"login", "login",
"review-detail",
"review-list",
"runway-detail", "runway-detail",
"runway-edit", "runway-edit",
"runways",
"snap-edit", "snap-edit",
"street-snap-detail", "street-snap-detail",
"street-snaps",
"users", "users",
} }
got := PageNames() got := PageNames()
@ -74,7 +71,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "", "UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
"Images": []any{}, "Images": []any{},
// 列表数据 // 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖) // 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
"Article": &dto.PublicArticleDetail{UID: "abc123"}, "Article": &dto.PublicArticleDetail{UID: "abc123"},
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"}, "Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
@ -88,19 +85,20 @@ func TestBackstageTemplatesRender(t *testing.T) {
for _, page := range PageNames() { for _, page := range PageNames() {
for _, kind := range []string{"street", "runway"} { for _, kind := range []string{"street", "runway"} {
if page != "review-detail" && kind == "runway" { name := page + "/" + kind
continue // 只有审核详情页按 kind 分流
}
name := page
if page == "review-detail" {
name = page + "/" + kind
}
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
data := cloneData(base) data := cloneData(base)
for k, v := range perPage[page] { for k, v := range perPage[page] {
data[k] = v data[k] = v
} }
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"} 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() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)

View File

@ -58,9 +58,13 @@ func queryBool(c *gin.Context, key string, def bool) bool {
return v == "1" || v == "true" return v == "1" || v == "true"
} }
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。 // parseLocale 解析请求语言,优先级:X-Locale 头 > ?locale=cn|en > Accept-Language > 默认 en。
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。 // 与前端 i18n 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
// X-Locale 由前端统一经请求头透传「当前页面语言」,显式且不受浏览器语言设置干扰。
func parseLocale(c *gin.Context) string { 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" { if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" {
return l return l
} }

View File

@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) {
// GET /api/v1/ssg/street-snaps/popular // GET /api/v1/ssg/street-snaps/popular
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) { func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
const limit = 20 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 { if err != nil {
fail(c, err) fail(c, err)
return return

View File

@ -82,12 +82,12 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
return 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 { if err != nil {
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。 // 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。 // 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap { 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 detail, err = d2, nil
} }
} }

View File

@ -24,9 +24,23 @@ import (
"net/http" "net/http"
"strings" "strings"
"fashionapi/internal/pkg/imgurl"
"github.com/gin-gonic/gin" "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/* //go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS var backstageFS embed.FS
@ -42,8 +56,10 @@ var assetFS fs.FS
func init() { func init() {
funcs := template.FuncMap{ funcs := template.FuncMap{
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。 // imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
"reviewDetailURL": reviewDetailURL, // 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). base, err := template.New("layout.html").Funcs(funcs).
@ -94,7 +110,7 @@ func PageNames() []string {
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。 // renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
// //
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上 // page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。 // NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
func renderPage(c *gin.Context, status int, page string, data gin.H) { func renderPage(c *gin.Context, status int, page string, data gin.H) {
tpl, ok := pageTemplates[page] tpl, ok := pageTemplates[page]

View File

@ -20,6 +20,7 @@
<body> <body>
{{template "content" .}} {{template "content" .}}
<div id="toast" class="flash ok" hidden></div> <div id="toast" class="flash ok" hidden></div>
<dialog id="zoom-modal"><img id="zoom-img" alt=""></dialog>
<script src="/admin/assets/admin.js" defer></script> <script src="/admin/assets/admin.js" defer></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,59 @@
{{define "title"}}文章管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 文章管理{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>文章管理</h1>
<p class="muted">走秀与街拍合并管理:通过 / 驳回 / 编辑 / 下架都在此处发起。待审核优先排在前面。</p>
<div class="kinds">
<a href="/admin/articles?status={{.Status}}" class="{{if eq .Kind ""}}on{{end}}">全部类型</a>
<a href="/admin/articles?status={{.Status}}&kind=runway" class="{{if eq .Kind "runway"}}on{{end}}">走秀</a>
<a href="/admin/articles?status={{.Status}}&kind=street" class="{{if eq .Kind "street"}}on{{end}}">街拍</a>
</div>
<div class="tabs">
<a href="/admin/articles?status=&kind={{.Kind}}" class="{{if eq .Status ""}}on{{end}}">全部{{if gt (index .TabCounts "") 0}}({{index .TabCounts ""}}){{end}}</a>
<a href="/admin/articles?status=pending&kind={{.Kind}}" class="{{if eq .Status "pending"}}on{{end}}">待审核{{if gt (index .TabCounts "pending") 0}}({{index .TabCounts "pending"}}){{end}}</a>
<a href="/admin/articles?status=published&kind={{.Kind}}" class="{{if eq .Status "published"}}on{{end}}">已发布{{if gt (index .TabCounts "published") 0}}({{index .TabCounts "published"}}){{end}}</a>
<a href="/admin/articles?status=rejected&kind={{.Kind}}" class="{{if eq .Status "rejected"}}on{{end}}">已驳回{{if gt (index .TabCounts "rejected") 0}}({{index .TabCounts "rejected"}}){{end}}</a>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>重复率</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
<td><code>{{.ID}}</code></td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td>
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
<div class="ops">
{{if eq .Kind "runway"}}<a class="view" href="/admin/runways/{{.UID}}/edit">编辑</a>{{else}}<a class="view" href="/admin/street-snaps/{{.UID}}/edit">编辑</a>{{end}}
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/approve"><button type="submit" class="op op-primary">通过</button></form>{{end}}
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/reject"><button type="submit" class="op op-danger">驳回</button></form>{{end}}
{{if eq .Kind "runway"}}<form class="inline" method="post" action="/admin/runways/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{else}}<form class="inline" method="post" action="/admin/street-snaps/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{end}}
</div>
</td>
</tr>{{end}}
</tbody>
</table>
</div>
{{template "pager" .Pager}}
<div class="batchbar" id="batchbar" hidden>
<span class="muted" id="sel-count">已选 0 条</span>
<span class="spacer"></span>
<button type="button" id="batch-approve">批量通过</button>
<button type="button" id="batch-reject" class="reject">批量驳回…</button>
<button type="button" id="batch-clear" class="ghost">清空选择</button>
</div>
</div>
{{end}}

View File

@ -8,9 +8,7 @@
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}} {{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
<div class="cards"> <div class="cards">
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a> <a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
<a class="card" href="/admin/runways"><div class="t">走秀档案</div><div class="d">按系列 / 季节 / 年份筛选</div></a> <a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 驳回 · 编辑 · 下架</div></a>
<a class="card" href="/admin/street-snaps"><div class="t">街拍</div><div class="d">按城市 / 年份筛选</div></a>
<a class="card" href="/admin/reviews"><div class="t">待审草稿{{if gt .PendingReviews 0}}({{.PendingReviews}}){{end}}</div><div class="d">爬虫入库待人工审核</div></a>
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a> <a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a> <a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
</div> </div>

View File

@ -1,56 +0,0 @@
{{define "title"}}草稿审核 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
{{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main mid">
<p class="muted">类型 <b>{{.Record.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Record.Status}}</b></p>
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
<form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/approve">
{{range .Record.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
<button type="submit">通过并发布</button>
</form>
{{if eq .Record.Kind "street"}}
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div>
<h2>图片(<span id="img-stat">{{len .Record.Images}} 张 · {{len .Record.Groups}} 组</span>)</h2>
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
<form id="batch" method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/images/attach"></form>
<div class="grid">
{{range .Record.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<span class="nm">{{.Name}}</span>
<div class="acts">
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
<div class="batchbar">
<button type="submit" form="batch">合并选中为一组</button>
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div>
{{else}}
<h2>图片管理({{len .Record.Images}} 张)</h2>
<div class="grid">
{{range .Record.Images}}<div class="cell" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="nm">{{.Name}}</span>
<div class="acts">
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@ -1,50 +0,0 @@
{{define "title"}}待审草稿 · 管理后台{{end}}
{{define "barLabel"}}待审草稿{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>待审草稿(共 {{.Total}} 条)</h1>
<div class="kinds">
{{range .Kinds}}<a href="{{if eq .Kind ""}}/admin/reviews{{else}}/admin/reviews/{{.Kind}}{{end}}" class="{{if or (eq $.Kind .Kind) (eq $.Kind "all")}}on{{end}}">{{.Label}}{{if gt .Pending 0}}({{.Pending}}){{end}}</a>
{{end}}
</div>
<div class="tabs">
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a>
<a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a>
<a href="/admin/reviews{{.ListBase}}?status=published" class="{{if eq .Status "published"}}on{{end}}">已通过</a>
<a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
</div>
{{/* 多选 + 批量操作:勾选后底部出现操作条(admin.js 接管;无 JS 时单条「查看/审核」照常可用) */}}
<div class="table-wrap">
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Records}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td><code>{{.ID}}</code></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接发布">通过</button>{{end}}</td>
</tr>{{end}}
</tbody>
</table>
</div>
{{template "pager" .Pager}}
<div class="batchbar" id="batchbar" hidden>
<span class="muted" id="sel-count">已选 0 条</span>
<span class="spacer"></span>
<button type="button" id="batch-approve">批量通过</button>
<button type="button" id="batch-reject" class="reject">批量驳回…</button>
<button type="button" id="batch-clear" class="ghost">清空选择</button>
</div>
</div>
{{end}}

View File

@ -1,13 +1,13 @@
{{define "title"}}走秀详情 · 管理后台{{end}} {{define "title"}}走秀详情 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 走秀详情{{end}} {{define "barLabel"}}管理后台 · 走秀详情{{end}}
{{define "barRight"}}<a href="/admin/runways">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}} {{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}} {{define "content"}}
{{template "bar" .}} {{template "bar" .}}
<div class="main"> <div class="main">
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p> <p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
<h1>{{.Article.Title}}</h1> <h1>{{.Article.Title}}</h1>
<div class="grid"> <div class="grid">
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}} {{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@ -13,16 +13,14 @@
<button type="submit">保存字段</button> <button type="submit">保存字段</button>
</form> </form>
<h2>图片管理({{len .Images}} 张)</h2> {{template "imageGrid" .}}
<div class="grid">
{{range .Images}}<div class="cell"> {{if eq .Runway.Status "pending"}}
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button> <div class="review-actions">
<div class="acts"> <form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
<form method="post" action="/admin/runways/{{$.UID}}/cover"><input type="hidden" name="image" value="{{.Image}}"><button type="submit">设封面</button></form> <form method="post" action="/admin/articles/runway/{{.Runway.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
<form method="post" action="/admin/runways/{{$.UID}}/images/{{.ID}}/delete"><button type="submit" class="reject">删除</button></form>
</div>
</div>{{end}}
</div> </div>
{{end}}
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px"> <form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}} {{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}

View File

@ -1,39 +0,0 @@
{{define "title"}}走秀档案 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 走秀档案{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>走秀列表</h1>
<form class="toolbar" method="get" action="/admin/runways">
<select name="collection">
<option value="" {{if eq .Collection ""}}selected{{end}}>全部系列</option>
<option value="rtw" {{if eq .Collection "rtw"}}selected{{end}}>RTW 成衣</option>
<option value="menswear" {{if eq .Collection "menswear"}}selected{{end}}>男装</option>
<option value="couture" {{if eq .Collection "couture"}}selected{{end}}>高定</option>
<option value="resort" {{if eq .Collection "resort"}}selected{{end}}>度假</option>
<option value="pre_fall" {{if eq .Collection "pre_fall"}}selected{{end}}>早秋</option>
</select>
<select name="season">
<option value="" {{if eq .Season ""}}selected{{end}}>全部季节</option>
<option value="spring" {{if eq .Season "spring"}}selected{{end}}>春夏</option>
<option value="fall" {{if eq .Season "fall"}}selected{{end}}>秋冬</option>
</select>
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
<select name="sort">
<option value="" {{if eq .Sort ""}}selected{{end}}>默认排序</option>
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
</select>
<button type="submit">筛选</button>
<a href="/admin/runways">清除</a>
</form>
<table>
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th>状态</th><th></th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -6,21 +6,19 @@
<div class="main"> <div class="main">
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p> <p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<form method="post" action="/admin/street-snaps/{{.UID}}/edit"> <form method="post" action="/admin/street-snaps/{{.UID}}/edit">
<div><label>标题</label><input name="title" value="{{.Snap.Title}}"></div> <div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div> <div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
<button type="submit">保存字段</button> <button type="submit">保存字段</button>
</form> </form>
<h2>图片管理({{len .Images}} 张)</h2> {{template "imageGrid" .}}
<div class="grid">
{{range .Images}}<div class="cell"> {{if eq .Snap.Status "pending"}}
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button> <div class="review-actions">
<div class="acts"> <form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
<form method="post" action="/admin/street-snaps/{{$.UID}}/cover"><input type="hidden" name="image" value="{{.Image}}"><button type="submit">设封面</button></form> <form method="post" action="/admin/articles/street/{{.Snap.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
<form method="post" action="/admin/street-snaps/{{$.UID}}/images/{{.ID}}/delete"><button type="submit" class="reject">删除</button></form>
</div>
</div>{{end}}
</div> </div>
{{end}}
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px"> <form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}} {{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}

View File

@ -1,13 +1,13 @@
{{define "title"}}街拍详情 · 管理后台{{end}} {{define "title"}}街拍详情 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 街拍详情{{end}} {{define "barLabel"}}管理后台 · 街拍详情{{end}}
{{define "barRight"}}<a href="/admin/street-snaps">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}} {{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}} {{define "content"}}
{{template "bar" .}} {{template "bar" .}}
<div class="main"> <div class="main">
<p class="muted">编码 {{.Snap.UID}}</p> <p class="muted">编码 {{.Snap.UID}}</p>
<h1>{{.Snap.Title}}</h1> <h1>{{.Snap.Title}}</h1>
<div class="grid"> <div class="grid">
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}} {{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@ -1,27 +0,0 @@
{{define "title"}}街拍 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 街拍{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>街拍列表</h1>
<form class="toolbar" method="get" action="/admin/street-snaps">
<input name="city" placeholder="城市" value="{{.City}}">
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
<select name="sort">
<option value="" {{if eq .Sort ""}}selected{{end}}>默认(图最多)</option>
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
</select>
<button type="submit">筛选</button>
<a href="/admin/street-snaps">清除</a>
</form>
<table>
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th>状态</th><th></th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -16,9 +16,7 @@
<span class="bar-title">{{template "barLabel" .}}</span> <span class="bar-title">{{template "barLabel" .}}</span>
<nav class="bar-nav"> <nav class="bar-nav">
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a> <a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
<a href="/admin/reviews" class="{{if or (eq .NavKey "review-list") (eq .NavKey "review-detail")}}on{{end}}">审核{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a> <a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
<a href="/admin/runways" class="{{if or (eq .NavKey "runways") (eq .NavKey "runway-detail") (eq .NavKey "runway-edit")}}on{{end}}">走秀</a>
<a href="/admin/street-snaps" class="{{if or (eq .NavKey "street-snaps") (eq .NavKey "street-snap-detail") (eq .NavKey "snap-edit")}}on{{end}}">街拍</a>
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a> <a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a> <a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a> <a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
@ -35,3 +33,47 @@
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}} {{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div> </div>
{{end}} {{end}}
{{/*
共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。
两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。
页面需要提供的字段:
.Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案)
.UID 记录的 hashid(用于 /admin/{street-snaps|runways}/<UID>/... 路由)
.RecordID 记录数字 id(用于 /admin/articles/<kind>/<id>/... 路由)
.Status 记录状态(pending 时显示待审角标)
.Cells []gin.H,每项 {id, image, name, isDetail, role}
*/}}
{{define "imageGrid"}}
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div>
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
<div class="grid">
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
<span class="role">{{.role}}</span>
<span class="nm">{{.name}}</span>
<div class="acts">
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
{{else}}<form method="post" action="/admin/street-snaps/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/street-snaps/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
{{end}}
{{if eq .isDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.id}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.id}}"> 选入</label>
{{if and (eq .isDetail 0) .DetCount}}<button type="button" class="toggle-det" data-parent="{{.id}}">显示副图({{.DetCount}} 张)</button>{{end}}
{{if eq .isDetail 1}}<form data-only="detail" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/{{.id}}/detach"><button type="submit">拆出</button></form>{{end}}
</div>
</div>{{end}}
</div>
<div class="batchbar">
<button type="submit" form="batch">合并选中为一组</button>
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div>
{{end}}

View File

@ -13,8 +13,12 @@ type BrandRunwayImage struct {
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
Name string `gorm:"column:name" json:"name"` Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增 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=主图 1=细节图
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 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 留痕): // 去重字段(近重复 + 语义 embedding 留痕):
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索; // phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;

View File

@ -8,7 +8,8 @@ import "database/sql"
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。 // 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
type StreetSnap struct { type StreetSnap struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"` 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"` Year uint16 `gorm:"column:year" json:"year"`
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类 City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
Cover string `gorm:"column:cover" json:"cover"` Cover string `gorm:"column:cover" json:"cover"`

View File

@ -22,7 +22,8 @@ import (
const Size = 64 const Size = 64
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。 // DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
const DefaultThreshold = 10 // 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
const DefaultThreshold = 4
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。 // Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。 // 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -331,21 +331,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error { func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.BrandRunwayImage 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil return nil
} }
if err != nil { if err != nil {
return err 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{}). if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", imageID). Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err 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) (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 { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err 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 return keys, nil
} }

View File

@ -196,3 +196,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got) 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)
}
}

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"strings"
"time" "time"
"fashionapi/internal/model" "fashionapi/internal/model"
@ -42,6 +43,8 @@ type IngestRepository interface {
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。 // ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。 // 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error 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)查正式表既有行,返回其状态。 // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。 // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error) 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)查正式表既有行,返回其状态。 // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突, // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 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) { func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
title = NormalizeStreetTitle(title)
var row struct { var row struct {
ID uint32 `gorm:"column:id"` ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"` 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 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) { func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
now := uint32(time.Now().Unix()) now := uint32(time.Now().Unix())
snap.CreatedAt, snap.UpdatedAt = now, now snap.CreatedAt, snap.UpdatedAt = now, now

View File

@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
} }
func newRunwayImages(cover string) []model.BrandRunwayImage { 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,图片落到正式图片表。 // TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。

View File

@ -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)
}
}
}
}

View File

@ -46,6 +46,11 @@ type ReviewRepository interface {
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。 // DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error 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 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。 // 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())}). Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error 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
}

View File

@ -223,10 +223,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。 // snapEditable 后台可编辑的街拍字段白名单(键即列名)。
var snapEditable = map[string]bool{ var snapEditable = map[string]bool{
"title": true, "title": true,
"year": true, "title_cn": true,
"city": true, "year": true,
"cover": true, "city": true,
"cover": true,
} }
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { 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 { func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.StreetSnapImage 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil return nil
} }
if err != nil { if err != nil {
return err 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{}). if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", imageID). Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err 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) (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 { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err 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 return keys, nil
} }

View File

@ -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)
}
}

View File

@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度 // 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
admin.Use(opt.Backstage.NavStats()) admin.Use(opt.Backstage.NavStats())
{ {
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 // 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。 // 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch) admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI) admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI) admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard) admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands) admin.GET("/brands", opt.Backstage.Brands)
@ -89,31 +89,29 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/brands", opt.Backstage.BrandCreate) admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit) admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate) 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", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit) admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate) admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete) admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover) admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted) 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", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit) admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate) 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/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover) admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted) 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.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry) admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -13,7 +13,9 @@ import (
"fashionapi/internal/dto" "fashionapi/internal/dto"
"fashionapi/internal/handler" "fashionapi/internal/handler"
"fashionapi/internal/model" "fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/jwt" "fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"fashionapi/internal/service" "fashionapi/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -164,12 +166,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到, // Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。 // 故固定返回 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 return nil, service.ErrStreetSnapNotFound
} }
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。 // 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{ return &dto.PublicStreetSnapDetail{
UID: id, UID: id,
Title: "Paris Fashion Week Street(待审)", Title: "Paris Fashion Week Street(待审)",
@ -178,11 +180,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
}, },
}, nil }, 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 return nil, nil
} }
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) { 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 { func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil return nil
@ -198,13 +205,63 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil 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) { func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168) m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m} auth := &fakeAuth{jwt: m}
brand := &fakeBrand{} 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}) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问 // 1) 登录页可访问
@ -281,13 +338,17 @@ func TestBackstage(t *testing.T) {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location")) t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
} }
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源) // 6) 文章管理混合列表渲染(走秀 + 街拍合并)
w = httptest.NewRecorder() 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}) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") { body := w.Body.String()
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String()) if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
}
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
t.Fatalf("文章管理列表应含状态 tab: %s", body)
} }
// 7) 后台走秀详情渲染图集。 // 7) 后台走秀详情渲染图集。
@ -301,13 +362,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String()) t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
} }
// 8) 街拍列表渲染 // 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
w = httptest.NewRecorder() 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}) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") { body = w.Body.String()
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String()) if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
} }
// 9) 后台街拍详情渲染图集。 // 9) 后台街拍详情渲染图集。
@ -420,7 +482,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
auth := &fakeAuth{jwt: m} auth := &fakeAuth{jwt: m}
brand := &fakeBrand{} brand := &fakeBrand{}
rev := &fakeReview{attachOK: true} 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}) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev return r, rev
} }
@ -444,14 +506,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
tok := reviewToken(t, r) tok := reviewToken(t, r)
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusFound { if w.Code != http.StatusFound {
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String()) 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")) t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
} }
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 { 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) tok := reviewToken(t, r)
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@ -483,13 +545,13 @@ func TestBackstageStreetDetach(t *testing.T) {
tok := reviewToken(t, r) tok := reviewToken(t, r)
w := httptest.NewRecorder() 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) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusFound { if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code) 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")) t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
} }
if rev.last.op != "detach" || rev.last.imgID != 2 { if rev.last.op != "detach" || rev.last.imgID != 2 {
@ -499,33 +561,23 @@ func TestBackstageStreetDetach(t *testing.T) {
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。 // TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) { func TestBackstageStreetRenderGroups(t *testing.T) {
hashid.Init("test-salt")
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) tok := reviewToken(t, r)
rev.view = &service.RecordDetailView{ _ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
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"}},
},
}
w := httptest.NewRecorder() 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) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != http.StatusOK { 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() body := w.Body.String()
if !strings.Contains(body, "detail-a") { if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a") t.Fatalf("应渲染副图 detail-a")
} }
// 副图徽标显示所属主图(沿链归并后的实际主图)。 // 副图徽标显示所属主图。
if !strings.Contains(body, "副图 → #1") { if !strings.Contains(body, "副图 → #1") {
t.Fatalf("副图应显示所属主图") t.Fatalf("副图应显示所属主图")
} }
@ -552,7 +604,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
tok := reviewToken(t, r) tok := reviewToken(t, r)
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@ -605,7 +657,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body) t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
} }
// 导航已在共享 partial 里,各模块入口都应在 // 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} { for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
if !strings.Contains(body, ">"+label) { if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", 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}]}` body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/json")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
@ -652,7 +704,7 @@ func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
body := `{"action":"reject","items":[{"kind":"street","id":11}]}` body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/json")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) 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}]}` body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
w := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/json")
req.AddCookie(tok) req.AddCookie(tok)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)

View File

@ -194,25 +194,22 @@ func (s *articleService) detailErr(err error) error {
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 // buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail { func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致, // 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
mains := make([]model.BrandRunwayImage, 0, len(imgs)) mains := make([]model.BrandRunwayImage, 0, len(imgs))
byLook := make(map[uint32][]model.BrandRunwayImage) detailsByParent := make(map[uint32][]model.BrandRunwayImage)
for _, im := range imgs { for _, im := range imgs {
if im.IsDetail == 0 { if im.IsDetail == 0 || im.ParentImageID == 0 {
mains = append(mains, im) mains = append(mains, im)
} else { } else {
byLook[im.LookIndex] = append(byLook[im.LookIndex], im) detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} }
} }
pubMains := s.toPublicImages(mains) pubMains := s.toPublicImages(mains)
for i := range pubMains { for i := range pubMains {
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批 // 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。 if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
pubMains[i].Detail = s.toPublicImages(kids) 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) { 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 { 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), Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name, Name: im.Name,
IsDetail: im.IsDetail, IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
}) })
} }
return out return out
@ -325,7 +359,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
Image: s.img.ComposeThumb(im.Image), Image: s.img.ComposeThumb(im.Image),
Name: im.Name, Name: im.Name,
IsDetail: im.IsDetail, IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
}) })
} }
return out return out

View File

@ -37,6 +37,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil } func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
return nil
}
// fakeBrandRepo 品牌校验恒通过。 // fakeBrandRepo 品牌校验恒通过。
type fakeBrandRepo struct{ repository.BrandRepository } type fakeBrandRepo struct{ repository.BrandRepository }

View File

@ -286,7 +286,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
Image: fi.url, Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1), Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1), SortOrder: uint32(i + 1),
LookIndex: uint32(i + 1),
IsDetail: 0, IsDetail: 0,
Phash: sqlNull(fi.phash), Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup, 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()) s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return 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", log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected) job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID) _ = s.repo.MarkDone(ctx, job.ID)
} }
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的 // fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把 // BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。 // failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计 // cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。 //(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
@ -408,7 +415,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
Image: r.url, Image: r.url,
Name: name, Name: name,
SortOrder: uint32(order), SortOrder: uint32(order),
LookIndex: uint32(task.lookIdx),
IsDetail: isDetail, IsDetail: isDetail,
Phash: sqlNull(r.phashBits), Phash: sqlNull(r.phashBits),
IsDuplicate: isDup, IsDuplicate: isDup,
@ -435,9 +441,11 @@ func countMainImages(images []model.BrandRunwayImage) uint16 {
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。 // processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 实体键查重并决定分支(单表模型,同 runway)。 // 1) 实体键查重并决定分支(单表模型,同 runway)。
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致, // 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。 // 同城同年的不同专题(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 { if err != nil {
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。 // 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error()) 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() writeStart := time.Now()
snap := &model.StreetSnap{ snap := &model.StreetSnap{
JobID: job.ID, JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
Year: p.Year, Year: p.Year,
City: p.City, City: p.City,
Cover: cover, Cover: cover,
@ -695,7 +703,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
} }
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、 // lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。 // 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
type lookImageTask struct { type lookImageTask struct {
url string url string
lookIdx int // 1-based look 序号 lookIdx int // 1-based look 序号

View File

@ -28,8 +28,8 @@ type RecordCard struct {
// RecordImageRef 记录图片引用(统一结构)。 // RecordImageRef 记录图片引用(统一结构)。
// //
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用); // 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
// DetailCount 让模板不用自己数副图。 // =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
type RecordImageRef struct { type RecordImageRef struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Image string `json:"image"` Image string `json:"image"`
@ -70,8 +70,9 @@ type RecordDetailView struct {
Images []RecordImageRef `json:"images"` Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"` Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 // 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
Groups []ImageGroup `json:"groups,omitempty"` 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 { 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 { if len(imgIDs) == 0 {
return ErrInvalidImageGroup 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 { 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 ErrInvalidImageGroup
} }
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
} }
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 // translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
@ -367,12 +376,58 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"}, {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 { 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 return v, nil
} }
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error { func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateRecord(ctx, id, fields) return m.repo.UpdateRecord(ctx, id, fields)
} }

View File

@ -14,9 +14,10 @@ import (
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 // fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
type fakeGroupRepo struct { type fakeGroupRepo struct {
repository.ReviewRepository repository.ReviewRepository
images []model.StreetSnapImage images []model.StreetSnapImage
detail *model.StreetSnap detail *model.StreetSnap
attachFn func(snapID, mainID uint32, imgIDs []uint32) error 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) { func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
return nil return nil
} }
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
if f.attachRunwayFn != nil {
return f.attachRunwayFn(runwayID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
return nil
}
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil) var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
func newGroupService(f *fakeGroupRepo) ReviewService { func newGroupService(f *fakeGroupRepo) ReviewService {
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
} }
} }
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。 // TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
func TestStreetGroupNonStreetRejected(t *testing.T) { func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{} repo := &fakeGroupRepo{}
svc := newGroupService(repo) svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) { if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err) 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)
} }
} }

View File

@ -12,7 +12,7 @@ import (
// //
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A) // 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) { func TestBuildSnapImages(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")} s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{ imgs := []model.StreetSnapImage{
@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) {
} }
a := out[0] a := out[0]
if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 { if a.Name != "A" || a.IsDetail != 0 {
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex) 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" { if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail)) t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
} }
for _, d := range a.Detail { for _, d := range a.Detail {
if d.IsDetail != 1 || d.LookIndex != 1 { if d.IsDetail != 1 {
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex) t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
} }
} }
c := out[1] c := out[1]
if c.Name != "C" || c.LookIndex != 2 { if c.Name != "C" {
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex) t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
} }
if len(c.Detail) != 1 || c.Detail[0].Name != "D" { if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail)) t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))

View File

@ -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)
}
}

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"log" "log"
"net/http" "net/http"
"sort"
"fashionapi/internal/dto" "fashionapi/internal/dto"
"fashionapi/internal/model" "fashionapi/internal/model"
@ -19,11 +20,11 @@ type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。 // AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) 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 后台详情:读基表,能渲染 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 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。 // UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
@ -70,7 +71,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
for _, r := range rows { for _, r := range rows {
ids = append(ids, r.ID) ids = append(ids, r.ID)
} }
list := s.toPublicSnaps(rows) list := s.toPublicSnaps(rows, q.Locale)
if q.Images > 0 && len(list) > 0 { if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil { 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 { if err != nil {
return nil, 0, internalErr(err.Error()) return nil, 0, internalErr(err.Error())
} }
return s.toPublicSnaps(rows), total, nil return s.toPublicSnaps(rows, q.Locale), total, nil
} }
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。 // toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。 // 公开 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)) list := make([]dto.PublicStreetSnap, 0, len(rows))
for _, r := range rows { for _, r := range rows {
list = append(list, dto.PublicStreetSnap{ list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title, Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.ComposeThumb(r.Cover), Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount, ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应) Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
@ -149,7 +150,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。 // Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。 // 图片统一走展示样式(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) snap, err := s.repo.FindByID(ctx, id)
if err != nil { if err != nil {
return nil, s.detailErr(err) return nil, s.detailErr(err)
@ -158,14 +159,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
if err != nil { if err != nil {
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
} }
return s.buildSnapDetail(snap, imgs), nil return s.buildSnapDetail(snap, imgs, locale), nil
} }
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。 // DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
// //
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口; // 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。 // 若后台详情也走公开视图,待审项会 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) snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil { if err != nil {
return nil, s.detailErr(err) return nil, s.detailErr(err)
@ -174,7 +175,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
if err != nil { if err != nil {
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
} }
return s.buildSnapDetail(snap, imgs), nil return s.buildSnapDetail(snap, imgs, locale), nil
} }
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。 // detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
@ -186,10 +187,10 @@ func (s *streetSnapService) detailErr(err error) error {
} }
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。 // 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{ return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title, Title: pickLocale(snap.Title, snap.TitleCn, locale),
Cover: s.img.Compose(snap.Cover), Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs), Images: s.buildSnapImages(imgs),
} }
@ -198,7 +199,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): // buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。 // Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。 // 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 { if len(imgs) == 0 {
return nil return nil
@ -237,12 +238,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
continue continue
} }
pub := s.toPublicSnapImage(im) pub := s.toPublicSnapImage(im)
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
index[im.ID] = len(out) index[im.ID] = len(out)
out = append(out, pub) out = append(out, pub)
} }
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。 // 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
for _, im := range imgs { for _, im := range imgs {
root := resolveRoot(im.ID) root := resolveRoot(im.ID)
if root == im.ID { if root == im.ID {
@ -254,14 +255,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
} }
pub := s.toPublicSnapImage(im) pub := s.toPublicSnapImage(im)
pub.IsDetail = 1 pub.IsDetail = 1
pub.LookIndex = out[gi].LookIndex
out[gi].Detail = append(out[gi].Detail, pub) out[gi].Detail = append(out[gi].Detail, pub)
} }
return out return out
} }
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit) rows, err := s.repo.Popular(ctx, limit)
if err != nil { if err != nil {
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
@ -270,7 +271,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
for _, r := range rows { for _, r := range rows {
list = append(list, dto.PublicStreetSnap{ list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title, Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount, 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) { 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 { func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {