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

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 job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '',
-- 街拍标题中文化:保留既有 title 作英文源,仅加 title_cn 供后台人工翻译、前端按 locale 取用。
-- 与 brand_runways 用 title_en/title_cn 不同:street_snaps 上有 uq_ss_entity(city, year, COALESCE(title,'')),
-- 改名成本过高;pickLocale 在中文为空时回落英文,无需数据回灌。
ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT '';
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
-- 2026-09-22-01b-publish-existing-rows.sql。
@ -31,14 +35,20 @@ CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
SELECT
id, brand_id, title_en, title_cn, description_en, description_cn,
cover, image_count, year, collection_type, season, season_code,
status, is_deleted, created_at, updated_at
FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
@ -46,7 +56,10 @@ CREATE VIEW public_brand_runway_images AS
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
SELECT
id, title, title_cn, year, city, cover, image_count,
status, is_deleted, created_at, updated_at
FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i

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
-- 生成时间:2026-09-23T15:56:58+08:00
-- 生成时间:2026-09-25T10:41:14+08:00
--
-- 导入(二选一):
-- dbtool import -in <本文件>
-- psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f <本文件>
--
-- 注意:本文件不含 DROP,目标库必须是空库;重复执行会主键冲突。
-- 注意:本文件由 dump -clean 生成,结构段前会 DROP 同名表 —— 目标库中这些表的既有数据将丢失。
SET client_encoding = 'UTF8';
SET client_min_messages = warning;
-- ==================== 结构 ====================
-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失
DROP VIEW IF EXISTS public."public_street_snaps";
DROP VIEW IF EXISTS public."public_street_snap_images";
DROP VIEW IF EXISTS public."public_brand_runways";
DROP VIEW IF EXISTS public."public_brand_runway_images";
DROP TABLE IF EXISTS public."brand_runway_images" CASCADE;
DROP TABLE IF EXISTS public."brand_runways" CASCADE;
DROP TABLE IF EXISTS public."brands" CASCADE;
DROP TABLE IF EXISTS public."favorites" CASCADE;
DROP TABLE IF EXISTS public."histories" CASCADE;
DROP TABLE IF EXISTS public."image_duplicates" CASCADE;
DROP TABLE IF EXISTS public."image_embeddings" CASCADE;
DROP TABLE IF EXISTS public."ingest_jobs" CASCADE;
DROP TABLE IF EXISTS public."ingest_nonces" CASCADE;
DROP TABLE IF EXISTS public."refresh_tokens" CASCADE;
DROP TABLE IF EXISTS public."street_snap_images" CASCADE;
DROP TABLE IF EXISTS public."street_snaps" CASCADE;
DROP TABLE IF EXISTS public."users" CASCADE;
CREATE TABLE IF NOT EXISTS public."brand_runway_images" (
"id" bigint GENERATED BY DEFAULT AS IDENTITY,
"created_at" bigint,
@ -192,24 +211,21 @@ SELECT i.id,
CREATE OR REPLACE VIEW public."public_brand_runways" AS
SELECT brand_runways.id,
brand_runways.brand_id,
brand_runways.title_en,
brand_runways.title_cn,
brand_runways.description_en,
brand_runways.description_cn,
brand_runways.created_at,
brand_runways.updated_at,
brand_runways.is_deleted,
brand_runways.image_count,
brand_runways.brand_id,
brand_runways.year,
brand_runways.cover,
brand_runways.image_count,
brand_runways.year,
brand_runways.collection_type,
brand_runways.season,
brand_runways.season_code,
brand_runways.status,
brand_runways.job_id,
brand_runways.reviewer,
brand_runways.reject_reason
brand_runways.is_deleted,
brand_runways.created_at,
brand_runways.updated_at
FROM brand_runways
WHERE brand_runways.status::text = 'published'::text AND brand_runways.is_deleted = 0;
@ -238,15 +254,10 @@ SELECT street_snaps.id,
street_snaps.city,
street_snaps.cover,
street_snaps.image_count,
street_snaps.status,
street_snaps.is_deleted,
street_snaps.created_at,
street_snaps.updated_at,
street_snaps.source,
street_snaps.source_url,
street_snaps.status,
street_snaps.job_id,
street_snaps.reviewer,
street_snaps.reject_reason
street_snaps.updated_at
FROM street_snaps
WHERE street_snaps.status::text = 'published'::text AND street_snaps.is_deleted = 0;
@ -1221,8 +1232,6 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at"
(E'104', E'8', E'6161a1aa71bc1292b81fb10ae67549a329e738fac5ed80f2fad37fc8a91a0cc9', E'1791787806', E'1', E'1789195806'),
(E'105', E'8', E'66e6b349950fe0ed32b96ba129238ae8539bea2a4f8be014636930b6053fe903', E'1791792765', E'1', E'1789200765'),
(E'106', E'8', E'e31ee908c298498aec6fef629418be71131ae444780257b9800a5815ba69eccd', E'1791792802', E'1', E'1789200802'),
(E'107', E'8', E'409f92c490e950f4f5cd5651eb810f3b5d4313547a8d87699fef77941c58492a', E'1791875855', E'1', E'1789283855'),
(E'108', E'8', E'70193ca537cf5bd2f35b925ae4ad9232f319f56376db712b60f09af01d0b3714', E'1791883446', E'1', E'1789291446'),
(E'109', E'8', E'657e2fbc46a6e907b3f032e810616482e9c3fb7ffde7970ec7fdd2727d9a9e9d', E'1791890744', E'1', E'1789298744'),
(E'110', E'8', E'ee359442e4c5d117e02afe91bb122b48d887e4fca5d1e6a8e5a179bd3434a249', E'1791891010', E'1', E'1789299010'),
(E'116', E'8', E'4ede0f57245a9b63b746cc4bafdb61157a7e2d1a9f1dca6cd65037aa020d905c', E'1792570859', E'1', E'1789978859'),
@ -1236,71 +1245,10 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at"
(E'60', E'8', E'6c3e41b17da8c0734df4d389e996f648bf9d4fba7a718929e118f51b01f79d43', E'1791127836', E'1', E'1788535836'),
(E'61', E'8', E'1b7ea57d082cd7668ec7ef8a5060188db0d93d4afcfc4d8eff7ed423e2661e25', E'1791127861', E'1', E'1788535861'),
(E'62', E'8', E'2a5380647f63c87de03b79ad7dc4b8be659494fc6530dd30e62b720af2e090d7', E'1791127873', E'1', E'1788535873'),
(E'63', E'8', E'0ab8f2fad351b0acc6267b634b6dad57a6eb227791294949c75d24b25a14ddd4', E'1791127898', E'1', E'1788535898'),
(E'64', E'8', E'b654e2d27e23bb4a679d260d6828d7bd62a6643ef4b2ef6040e2b9f9333e7be2', E'1791129506', E'1', E'1788537506'),
(E'65', E'8', E'893918c82ed2379dcb303e7fb183653bed09deb3f7531dca8312d41a375240a0', E'1791129563', E'1', E'1788537563'),
(E'66', E'8', E'fdc2c38bfed4b6270d04b350e5f8f791003784f8a7ff01067e583cc2b029ede7', E'1791129702', E'1', E'1788537702'),
(E'67', E'8', E'332974485a5c4a1637d408be6dfd871c60660740f4611ae9f74d03dfc3761047', E'1791129958', E'1', E'1788537958'),
(E'68', E'8', E'f3c557d3b62d9e328594b73ea9e6910aaa0d8c474eaa33c88891ffa377d23520', E'1791133840', E'1', E'1788541840'),
(E'69', E'8', E'843cb5f4d8c25fa7c18532bb6925ac9ed7726c06f311aa090004d6b1e59d223c', E'1791134935', E'1', E'1788542935'),
(E'70', E'8', E'6be368df50a1befb326f145e9b1862edf0b5e1fdc96e6794c124af8d022b82f6', E'1791173464', E'1', E'1788581464'),
(E'71', E'8', E'6bdecb0f64e7f34c0ac7207c31a2cd075693b4a1dfeae58543bc96d0d3bee833', E'1791177493', E'1', E'1788585493'),
(E'72', E'8', E'ce3e69d9d17be46915b342f24bbc913961fd308a4c955ec7dc07ea3a239754ce', E'1791179899', E'1', E'1788587899'),
(E'73', E'8', E'c12ad4e7c5cd7d9dfabe40b82a0cd0d593cac3e453a0ef20f386aef71361f26f', E'1791182039', E'1', E'1788590039'),
(E'74', E'8', E'14dc8ab713b1f8ffd2e12dcee9bc9417dc76b911c7a1d6ff206cdc7c4617e5b6', E'1791182059', E'1', E'1788590059'),
(E'75', E'8', E'8f7c246cc0504cf50f9991e10b76e0b52e81e26c9f60a326c807f56152912e48', E'1791182164', E'1', E'1788590164'),
(E'76', E'8', E'f963ee99bb9fb2a5df5c88cdaa06c0971dc6cd94a975366c6da2691c26fda82d', E'1791184327', E'1', E'1788592327'),
(E'77', E'8', E'ac298e5efc00267c402c11b049d9d92f520bd690f5ee32a124e3659dfc3d0a19', E'1791184355', E'1', E'1788592355'),
(E'78', E'8', E'361945cad31b12d3ec0923c809020887c957dc3b052aa74a408c1c80f617169b', E'1791184476', E'1', E'1788592476'),
(E'79', E'8', E'91f8df41003d1f7872ef25e1b223c97efc5cdb52f688c5846c1b298a78596878', E'1791185097', E'1', E'1788593097'),
(E'80', E'8', E'd6cf15948cc3994f9b1a91619706571842175a9eb7e39f951206b1ae962943f2', E'1791185134', E'1', E'1788593134'),
(E'81', E'8', E'890ff168f17695a3e44af8612d20bf0cdf9e717e4e98d8936affb8076f8f52ab', E'1791185182', E'1', E'1788593182'),
(E'82', E'8', E'cb3a6e4f3ec5edf2ba21534abe8920e8bc265092417b634d113aff9ad8066a5f', E'1791186526', E'1', E'1788594526'),
(E'83', E'8', E'429ab08ce03f38a9c331541d27d12ca24a832833240430e54ef3772aa7836d03', E'1791212558', E'1', E'1788620558'),
(E'84', E'8', E'eb26f72f49277d04d355c1f32a2d3fd0311419b9e2f6b7fb2fc622eea52693bc', E'1791212567', E'1', E'1788620567'),
(E'85', E'8', E'e3b5e612dcfe72d4d03e97583976fda21fac2387f0d55ece36af819cf3bcbc51', E'1791212587', E'1', E'1788620587'),
(E'86', E'8', E'cb0764d2a1e98f78349b718d93170341ac2bbaac914c321386b0b7d0fb7c3d39', E'1791212602', E'1', E'1788620602'),
(E'87', E'8', E'16b4e8cb31a5fd085ecce3e9694912d725d9c376cab27d7116f6a4ea039e86e7', E'1791212603', E'1', E'1788620603'),
(E'88', E'8', E'b71ecec8386d158b7fd1215e43ee6f870ac4a5049162a1ecb81b40cbbf375cb6', E'1791302249', E'1', E'1788710249'),
(E'118', E'8', E'66978ac58f5437687555c535b5a9dad603c6ad905f6684de9d5cc40985e11ee0', E'1792597889', E'1', E'1790005889'),
(E'8', E'8', E'71d2cda477878b23c92d0ef574a3e99fd5c02fa4af083554b73d969d700f8004', E'1790848478', E'1', E'1788256478'),
(E'23', E'8', E'4a112c6a9e30af63a329fff29d111b352fbc24f26191212948b6cad25681ae03', E'1790928285', E'1', E'1788336285'),
(E'25', E'8', E'5155c8e63c9b3b7bc28bb1d0c5c4ec263a79b8d8dc0275d86832fcbb67603990', E'1790928395', E'1', E'1788336395'),
(E'26', E'8', E'924da4ae618c29a6f6e784a81ffc6abd2618e0bf3b15f6918f653c56aa4e42f5', E'1790928415', E'1', E'1788336415'),
(E'27', E'8', E'3ba38219e55041dfcfb838cd9667642d1e8129a3d0a2aec8398edf7531f17b27', E'1790928503', E'1', E'1788336503'),
(E'28', E'8', E'8bc32be79b3eb89e9d30bb7a4aaf0b29c58bb1c036f5c7b9d0c51a9af4686150', E'1790930929', E'1', E'1788338929'),
(E'30', E'8', E'd79a8269cc47eb22ad5329a79cc90da4142dca5a440eeaffecbeaa35e51cdcd6', E'1790931344', E'1', E'1788339344'),
(E'31', E'8', E'21dd83a1f7ae3a6dce0132aa7ad832b228fced7e1d0ff033102be59bb4fbb336', E'1790931884', E'1', E'1788339884'),
(E'32', E'8', E'f7f66ddf4bbe1c99cbe13e6448ca3dd3bbd5c6ee3d778ca31befe2a39f0eab00', E'1790932111', E'1', E'1788340111'),
(E'33', E'8', E'10e53f9937d779f27e9f62e561620cce92b7b94d4e45ef59060f1a587cbaf0da', E'1790932461', E'1', E'1788340461'),
(E'36', E'8', E'8ab626968d6ba5c85546c0154d1835622f21cba3fd5f70cb8962fcfa1f7c93f9', E'1790937983', E'1', E'1788345983'),
(E'37', E'8', E'8c819c48e7f17a248791390937c488a94fd7c4c7822907900a80f24ff086e394', E'1790938015', E'1', E'1788346015'),
(E'38', E'8', E'1e33cf9f6036efc21bbf28657577290087f8105d76c917f16f320d7cf25f8278', E'1790938336', E'1', E'1788346336'),
(E'39', E'8', E'77962d61a0c55d79de83e31e4efded50e565ca0d09c2f4554c1108e4aecef547', E'1790939501', E'1', E'1788347501'),
(E'40', E'8', E'c54cf2aececa5fd06701dd6fe2f9ee159f1046a5dd8818eec682498d7ec21a33', E'1790939520', E'1', E'1788347520'),
(E'41', E'8', E'b02994a50c903252e72ea078dc052a535069fe0a814df4de30d4aec57392ed0e', E'1790940991', E'1', E'1788348991'),
(E'42', E'8', E'0994a166bfe5a917d66da3d28e853991a724bad5ba2a56d0589b9f6e93f8ea8c', E'1790941592', E'1', E'1788349592'),
(E'43', E'8', E'941b4c59118ba58915a02f0ae4250b9ba8a0816b62b9a57e2203374b8741b035', E'1790941855', E'1', E'1788349855'),
(E'44', E'8', E'0218725a02ee66cdd6e266d6c34103352602330ffb3979b9051cfbf5126c8f90', E'1790954612', E'1', E'1788362612'),
(E'45', E'8', E'd12c5961d9118c099fe75da3ba3aa193c22c2b343d8f4e43e51977cded7a087e', E'1790954621', E'1', E'1788362621'),
(E'46', E'8', E'21b760a00aa8c919f23c8125ef331cbe935ed5398a9c1c43baeb54ae3945be2f', E'1790954789', E'1', E'1788362789'),
(E'47', E'8', E'4f0dd38a4b1d466d7eb714811747caf8a063a956c4facb35bdaf7c02941ca0d9', E'1790957460', E'1', E'1788365460'),
(E'48', E'8', E'440c584569062a9af0ef693e700e1c94b0c463ed0f682117edf1caed03c90dfb', E'1791083234', E'1', E'1788491234'),
(E'49', E'8', E'29182427b04947c65f4dbc4b750171efaa381eea076d9dc683414fb79ef941ec', E'1791101818', E'1', E'1788509818'),
(E'50', E'8', E'0f63fe9607968891784916633771b78c4125088c1cb9f612ce5b728428167f08', E'1791101826', E'1', E'1788509826'),
(E'51', E'8', E'cf652c732ae41567dcea1877f12d2042c527476d97dc673221aeee328918e7d9', E'1791126011', E'1', E'1788534011'),
(E'52', E'8', E'dcba5a0e495748eb77177f2b9c842ea6b8848bb0a4d9f2f8ba3a6f7814f3899f', E'1791126023', E'1', E'1788534023'),
(E'111', E'8', E'ce67009fb0eac30226a49f5800e4935f7433024e231971e18832ac997151544a', E'1792061397', E'1', E'1789469397'),
(E'112', E'8', E'816b96381d886887a05c12ec5883371671a95a9fa3bf27a308097c6565a23473', E'1792062995', E'1', E'1789470995'),
(E'113', E'8', E'6b6a514221de8aa4c0fda45fa6c9d6eab318aeb3ae8f72c37eec1100e11ffb73', E'1792114905', E'1', E'1789522905'),
(E'114', E'8', E'68af59790a958f09d2fe65728fdae7cf01ea59b40b99f1cd69977a4eefce3919', E'1792415160', E'1', E'1789823160'),
(E'115', E'8', E'6cc47083bd5373028aba68c89c0050ab2d691ee65e20993d50e8002cc500a1ba', E'1792415169', E'1', E'1789823169'),
(E'119', E'8', E'1b965638435da6e428c9d3d3316ac77ea3dad74f3427eed61cfd1dcaed5c1856', E'1792666163', E'0', E'1790074163'),
(E'1', E'7', E'a6d40efa41eb7184626615404d62eec90837387fd9c0fab4b2e4998f99116641', E'1790768077', E'1', E'1788176077'),
(E'2', E'7', E'294f4f0e9a3933bc0a346a650e9c2229c73351f23073f4826de864c60aae7ab3', E'1790768092', E'1', E'1788176092'),
(E'3', E'7', E'1b5cb599d3cb5f34e154e1a30ed531f6bb60899a0f4eb7baa1ece97a2b0b8922', E'1790769528', E'1', E'1788177528'),
(E'4', E'7', E'4f53b8a0dcf701a2935e2616ca183e1538fab999fd2d282ec5efb347c130772f', E'1790769528', E'1', E'1788177528');
INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at", "revoked", "created_at") VALUES
(E'4', E'7', E'4f53b8a0dcf701a2935e2616ca183e1538fab999fd2d282ec5efb347c130772f', E'1790769528', E'1', E'1788177528'),
(E'9', E'7', E'43e9ab9901f7e3a3ca79e581195666c0a298a0cdaaa08655ccdd7b1e8995e68a', E'1790907621', E'1', E'1788315621'),
(E'10', E'7', E'ee69982285607bcda68c297be3826cf17aebe16ee5d5eb354846d5f848ce1644', E'1790913746', E'1', E'1788321746'),
(E'11', E'7', E'82980a5eb43432b294fe98e0ace622da74e8b60349a8a82be416972d19505017', E'1790913779', E'1', E'1788321779'),
@ -1319,7 +1267,73 @@ INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at"
(E'29', E'7', E'22af98693b2caec27e958713b914414d8cf5f306101d53226c9d04dc6754b671', E'1790930929', E'1', E'1788338929'),
(E'34', E'7', E'09bdc1a3388e2b09114339ac5a798b3b060a0d880d4d5a3b9e358a1a976dc703', E'1790934593', E'1', E'1788342593'),
(E'35', E'7', E'a3ba322c3161d576afe7b2a3275cca4e215a91ee0768718f9aa841bc219f37bc', E'1790934604', E'1', E'1788342604'),
(E'120', E'7', E'c74e517dc0d12b895e5867b241fde55c78b55fc9ee44a72501b8f2035d80528b', E'1792736601', E'0', E'1790144601');
(E'120', E'7', E'c74e517dc0d12b895e5867b241fde55c78b55fc9ee44a72501b8f2035d80528b', E'1792736601', E'0', E'1790144601'),
(E'107', E'8', E'409f92c490e950f4f5cd5651eb810f3b5d4313547a8d87699fef77941c58492a', E'1791875855', E'1', E'1789283855'),
(E'108', E'8', E'70193ca537cf5bd2f35b925ae4ad9232f319f56376db712b60f09af01d0b3714', E'1791883446', E'1', E'1789291446'),
(E'81', E'8', E'890ff168f17695a3e44af8612d20bf0cdf9e717e4e98d8936affb8076f8f52ab', E'1791185182', E'1', E'1788593182'),
(E'82', E'8', E'cb3a6e4f3ec5edf2ba21534abe8920e8bc265092417b634d113aff9ad8066a5f', E'1791186526', E'1', E'1788594526'),
(E'83', E'8', E'429ab08ce03f38a9c331541d27d12ca24a832833240430e54ef3772aa7836d03', E'1791212558', E'1', E'1788620558'),
(E'84', E'8', E'eb26f72f49277d04d355c1f32a2d3fd0311419b9e2f6b7fb2fc622eea52693bc', E'1791212567', E'1', E'1788620567'),
(E'85', E'8', E'e3b5e612dcfe72d4d03e97583976fda21fac2387f0d55ece36af819cf3bcbc51', E'1791212587', E'1', E'1788620587'),
(E'86', E'8', E'cb0764d2a1e98f78349b718d93170341ac2bbaac914c321386b0b7d0fb7c3d39', E'1791212602', E'1', E'1788620602'),
(E'87', E'8', E'16b4e8cb31a5fd085ecce3e9694912d725d9c376cab27d7116f6a4ea039e86e7', E'1791212603', E'1', E'1788620603'),
(E'88', E'8', E'b71ecec8386d158b7fd1215e43ee6f870ac4a5049162a1ecb81b40cbbf375cb6', E'1791302249', E'1', E'1788710249'),
(E'118', E'8', E'66978ac58f5437687555c535b5a9dad603c6ad905f6684de9d5cc40985e11ee0', E'1792597889', E'1', E'1790005889'),
(E'8', E'8', E'71d2cda477878b23c92d0ef574a3e99fd5c02fa4af083554b73d969d700f8004', E'1790848478', E'1', E'1788256478'),
(E'23', E'8', E'4a112c6a9e30af63a329fff29d111b352fbc24f26191212948b6cad25681ae03', E'1790928285', E'1', E'1788336285'),
(E'25', E'8', E'5155c8e63c9b3b7bc28bb1d0c5c4ec263a79b8d8dc0275d86832fcbb67603990', E'1790928395', E'1', E'1788336395'),
(E'26', E'8', E'924da4ae618c29a6f6e784a81ffc6abd2618e0bf3b15f6918f653c56aa4e42f5', E'1790928415', E'1', E'1788336415'),
(E'27', E'8', E'3ba38219e55041dfcfb838cd9667642d1e8129a3d0a2aec8398edf7531f17b27', E'1790928503', E'1', E'1788336503'),
(E'28', E'8', E'8bc32be79b3eb89e9d30bb7a4aaf0b29c58bb1c036f5c7b9d0c51a9af4686150', E'1790930929', E'1', E'1788338929'),
(E'63', E'8', E'0ab8f2fad351b0acc6267b634b6dad57a6eb227791294949c75d24b25a14ddd4', E'1791127898', E'1', E'1788535898'),
(E'64', E'8', E'b654e2d27e23bb4a679d260d6828d7bd62a6643ef4b2ef6040e2b9f9333e7be2', E'1791129506', E'1', E'1788537506'),
(E'65', E'8', E'893918c82ed2379dcb303e7fb183653bed09deb3f7531dca8312d41a375240a0', E'1791129563', E'1', E'1788537563'),
(E'66', E'8', E'fdc2c38bfed4b6270d04b350e5f8f791003784f8a7ff01067e583cc2b029ede7', E'1791129702', E'1', E'1788537702'),
(E'67', E'8', E'332974485a5c4a1637d408be6dfd871c60660740f4611ae9f74d03dfc3761047', E'1791129958', E'1', E'1788537958'),
(E'68', E'8', E'f3c557d3b62d9e328594b73ea9e6910aaa0d8c474eaa33c88891ffa377d23520', E'1791133840', E'1', E'1788541840'),
(E'69', E'8', E'843cb5f4d8c25fa7c18532bb6925ac9ed7726c06f311aa090004d6b1e59d223c', E'1791134935', E'1', E'1788542935'),
(E'70', E'8', E'6be368df50a1befb326f145e9b1862edf0b5e1fdc96e6794c124af8d022b82f6', E'1791173464', E'1', E'1788581464'),
(E'71', E'8', E'6bdecb0f64e7f34c0ac7207c31a2cd075693b4a1dfeae58543bc96d0d3bee833', E'1791177493', E'1', E'1788585493'),
(E'72', E'8', E'ce3e69d9d17be46915b342f24bbc913961fd308a4c955ec7dc07ea3a239754ce', E'1791179899', E'1', E'1788587899'),
(E'73', E'8', E'c12ad4e7c5cd7d9dfabe40b82a0cd0d593cac3e453a0ef20f386aef71361f26f', E'1791182039', E'1', E'1788590039'),
(E'74', E'8', E'14dc8ab713b1f8ffd2e12dcee9bc9417dc76b911c7a1d6ff206cdc7c4617e5b6', E'1791182059', E'1', E'1788590059'),
(E'75', E'8', E'8f7c246cc0504cf50f9991e10b76e0b52e81e26c9f60a326c807f56152912e48', E'1791182164', E'1', E'1788590164'),
(E'76', E'8', E'f963ee99bb9fb2a5df5c88cdaa06c0971dc6cd94a975366c6da2691c26fda82d', E'1791184327', E'1', E'1788592327'),
(E'77', E'8', E'ac298e5efc00267c402c11b049d9d92f520bd690f5ee32a124e3659dfc3d0a19', E'1791184355', E'1', E'1788592355'),
(E'78', E'8', E'361945cad31b12d3ec0923c809020887c957dc3b052aa74a408c1c80f617169b', E'1791184476', E'1', E'1788592476'),
(E'79', E'8', E'91f8df41003d1f7872ef25e1b223c97efc5cdb52f688c5846c1b298a78596878', E'1791185097', E'1', E'1788593097'),
(E'80', E'8', E'd6cf15948cc3994f9b1a91619706571842175a9eb7e39f951206b1ae962943f2', E'1791185134', E'1', E'1788593134'),
(E'30', E'8', E'd79a8269cc47eb22ad5329a79cc90da4142dca5a440eeaffecbeaa35e51cdcd6', E'1790931344', E'1', E'1788339344'),
(E'31', E'8', E'21dd83a1f7ae3a6dce0132aa7ad832b228fced7e1d0ff033102be59bb4fbb336', E'1790931884', E'1', E'1788339884'),
(E'32', E'8', E'f7f66ddf4bbe1c99cbe13e6448ca3dd3bbd5c6ee3d778ca31befe2a39f0eab00', E'1790932111', E'1', E'1788340111'),
(E'33', E'8', E'10e53f9937d779f27e9f62e561620cce92b7b94d4e45ef59060f1a587cbaf0da', E'1790932461', E'1', E'1788340461'),
(E'36', E'8', E'8ab626968d6ba5c85546c0154d1835622f21cba3fd5f70cb8962fcfa1f7c93f9', E'1790937983', E'1', E'1788345983'),
(E'37', E'8', E'8c819c48e7f17a248791390937c488a94fd7c4c7822907900a80f24ff086e394', E'1790938015', E'1', E'1788346015'),
(E'38', E'8', E'1e33cf9f6036efc21bbf28657577290087f8105d76c917f16f320d7cf25f8278', E'1790938336', E'1', E'1788346336'),
(E'39', E'8', E'77962d61a0c55d79de83e31e4efded50e565ca0d09c2f4554c1108e4aecef547', E'1790939501', E'1', E'1788347501');
INSERT INTO public."refresh_tokens" ("id", "user_id", "token_hash", "expires_at", "revoked", "created_at") VALUES
(E'40', E'8', E'c54cf2aececa5fd06701dd6fe2f9ee159f1046a5dd8818eec682498d7ec21a33', E'1790939520', E'1', E'1788347520'),
(E'41', E'8', E'b02994a50c903252e72ea078dc052a535069fe0a814df4de30d4aec57392ed0e', E'1790940991', E'1', E'1788348991'),
(E'42', E'8', E'0994a166bfe5a917d66da3d28e853991a724bad5ba2a56d0589b9f6e93f8ea8c', E'1790941592', E'1', E'1788349592'),
(E'43', E'8', E'941b4c59118ba58915a02f0ae4250b9ba8a0816b62b9a57e2203374b8741b035', E'1790941855', E'1', E'1788349855'),
(E'44', E'8', E'0218725a02ee66cdd6e266d6c34103352602330ffb3979b9051cfbf5126c8f90', E'1790954612', E'1', E'1788362612'),
(E'45', E'8', E'd12c5961d9118c099fe75da3ba3aa193c22c2b343d8f4e43e51977cded7a087e', E'1790954621', E'1', E'1788362621'),
(E'46', E'8', E'21b760a00aa8c919f23c8125ef331cbe935ed5398a9c1c43baeb54ae3945be2f', E'1790954789', E'1', E'1788362789'),
(E'47', E'8', E'4f0dd38a4b1d466d7eb714811747caf8a063a956c4facb35bdaf7c02941ca0d9', E'1790957460', E'1', E'1788365460'),
(E'48', E'8', E'440c584569062a9af0ef693e700e1c94b0c463ed0f682117edf1caed03c90dfb', E'1791083234', E'1', E'1788491234'),
(E'49', E'8', E'29182427b04947c65f4dbc4b750171efaa381eea076d9dc683414fb79ef941ec', E'1791101818', E'1', E'1788509818'),
(E'50', E'8', E'0f63fe9607968891784916633771b78c4125088c1cb9f612ce5b728428167f08', E'1791101826', E'1', E'1788509826'),
(E'51', E'8', E'cf652c732ae41567dcea1877f12d2042c527476d97dc673221aeee328918e7d9', E'1791126011', E'1', E'1788534011'),
(E'52', E'8', E'dcba5a0e495748eb77177f2b9c842ea6b8848bb0a4d9f2f8ba3a6f7814f3899f', E'1791126023', E'1', E'1788534023'),
(E'111', E'8', E'ce67009fb0eac30226a49f5800e4935f7433024e231971e18832ac997151544a', E'1792061397', E'1', E'1789469397'),
(E'112', E'8', E'816b96381d886887a05c12ec5883371671a95a9fa3bf27a308097c6565a23473', E'1792062995', E'1', E'1789470995'),
(E'113', E'8', E'6b6a514221de8aa4c0fda45fa6c9d6eab318aeb3ae8f72c37eec1100e11ffb73', E'1792114905', E'1', E'1789522905'),
(E'114', E'8', E'68af59790a958f09d2fe65728fdae7cf01ea59b40b99f1cd69977a4eefce3919', E'1792415160', E'1', E'1789823160'),
(E'115', E'8', E'6cc47083bd5373028aba68c89c0050ab2d691ee65e20993d50e8002cc500a1ba', E'1792415169', E'1', E'1789823169'),
(E'119', E'8', E'1b965638435da6e428c9d3d3316ac77ea3dad74f3427eed61cfd1dcaed5c1856', E'1792666163', E'1', E'1790074163'),
(E'121', E'8', E'3a2936b9b9caff6e7c9a49b37ea0e58eddd170e6919a414789b6e0665011b51f', E'1792762709', E'1', E'1790170709'),
(E'122', E'8', E'33a51e39bf62ebb67c66bf6d1e5a70ebe1c5ac9eec7ec8773951ef1e9516bf6b', E'1792764003', E'1', E'1790172003'),
(E'123', E'8', E'0cdb2771c2cc28cb0bc31e9f11e154010abe7f711f1aa63d510ccb3a482e4324', E'1792764012', E'0', E'1790172012');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'443', E'6', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'Look 1', E'1', E'1', E'1790050749', E'1790075943', E'[1,1,1,0,1,0,1,0,1,1,1,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,1,1,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,1,1,0,0]', E'0', E'0', E'0', E'0'),
@ -2631,14 +2645,10 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1310', E'6', E'street/1409b252c6f16b125b58a3d35180b506d8acec23.jpg', E'Look 30', E'17', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,1,1,0,1,0,1,1,0,0,1,1,1,1,1,0,0,1,0,0,1,1,0,0,0,0,1,0,0,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1311', E'6', E'street/b0689f6df162315cc055a36dd203a5928574e452.jpg', E'Look 31', E'18', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,1,0,1,1,1,1,1,1,1,0,0,0,1,1,1,0,1,0,1,1,1,0,0,0,1,0,0,1,1,1,0,0,1,0,1,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1312', E'6', E'street/64100be46b4b98217c9e1312444c7b3a51d58bbb.jpg', E'Look 33', E'19', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,1,0,1,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,1,0,0,0,1,0,0,0,1,1,0,0,0,0,1,0,0,1,1,0,0,0,1,1,0,1,0,1,1,0,0,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1313', E'6', E'street/52c8f45238ab3123cc862e5d2dd616c3d17b263c.jpg', E'Look 34', E'20', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1314', E'6', E'street/24423a4e3abcb2c3b509c3263ce00fcd82e29a91.jpg', E'Look 35', E'21', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1315', E'6', E'street/34b76ef6445d720dd761776af9dc77376ecff272.jpg', E'Look 36', E'22', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,0,0,0,1]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1316', E'6', E'street/09609a450e9320635999c06bd879d2d78440b0e9.jpg', E'Look 37', E'23', E'0', E'1790076009', E'1790076009', E'[1,1,0,0,1,1,1,0,1,1,0,0,1,1,1,1,1,1,0,0,1,1,0,0,1,1,0,1,1,1,0,1,1,1,0,1,1,1,0,0,1,0,0,0,1,0,1,0,0,1,1,0,1,0,1,0,0,0,1,0,0,0,1,0]', E'0', E'0', E'0', E'0'),
(E'1317', E'6', E'street/ad5b8f665970b4b83fcaa0cdd23c3cf032afb52e.jpg', E'Look 38', E'24', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,1,0,0,1,1,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1318', E'6', E'street/c8c0107e64bd5394cba9cf57f350090ddd4e4997.jpg', E'Look 39', E'25', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,1,1,0,0,1,0,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1319', E'6', E'street/945e3d8178257ddf289cd2f18b52473c179433c1.jpg', E'Look 40', E'26', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1]', E'0', E'0', E'0', E'0'),
(E'1319', E'6', E'street/945e3d8178257ddf289cd2f18b52473c179433c1.jpg', E'Look 40', E'26', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1320', E'6', E'street/c404d4e581065dbf5bff9ad6ad0788dc1b2898c9.jpg', E'Look 41', E'27', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,0,1,1,0,0,0,1,1,0,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,0,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1321', E'6', E'street/1a65cf85c9d26f9a284b639d75b2adcb72f1e110.jpg', E'Look 42', E'28', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,0,0,0,1,0,1,0,1,1,1,0,0,1,0,0,1,0,1,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,1,1,0,0,1,1,0,0,1,0,1,0,0,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1322', E'6', E'street/8469a3384ad87f1631551bd5dd446a9626b49434.jpg', E'Look 43', E'29', E'0', E'1790076009', E'1790076009', E'[0,1,1,1,0,1,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,1,0,1,1,1,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
@ -2646,6 +2656,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1324', E'6', E'street/7f1902f48c3f801ee94de5d2088ea9647e543917.jpg', E'Look 45', E'31', E'0', E'1790076009', E'1790076009', E'[0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,1,0,0,1,0,0,0,0,1,0,0,0,1,1,0,1,1,0,1,1,1,0,1,0,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'),
(E'1325', E'6', E'street/5cb18e2bc75e1be3b27b20eb12752264e90ef700.jpg', E'Look 46', E'32', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,0,0,1,1,1,0,0,0,1,0,1,1,1,0,1,1,0,0,0,1,1,1,1,1,0,0,0,1,0,0,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,1,0,1,1,0,0]', E'0', E'0', E'0', E'0'),
(E'1326', E'6', E'street/ceed2936f9dccd5f9dd7ff4eafd2738f07b579c6.jpg', E'Look 47', E'33', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,0,0,1,1,0,0,0,1,0,1,1,1,1,0,0,1,0,0,1,1,1,0,0,0,1,0,1,0,1,1,1,0,1,1,0,0,1,1,1,1,0,1,0,1,1,0,1,1,0,1,0,0,1,0,1]', E'0', E'0', E'0', E'0'),
(E'1313', E'6', E'street/52c8f45238ab3123cc862e5d2dd616c3d17b263c.jpg', E'Look 34', E'20', E'0', E'1790076009', E'1790266014', E'[0,1,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'1', E'1312'),
(E'1314', E'6', E'street/24423a4e3abcb2c3b509c3263ce00fcd82e29a91.jpg', E'Look 35', E'21', E'1', E'1790076009', E'1790268289', E'[1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1315', E'6', E'street/34b76ef6445d720dd761776af9dc77376ecff272.jpg', E'Look 36', E'22', E'1', E'1790076009', E'1790268299', E'[0,1,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1327', E'6', E'street/5ee5632ae55908eafe20f81be70398b0072e4e67.jpg', E'Look 48', E'34', E'0', E'1790076009', E'1790076009', E'[1,1,0,0,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,1,1,1,1,0,0,1,0,1,0,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1328', E'6', E'street/c4c2eb5f2e5005a2cba8dfdb286042d6bb472104.jpg', E'Look 49', E'35', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,1,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,0,1,0,1,1,1,1,0,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'),
(E'1329', E'6', E'street/39e218c3f927a49d7d4973946f55077ef7a0e5e4.jpg', E'Look 50', E'36', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,0,1,0,0,0,0,1,0,1,0,1,1,1,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,0,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'),
@ -2734,9 +2747,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1412', E'6', E'street/604f4b1723da9652e1d96f94318507baf9bca0b6.jpg', E'Look 133', E'119', E'0', E'1790076009', E'1790076009', E'[0,1,1,1,0,1,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0,1,0,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'),
(E'1413', E'6', E'street/7d7b5efd894c32ee7ccd769fc23644adb876ab84.jpg', E'Look 134', E'120', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,1,0,0,0,0,1,0,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,1,0,1,0,0,0,0,1,1,1,1,0,1,0,0,1,1,1,0,0,1,1,1]', E'0', E'0', E'0', E'0'),
(E'1414', E'6', E'street/a73e82297883c46a82b51a0a44055666dc0f15f0.jpg', E'Look 135', E'121', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,0,0,1,0,1,1,1,0,0,0,1,0,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'),
(E'1415', E'6', E'street/209ac6a167f9f24308914c8abdaa80976269b2e1.jpg', E'Look 136', E'122', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'0', E'0');
(E'1415', E'6', E'street/209ac6a167f9f24308914c8abdaa80976269b2e1.jpg', E'Look 136', E'122', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,1,1,0,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1416', E'6', E'street/a8cf88415fbdb6eff5efeba173ddf46db6ff7341.jpg', E'Look 137', E'123', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,1,0,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1416', E'6', E'street/a8cf88415fbdb6eff5efeba173ddf46db6ff7341.jpg', E'Look 137', E'123', E'0', E'1790076009', E'1790076009', E'[0,1,0,1,1,0,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1417', E'6', E'street/4ed4b384433acaa514d16120d6d8a06f8047f63e.jpg', E'Look 138', E'124', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,0,1,1,1,0,1,1,1,0,0,0,1,0,1,1,1,1,0,0,1,1,1,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,0,0,1,0,1,1,1,0,0,1,0]', E'0', E'0', E'0', E'0'),
(E'1418', E'6', E'street/210ca1c7e99e4ed2c6af9205118bcd9d9a4e286e.jpg', E'Look 139', E'125', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,1,1,0,0,0,1,1,0,0,1,0,1,1,1,1,0,0,1,0,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,0,1,1,1,0,1,0,1,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1419', E'6', E'street/4d9230d8797eef47931fe570ccf06860039bf21a.jpg', E'Look 140', E'126', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,0,0,0,1,1,1,1,1,0,0,1,1,1,1,1,1,1,0,1,0,1,0,1,0,1,1,0,0,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,1,0,0,1,0,0,0,1,1,1,1,0,1,0,0]', E'0', E'0', E'0', E'0'),
@ -2835,9 +2848,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1512', E'6', E'street/3c36931964afaa0b13563ac2d5d6f1a00bf31cff.jpg', E'Look 233', E'219', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,1,0,1,0,1,0,0,1,0,1,0,0,0,0,0,0,1,1,0,1,0,0,0,0,1,1,1,0,1,0,1,1,0,1,1,0,0,0,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,0,1,1]', E'0', E'0', E'0', E'0'),
(E'1513', E'6', E'street/e48204d9d7612eb1a4cde6ac706ec457053bdf26.jpg', E'Look 234', E'220', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,1,0,0,1,1,1,0,0,1,0,1,1,1,0,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1,1,1,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1514', E'6', E'street/a0af9f33fb8def1c7555eb5a70c7ad3f1608f840.jpg', E'Look 235', E'221', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1,1,0,0,1,1,0,1,1,1,1,0,1,1,1,1,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1515', E'6', E'street/bfe38b19823bb0a60646c60ec553dc034eec2d07.jpg', E'Look 236', E'222', E'0', E'1790076009', E'1790076009', E'[1,1,1,1,0,0,0,0,1,1,1,1,0,1,0,0,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0');
(E'1515', E'6', E'street/bfe38b19823bb0a60646c60ec553dc034eec2d07.jpg', E'Look 236', E'222', E'0', E'1790076009', E'1790076009', E'[1,1,1,1,0,0,0,0,1,1,1,1,0,1,0,0,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1516', E'6', E'street/5f0d4db8cf460df9487bec7f3b72b689e1c48ab3.jpg', E'Look 237', E'223', E'0', E'1790076009', E'1790076009', E'[1,1,0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,1,1,1,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,1,1,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,1]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1516', E'6', E'street/5f0d4db8cf460df9487bec7f3b72b689e1c48ab3.jpg', E'Look 237', E'223', E'0', E'1790076009', E'1790076009', E'[1,1,0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,1,1,1,0,0,1,1,1,1,1,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,1,1,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1517', E'6', E'street/167cddda5d2c780a94d8136a142b1c4e542c3c2b.jpg', E'Look 238', E'224', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,0,0,0,1,1,1,1,0,0,0,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1,0,0,0,0,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1518', E'6', E'street/9d4164f07e4339e99dbbf9fe9dc3eb06556016df.jpg', E'Look 239', E'225', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,0,0,1,1,0,1,0,0,1,0,1,1,0,1,0,0,0,0,0,1,1,0,0,0,0,1,0,1,1,0,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,0,1,0,0,1,0,1,1,0,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1519', E'6', E'street/023955dc7e06e660231ec664f97cb39fe9f439c3.jpg', E'Look 240', E'226', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,0,0,0,0,0]', E'0', E'0', E'0', E'0'),
@ -2936,9 +2949,9 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1612', E'6', E'street/217bfe6290196fc69314483198be5031b467583c.jpg', E'Look 333', E'319', E'0', E'1790076009', E'1790076009', E'[1,0,0,0,1,0,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,1,0,0,1,0,0,1,1,0,1,1,1,0,0,1,0,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,0,0,1,1,1,1,0,0,0,1,0,1]', E'0', E'0', E'0', E'0'),
(E'1613', E'6', E'street/26ec653a2ebc5c9d6726a5b2144452d8047b7ad5.jpg', E'Look 334', E'320', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,0,1,1,1,1,1,0,0,0,1,1,0,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,0,1,1,0,1,0,1,1,0,0,1,1,0,1,1,1,0,0,0,1,0,1,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1614', E'6', E'street/3ead248b9ac8f09644f242b5454d425a10bd2907.jpg', E'Look 335', E'321', E'0', E'1790076009', E'1790076009', E'[0,0,1,0,0,1,1,1,0,1,1,1,0,1,0,1,0,1,0,1,1,1,1,0,0,0,0,0,1,1,1,0,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,0,0,1,1,1,0,1,1]', E'0', E'0', E'0', E'0'),
(E'1615', E'6', E'street/f1f338f6db7d836bb5fd8f0af448b4440e0458cb.jpg', E'Look 336', E'322', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0');
(E'1615', E'6', E'street/f1f338f6db7d836bb5fd8f0af448b4440e0458cb.jpg', E'Look 336', E'322', E'0', E'1790076009', E'1790076009', E'[1,0,0,1,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,1,0,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,1,0,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1616', E'6', E'street/1bc5d0482eeba7de40d06eb69dcf3db2670c1518.jpg', E'Look 337', E'323', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,1,0,1,1,1,0,0,1,1,1,1,1,0,0,0,1,0,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1616', E'6', E'street/1bc5d0482eeba7de40d06eb69dcf3db2670c1518.jpg', E'Look 337', E'323', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,0,0,1,0,0,1,1,0,0,0,1,0,0,1,0,0,1,1,0,1,0,1,0,0,1,1,0,1,1,0,1,1,1,0,0,1,1,1,1,1,0,0,0,1,0,1,0,1,1,0,0,1,0,1,1,1,1,0,0,1]', E'0', E'0', E'0', E'0'),
(E'1617', E'6', E'street/39cd988b752e66645bc1108220edd54883cfaf1d.jpg', E'Look 338', E'324', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,1,1,0,1,0,0,1,1,0,0,0,1,0,1,0,0,0,1,0,1,1,0,0,0,1,1,0,0,1,0,1,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,0,1,1,0,0,0,0,1,1,1,1]', E'0', E'0', E'0', E'0'),
(E'1618', E'6', E'street/c2fb7bbe6fd55429e4e6882eaf227b2708799102.jpg', E'Look 339', E'325', E'0', E'1790076009', E'1790076009', E'[1,0,1,1,1,1,0,0,1,1,0,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,1,0,0,0,1,0,0,0,1,0,0,1,1,1,0,0,1,0,1,0,0,0,0,1,1,1,1,0,0,0,1,1,1]', E'0', E'0', E'0', E'0'),
(E'1619', E'6', E'street/9698c65f27d4f691e49c3eeb0e15f188dd268312.jpg', E'Look 340', E'326', E'0', E'1790076009', E'1790076009', E'[0,1,1,0,1,1,0,1,0,1,1,0,0,1,0,1,1,1,0,0,1,0,1,1,1,0,0,0,1,0,0,1,1,0,0,0,0,1,0,1,1,0,1,1,0,1,0,1,1,1,1,0,0,1,1,1,0,1,1,1,1,1,0,0]', E'0', E'0', E'0', E'0'),
@ -3037,14 +3050,15 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'1712', E'6', E'street/414b2581b7e274400ff7bd3d779795d053d10176.jpg', E'Look 20', E'419', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,1,0,0,0,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,0,1,0,1,0,0,0,1,1,0,0,0,0,0,0,0,1,0,1,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0]', E'0', E'0', E'1', E'1304'),
(E'1713', E'6', E'street/a409cfbd15e235a57d55edaf923cac3957c2f60a.jpg', E'Look 21', E'420', E'0', E'1790076009', E'1790076009', E'[1,0,1,0,0,0,1,1,1,1,0,1,1,0,0,1,0,1,1,1,1,0,1,1,0,1,1,1,0,1,1,1,0,1,0,0,0,1,0,1,0,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0,1,1,0,0,1,1,0,0]', E'0', E'0', E'1', E'1304'),
(E'1714', E'6', E'street/d5a02679fc9f5187d156403590cdf25488c90273.jpg', E'Look 24', E'421', E'0', E'1790076009', E'1790076009', E'[1,1,1,0,1,1,0,1,1,0,1,0,1,1,0,1,0,0,1,0,0,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,0,0,0,0,1,1,0]', E'0', E'0', E'1', E'1307'),
(E'1715', E'6', E'street/88fa4a79aab8087ec11086be4257e52bd5263a86.jpg', E'Look 25', E'422', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,1,0,0,1,0,1,0,0,1,0,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,1,0,0,1,1,0]', E'0', E'0', E'1', E'1307');
(E'1715', E'6', E'street/88fa4a79aab8087ec11086be4257e52bd5263a86.jpg', E'Look 25', E'422', E'0', E'1790076009', E'1790076009', E'[0,1,0,0,1,0,0,1,0,1,0,0,1,0,1,1,1,0,0,0,0,1,1,1,1,0,0,0,0,1,1,1,0,0,1,0,0,1,1,1,1,0,1,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,1,0,0,1,1,0]', E'0', E'0', E'1', E'1307'),
(E'1716', E'6', E'street/6539e151e4d152bea9f5f0ca944a2f10c915ba40.jpg', E'Look 28', E'423', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,1,0,0,1,1,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'1', E'1308');
INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort_order", "is_deleted", "created_at", "updated_at", "phash", "is_duplicate", "dup_of", "is_detail", "parent_image_id") VALUES
(E'1716', E'6', E'street/6539e151e4d152bea9f5f0ca944a2f10c915ba40.jpg', E'Look 28', E'423', E'0', E'1790076009', E'1790076009', E'[0,0,0,1,0,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,1,0,0,1,1,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,0,1,0,0,1,1,1,1,1,0,0,1,1,0,0,1,1,0,0,1,1]', E'0', E'0', E'1', E'1308'),
(E'1717', E'6', E'street/6c5aa016a75e33ca8cbfff66dbc91d1736294fd2.jpg', E'Look 32', E'424', E'0', E'1790076009', E'1790076009', E'[0,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,1,0,0,1,0,1,0,0,1,1,0,1,0,1,1,1,0,0,1,1,1,1,1,1]', E'0', E'0', E'1', E'1311'),
(E'1885', E'146', E'street/7e3e6bc3312d66deb524e3430eac830245b3644b.jpg', E'Look 3', E'3', E'0', E'1789914056', E'1790226796', E'[0,1,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,1,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,0,1,1,0,0,0,1,1,1,0,1]', E'0', E'0', E'1', E'1884'),
(E'1886', E'146', E'street/868c6c56a10d076af39e8b47d9e49f0bd64bff0d.jpg', E'Look 4', E'4', E'0', E'1789914056', E'1790226796', E'[0,0,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,1,0,1,0,1,0,0,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0]', E'0', E'0', E'1', E'1884'),
(E'1318', E'6', E'street/c8c0107e64bd5394cba9cf57f350090ddd4e4997.jpg', E'Look 39', E'25', E'0', E'1790076009', E'1790226860', E'[0,0,1,0,1,1,0,0,0,0,0,0,1,1,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0,1,0,0,1,1,0,0,1,0,1,0,0,0]', E'0', E'0', E'1', E'1317'),
(E'1883', E'146', E'street/a9c1b744df1a9aaf7067d2e88835aead98fb4003.jpg', E'Look 1', E'1', E'0', E'1789914056', E'1789914056', E'[0,1,1,0,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,1,0,1,0,1,1,0,0,0,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1884', E'146', E'street/30f5ea0b1609bf24084cb74b456189ad010d4841.jpg', E'Look 2', E'2', E'0', E'1789914056', E'1789914056', E'[1,1,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,1,0,1,0,0,0,1,1,1,0,0,1,0,0,1,1,1,0,0,1,0,1,0,1,1,1,0,1,0,1,1,0,0,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
(E'1885', E'146', E'street/7e3e6bc3312d66deb524e3430eac830245b3644b.jpg', E'Look 3', E'3', E'0', E'1789914056', E'1789914056', E'[0,1,1,1,0,0,1,1,0,0,1,1,0,0,1,0,0,1,0,1,1,0,1,1,1,0,0,1,1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,0,0,0,1,1,0,1,1,0,0,0,1,1,1,0,1]', E'0', E'0', E'0', E'0'),
(E'1886', E'146', E'street/868c6c56a10d076af39e8b47d9e49f0bd64bff0d.jpg', E'Look 4', E'4', E'0', E'1789914056', E'1789914056', E'[0,0,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,0,1,1,0,1,0,1,0,0,1,1,1,1,0,0,1,0,1,1,1,1,1,0,0,0,0,0,1,1,1,0,0]', E'0', E'0', E'0', E'0'),
(E'1887', E'146', E'street/aabb887c88460c18dca25bffc99ef48419607d71.jpg', E'Look 5', E'5', E'0', E'1789914056', E'1789914056', E'[0,0,0,1,1,0,1,0,0,0,0,0,1,0,0,1,0,0,1,0,1,0,1,1,0,0,1,0,0,1,1,1,0,0,0,0,1,1,0,0,1,0,0,0,1,1,1,0,0,0,0,0,1,1,1,0,0,0,0,1,1,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1888', E'146', E'street/7481ef54db0777e8e0176a00caea9518b3956f36.jpg', E'Look 6', E'6', E'0', E'1789914056', E'1789914056', E'[1,0,0,0,0,1,1,1,1,0,0,1,0,1,1,0,1,0,1,1,0,1,1,1,1,0,1,1,0,0,1,0,1,1,0,1,0,0,1,1,1,0,0,1,0,0,1,1,0,1,1,1,0,0,1,0,0,1,1,1,0,1,1,0]', E'0', E'0', E'0', E'0'),
(E'1889', E'146', E'street/7c3bd0ca4a588ac98255868d2f6b2e55683e2257.jpg', E'Look 7', E'7', E'0', E'1789914056', E'1789914056', E'[1,0,1,0,0,1,0,1,0,1,1,1,0,1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,1,1,1,0,1,0,0,0,1,1,0,1,1,1,0,0,0,0]', E'0', E'0', E'0', E'0'),
@ -3674,10 +3688,10 @@ INSERT INTO public."street_snap_images" ("id", "snap_id", "image", "name", "sort
(E'2507', E'149', E'street/0a32a3b64a43db4a6655a3ebcf5d65530e2ad904.jpg', E'Look 59', E'59', E'0', E'1789917788', E'1789917788', E'[1,1,1,1,1,0,0,0,1,1,0,1,1,1,0,0,1,0,0,0,0,1,1,0,1,0,1,0,0,0,1,0,1,1,1,1,0,0,1,0,1,0,1,0,1,0,0,1,1,0,1,0,1,0,0,0,1,1,1,0,1,0,0,0]', E'0', E'0', E'0', E'0');
INSERT INTO public."street_snaps" ("id", "title", "year", "city", "cover", "image_count", "is_deleted", "created_at", "updated_at", "source", "source_url", "status", "job_id", "reviewer", "reject_reason") VALUES
(E'6', E'London Street Style Spring 2027 Day 3', E'2027', E'London', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'424', E'0', E'1790006883', E'1790076009', E'', E'', E'published', E'0', E'', E''),
(E'146', E'London Street Style Spring 2027 Day 2', E'2027', E'London', E'street/a9c1b744df1a9aaf7067d2e88835aead98fb4003.jpg', E'329', E'0', E'1789914056', E'1789914056', E'', E'', E'pending', E'100', E'', E''),
(E'147', E'The Best Street Style of Milan Fashion Week Fall 2026', E'2026', E'Milan', E'street/751fbc91e3e5fba3c4ca3420928cc829cbf1ad20.jpg', E'156', E'0', E'1789914679', E'1789914679', E'', E'', E'pending', E'101', E'', E''),
(E'148', E'The Best Street Style of London Fashion Week Fall 2026', E'2026', E'London', E'street/1bdd3d1c5256a9601b24373934cad5d60661f301.jpg', E'81', E'0', E'1789915003', E'1789915003', E'', E'', E'pending', E'102', E'', E''),
(E'6', E'London Street Style Spring 2027 Day 3', E'2027', E'London', E'street/4928cc08576f7c041facc49a813f08fff0bc51da.jpg', E'422', E'0', E'1790006883', E'1790076009', E'', E'', E'published', E'0', E'', E''),
(E'149', E'The Best Street Style From Copenhagen Fashion Week', E'0', E'Copenhagen', E'street/697a793538c53668ff288073b8e361c73578478f.jpg', E'59', E'0', E'1789917788', E'1790144662', E'', E'', E'published', E'104', E'admin', E'');
INSERT INTO public."users" ("id", "created_at", "updated_at", "username", "email", "password_hash", "is_deleted", "tier") VALUES

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 计全部图(含副图)。
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。
- ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。

View File

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

View File

@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; }
display: flex;
flex-direction: column;
}
.cell.det { background: #f8faff; border-color: #c7d2fe; }
/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */
.cell.det { background: #eef2ff; border: 2px solid #818cf8; }
/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */
.cell.det { display: none; }
.cell.det.shown { display: flex; }
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
.cell .thumb {
@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 0; }
transition: opacity .15s;
}
.cell .thumb:hover::after { opacity: 1; }
.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; }
.cell.det .role { color: #3b4bb5; }
/* 角色标签统一做成胶囊徽标:主图绿、细节图靛蓝,颜色编码一眼可分 */
.cell .role {
align-self: flex-start;
margin: 6px 8px 0;
padding: 2px 9px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: #1a7f43;
background: #e6f7ec;
}
.cell.det .role { color: #fff; background: #4338ca; }
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
@ -339,6 +353,44 @@ tbody tr:last-child td { border-bottom: 0; }
cursor: pointer;
}
.btn:hover { border-color: #111; }
.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; }
/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */
/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮,
收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */
.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ops .inline { display: inline-flex; margin: 0; padding: 0; }
.ops .view {
display: inline-block;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
color: #111;
background: #fff;
}
.ops .view:hover { border-color: #111; }
.op {
margin: 0;
padding: 5px 11px;
font-size: 13px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
white-space: nowrap;
}
.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; }
.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; }
.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; }
.op-primary:hover, .op-danger:hover { opacity: .9; }
.op-warn:hover { background: #fff8e6; }
/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */
.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; }
.review-actions .inline { display: inline-flex; margin: 0; padding: 0; }
.chk {
display: flex;
align-items: center;
@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; }
.chk:hover { border-color: #111; }
.chk input { margin: 0; }
/* ---------- 看大图弹层(<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 {
position: sticky;

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"net/http"
"strconv"
@ -8,7 +9,9 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/repository"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/service"
@ -30,11 +33,12 @@ type BackstageHandler struct {
street service.StreetSnapService
review service.ReviewService
ingest *service.IngestService
adminRepo repository.AdminRepository
}
// NewBackstageHandler 创建后台 handler。
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
}
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
@ -296,7 +300,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
}
// ── 审核记录 ───────────────────────────────────────────────────────────────────
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
func (h *BackstageHandler) ReviewList(c *gin.Context) {
kind := c.Param("kind")
if kind == "" {
kind = c.Query("kind")
}
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。
func (h *BackstageHandler) Articles(c *gin.Context) {
status := c.Query("status")
kind := c.Query("kind")
if kind != "runway" && kind != "street" {
kind = "" // 非法的 kind 回落为「全部」
}
page := atoiDefault(c.Query("page"), 1)
if page < 1 {
page = 1
}
// 校验 kind 是否在注册表中;未知 kind 退回「全部」。
kinds := h.review.KindTabs(c.Request.Context())
registered := map[string]bool{}
for _, t := range kinds {
if t.Kind != "" {
registered[t.Kind] = true
}
}
if kind != "" && kind != "all" && !registered[kind] {
kind = ""
}
records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20)
const size = 24
cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
// 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews
listBase := ""
if kind != "" && kind != "all" {
listBase = "/" + kind
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
items := make([]gin.H, 0, len(cards))
for _, card := range cards {
var t byte
if card.Kind == "runway" {
t = hashid.TypeRunway
} else {
t = hashid.TypeSnap
}
p := pagination.Params{Page: page, PageSize: 20}
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,
})
}
// 状态 tab 角标(空串=全部)。
tabCounts := map[string]int64{}
for _, s := range []string{"", "pending", "published", "rejected"} {
n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind)
if e == nil {
tabCounts[s] = n
}
}
p := pagination.Params{Page: page, PageSize: size}
meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "review-list", gin.H{
"Records": records,
"Total": total,
renderPage(c, http.StatusOK, "articles", gin.H{
"Items": items,
"Status": status,
"Kind": kind,
"Kinds": kinds,
"ListBase": listBase,
"TabCounts": tabCounts,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
@ -358,96 +377,74 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
})
}
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
out := make(map[uint32]repository.DupStat)
runwayIDs := make([]uint32, 0, len(cards))
streetIDs := make([]uint32, 0, len(cards))
for _, c := range cards {
if c.Kind == "runway" {
runwayIDs = append(runwayIDs, c.ID)
} else {
streetIDs = append(streetIDs, c.ID)
}
record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id))
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
return
}
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := ""
if record.BrandID > 0 {
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
brandName = b.NameEn
if brandName == "" {
brandName = b.NameCn
if len(runwayIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
for id, s := range m {
out[id] = s
}
}
}
renderPage(c, http.StatusOK, "review-detail", gin.H{
"Record": record,
"BrandName": brandName,
})
if len(streetIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
for id, s := range m {
out[id] = s
}
}
}
return out
}
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return
}
fields := reviewFormFields(c)
if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
username, _ := c.Get(middleware.ContextUsername)
if _, err := h.review.Approve(c.Request.Context(), kind, uint32(id), usernameString(username)); err != nil {
if _, err := h.review.Approve(c.Request.Context(), kind, id, usernameString(username)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewListURL(kind))
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
}
// ReviewReject 驳回:仅记录状态与理由。
func (h *BackstageHandler) ReviewReject(c *gin.Context) {
// ArticleReject 驳回:记录状态与理由(理由用于审核留痕)。
func (h *BackstageHandler) ArticleReject(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
reason := c.PostForm("reason")
username, _ := c.Get(middleware.ContextUsername)
if err := h.review.Reject(c.Request.Context(), kind, uint32(id), usernameString(username), reason); err != nil {
if err := h.review.Reject(c.Request.Context(), kind, id, usernameString(username), reason); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewListURL(kind))
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
}
// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
}
// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
@ -459,48 +456,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
imgIDs = append(imgIDs, uint32(v))
}
}
if len(imgIDs) == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"})
if len(imgIDs) == 0 || mainID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"})
return
}
if mainID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return
}
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
c.Redirect(http.StatusFound, articleEditURL(kind, id))
}
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
// ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。
func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
imgID := atoiDefault(c.Param("img"), 0)
if imgID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DetachImage(c.Request.Context(), kind, id, uint32(imgID)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
c.Redirect(http.StatusFound, articleEditURL(kind, id))
}
// reviewDetailURL 生成审核详情页跳转地址。
func reviewDetailURL(kind string, id uint32) string {
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
}
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
func reviewListURL(kind string) string {
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
return "/admin/reviews/" + kind
// articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。
// 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。
func articleEditURL(kind string, id uint32) string {
var t byte
if kind == "runway" {
t = hashid.TypeRunway
} else {
t = hashid.TypeSnap
}
return "/admin/reviews"
if kind == "runway" {
return "/admin/runways/" + hashid.EncodeWithType(id, t) + "/edit"
}
return "/admin/street-snaps/" + hashid.EncodeWithType(id, t) + "/edit"
}
// ── 爬虫入库任务监控 ──────────────────────────────────────────────────────────
@ -637,12 +636,76 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
brandName = b.NameCn
}
}
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.BrandRunwayImage{}
var mains []model.BrandRunwayImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
mains = append(mains, im)
}
}
mainIDs := map[uint32]bool{}
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
})
}
}
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
for pid, ds := range detailsByParent {
if mainIDs[pid] {
continue
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
renderPage(c, http.StatusOK, "runway-edit", gin.H{
"UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw,
"Images": imgs,
"Cells": cells,
"BrandName": brandName,
"IsDown": rw.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
})
}
@ -674,7 +737,7 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwayImageDelete 软删单张走秀图片。
@ -693,7 +756,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwaySetCover 把某张图片设为封面。
@ -713,7 +776,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。
@ -732,7 +795,7 @@ func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// ── 街拍编辑 ─────────────────────────────────────────────────────────────────────
@ -750,11 +813,75 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.StreetSnapImage{}
var mains []model.StreetSnapImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
mains = append(mains, im)
}
}
mainIDs := map[uint32]bool{}
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
})
}
}
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
for pid, ds := range detailsByParent {
if mainIDs[pid] {
continue
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
renderPage(c, http.StatusOK, "snap-edit", gin.H{
"UID": raw,
"Kind": "street",
"RecordID": snap.ID,
"Status": snap.Status,
"Snap": snap,
"Images": imgs,
"Cells": cells,
"IsDown": snap.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
})
}
@ -774,6 +901,7 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
}
fields := map[string]any{
"title": c.PostForm("title"),
"title_cn": c.PostForm("title_cn"),
"city": c.PostForm("city"),
"year": year,
}
@ -781,7 +909,7 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapImageDelete 软删单张街拍图片。
@ -800,7 +928,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapSetCover 把某张图片设为封面。
@ -820,7 +948,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapToggleDeleted 上下架。
@ -839,7 +967,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// atoiDefault 把字符串转 int,失败回落默认值。

View File

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

View File

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

View File

@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) {
// GET /api/v1/ssg/street-snaps/popular
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
const limit = 20
list, err := h.snaps.Popular(c.Request.Context(), limit)
list, err := h.snaps.Popular(c.Request.Context(), limit, parseLocale(c))
if err != nil {
fail(c, err)
return

View File

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

View File

@ -24,9 +24,23 @@ import (
"net/http"
"strings"
"fashionapi/internal/pkg/imgurl"
"github.com/gin-gonic/gin"
)
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
var imgComposer = imgurl.New("", "high", "thumb")
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
func SetImgComposer(c *imgurl.Composer) {
if c != nil {
imgComposer = c
}
}
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS
@ -42,8 +56,10 @@ var assetFS fs.FS
func init() {
funcs := template.FuncMap{
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。
"reviewDetailURL": reviewDetailURL,
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
}
base, err := template.New("layout.html").Funcs(funcs).
@ -94,7 +110,7 @@ func PageNames() []string {
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
//
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
func renderPage(c *gin.Context, status int, page string, data gin.H) {
tpl, ok := pageTemplates[page]

View File

@ -20,6 +20,7 @@
<body>
{{template "content" .}}
<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>
</body>
</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}}
<div class="cards">
<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/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/articles"><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/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
</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 "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"}}
{{template "bar" .}}
<div class="main">
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
<h1>{{.Article.Title}}</h1>
<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>
{{end}}

View File

@ -13,16 +13,14 @@
<button type="submit">保存字段</button>
</form>
<h2>图片管理({{len .Images}} 张)</h2>
<div class="grid">
{{range .Images}}<div class="cell">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<div class="acts">
<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>
</div>
</div>{{end}}
{{template "imageGrid" .}}
{{if eq .Runway.Status "pending"}}
<div class="review-actions">
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</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>
</div>
{{end}}
<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}}

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">
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<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>
<button type="submit">保存字段</button>
</form>
<h2>图片管理({{len .Images}} 张)</h2>
<div class="grid">
{{range .Images}}<div class="cell">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<div class="acts">
<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>
</div>
</div>{{end}}
{{template "imageGrid" .}}
{{if eq .Snap.Status "pending"}}
<div class="review-actions">
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</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>
</div>
{{end}}
<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}}

View File

@ -1,13 +1,13 @@
{{define "title"}}街拍详情 · 管理后台{{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"}}
{{template "bar" .}}
<div class="main">
<p class="muted">编码 {{.Snap.UID}}</p>
<h1>{{.Snap.Title}}</h1>
<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>
{{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>
<nav class="bar-nav">
<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/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/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/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/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}}
</div>
{{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"`
Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
// 主副图分组(列由 db/migrations/2026-09-24-01-runway-parent-image-id.sql 添加):
// 与 street_snap_images 同构——is_detail=0 为主图(parent_image_id=0);
// =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
// 去重字段(近重复 + 语义 embedding 留痕):
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;

View File

@ -8,7 +8,8 @@ import "database/sql"
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
type StreetSnap struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
Title string `gorm:"column:title" json:"title"`
Title string `gorm:"column:title" json:"title"` // 英文标题(既有源,改动前唯一标题列)
TitleCn string `gorm:"column:title_cn" json:"title_cn"` // 中文标题:后台人工翻译;为空时前端/接口回落英文
Year uint16 `gorm:"column:year" json:"year"`
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
Cover string `gorm:"column:cover" json:"cover"`

View File

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

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 {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.BrandRunwayImage
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var rw model.BrandRunway
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
return err
}
if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return tx.Exec(`UPDATE brand_runways SET image_count =
if err := tx.Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, img.RunwayID, img.RunwayID).Error
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if rw.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.RunwayID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
@ -383,6 +411,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", runwayID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

View File

@ -196,3 +196,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
}
}
// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。
func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string {
t.Helper()
var row struct{ Cover string `gorm:"column:cover"` }
if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读 %s.cover 失败: %v", table, err)
}
return row.Cover
}
// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "keep", 1, 0)
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 1)
only := seedEditorRunwayImage(t, db, id, "only", 1, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "a", 1, 0)
seedEditorRunwayImage(t, db, id, "b", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "keep", 1, 0, 0)
drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 1)
only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "a", 1, 0, 0)
seedEditorSnapImage(t, db, id, "b", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
"strings"
"time"
"fashionapi/internal/model"
@ -42,6 +43,8 @@ type IngestRepository interface {
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
@ -335,11 +338,49 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r
})
}
// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
// 进而插入一行注定被索引拒绝的记录。
//
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
title = NormalizeStreetTitle(title)
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
@ -359,6 +400,17 @@ func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city strin
return row.ID, row.Status, true, nil
}
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
// 连续段折叠为单个空格。
//
// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞
// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。
// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数,
// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。
func NormalizeStreetTitle(title string) string {
return strings.Join(strings.Fields(title), " ")
}
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
now := uint32(time.Now().Unix())
snap.CreatedAt, snap.UpdatedAt = now, now

View File

@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
}
func newRunwayImages(cover string) []model.BrandRunwayImage {
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}}
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}}
}
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。

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
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
@ -412,3 +417,89 @@ func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, im
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
//
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
if len(ids) == 0 {
return nil // 集合里只有主图自身:无操作成功
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.BrandRunwayImage
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
// 再把被并入图自身标为副图并指向新主图。
return tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}

View File

@ -224,6 +224,7 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
var snapEditable = map[string]bool{
"title": true,
"title_cn": true,
"year": true,
"city": true,
"cover": true,
@ -274,21 +275,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.StreetSnapImage
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var snap model.StreetSnap
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil {
return err
}
if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return tx.Exec(`UPDATE street_snaps SET image_count =
if err := tx.Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, img.SnapID, img.SnapID).Error
WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if snap.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.SnapID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
@ -326,6 +355,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", snapID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

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,任意后台页面都能显示队列深度
admin.Use(opt.Backstage.NavStats())
{
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
@ -89,31 +89,29 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
admin.GET("/runways", opt.Backstage.Runways)
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/驳回/下架的统一入口。
admin.GET("/articles", opt.Backstage.Articles)
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject)
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
// 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。
// 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -13,7 +13,9 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -164,12 +166,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return nil, service.ErrStreetSnapNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street(待审)",
@ -178,11 +180,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
return nil, nil
}
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
imgs := []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
@ -198,13 +205,63 @@ func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
type fakeAdminRepo struct{}
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
all := []repository.AdminRecordCard{
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
{Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"},
}
if kind != "" {
kept := all[:0]
for _, c := range all {
if c.Kind == kind {
kept = append(kept, c)
}
}
all = kept
}
if status != "" {
kept := all[:0]
for _, c := range all {
if c.Status == status {
kept = append(kept, c)
}
}
all = kept
}
return all, int64(len(all)), nil
}
func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) {
if kind == "runway" {
if status == "" || status == "published" {
return 1, nil
}
return 0, nil
}
if kind == "street" {
if status == "" || status == "pending" {
return 1, nil
}
return 0, nil
}
if status == "" {
return 2, nil
}
if status == "pending" || status == "published" {
return 1, nil
}
return 0, nil
}
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
@ -281,13 +338,17 @@ func TestBackstage(t *testing.T) {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
body := w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
}
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
t.Fatalf("文章管理列表应含状态 tab: %s", body)
}
// 7) 后台走秀详情渲染图集。
@ -301,13 +362,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
body = w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
}
// 9) 后台街拍详情渲染图集。
@ -420,7 +482,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
rev := &fakeReview{attachOK: true}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev
}
@ -444,14 +506,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
@ -465,7 +527,7 @@ func TestBackstageStreetAttachNeedsMain(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3"))
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -483,13 +545,13 @@ func TestBackstageStreetDetach(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/2/detach", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "detach" || rev.last.imgID != 2 {
@ -499,33 +561,23 @@ func TestBackstageStreetDetach(t *testing.T) {
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) {
hashid.Init("test-salt")
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.RecordDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.RecordImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
},
Groups: []service.ImageGroup{
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
},
}
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
// 副图徽标显示所属主图(沿链归并后的实际主图)。
// 副图徽标显示所属主图。
if !strings.Contains(body, "副图 → #1") {
t.Fatalf("副图应显示所属主图")
}
@ -552,7 +604,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -605,7 +657,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
}
// 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", label)
}
@ -619,7 +671,7 @@ func TestBackstageReviewBatchApprove(t *testing.T) {
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -652,7 +704,7 @@ func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -672,7 +724,7 @@ func TestBackstageReviewBatchPartialFailure(t *testing.T) {
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)

View File

@ -194,25 +194,22 @@ func (s *articleService) detailErr(err error) error {
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
byLook := make(map[uint32][]model.BrandRunwayImage)
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
for _, im := range imgs {
if im.IsDetail == 0 {
if im.IsDetail == 0 || im.ParentImageID == 0 {
mains = append(mains, im)
} else {
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
pubMains := s.toPublicImages(mains)
for i := range pubMains {
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
pubMains[i].Detail = s.toPublicImages(kids)
delete(byLook, pubMains[i].LookIndex)
}
}
@ -231,7 +228,45 @@ func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRun
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
return s.articles.GetForEdit(ctx, id)
rw, imgs, err := s.articles.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排,
// 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。
return rw, orderRunwayImagesForEdit(imgs), nil
}
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
out := make([]model.BrandRunwayImage, 0, len(imgs))
for _, main := range mains {
out = append(out, main)
out = append(out, detailsByParent[main.ID]...)
}
// 孤儿细节图(parent 缺失):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
@ -306,7 +341,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out
@ -325,7 +359,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
Image: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out

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 }
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
return nil
}
// fakeBrandRepo 品牌校验恒通过。
type fakeBrandRepo struct{ repository.BrandRepository }

View File

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

View File

@ -28,8 +28,8 @@ type RecordCard struct {
// RecordImageRef 记录图片引用(统一结构)。
//
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
type RecordImageRef struct {
ID uint32 `json:"id"`
Image string `json:"image"`
@ -70,8 +70,9 @@ type RecordDetailView struct {
Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
Groups []ImageGroup `json:"groups,omitempty"`
}
@ -286,20 +287,28 @@ func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID,
}
func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
if len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
default:
return ErrInvalidImageGroup
}
}
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if kind != dto.IngestKindStreet {
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
default:
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
}
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
@ -367,12 +376,58 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
},
}
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
detailCount := make(map[uint32]int, len(detailsByParent))
for p, ds := range detailsByParent {
detailCount[p] = len(ds)
}
for _, main := range mains {
ref := RecordImageRef{
ID: main.ID,
Image: m.img.Compose(main.Image),
Name: main.Name,
SortOrder: main.SortOrder,
IsDetail: 0,
ParentImageID: 0,
DetailCount: detailCount[main.ID],
}
v.Images = append(v.Images, ref)
grp := ImageGroup{Image: ref}
for _, d := range detailsByParent[main.ID] {
dref := RecordImageRef{
ID: d.ID,
Image: m.img.Compose(d.Image),
Name: d.Name,
SortOrder: d.SortOrder,
IsDetail: 1,
ParentImageID: d.ParentImageID,
}
grp.Details = append(grp.Details, dref)
v.Images = append(v.Images, dref)
}
v.Groups = append(v.Groups, grp)
}
return v, nil
}
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateRecord(ctx, id, fields)
}

View File

@ -17,6 +17,7 @@ type fakeGroupRepo struct {
images []model.StreetSnapImage
detail *model.StreetSnap
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
}
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
return nil
}
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
if f.attachRunwayFn != nil {
return f.attachRunwayFn(runwayID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
return nil
}
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
func newGroupService(f *fakeGroupRepo) ReviewService {
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
}
}
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err)
}
}
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
func TestRunwayGroupAttachDelegates(t *testing.T) {
var got struct {
runwayID uint32
mainID uint32
imgIDs []uint32
}
repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error {
got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs
return nil
}}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil {
t.Fatalf("Runway AttachImages 失败: %v", err)
}
if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs)
}
}

View File

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

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