后台店铺数据重复检查独立成菜单:删除店铺导入测试、新增撞款详情卡片区

- 删除「店铺导入测试」菜单及全部实现:admin.html 面板、admin.js 逻辑、
  admin_api.py 路由/常量/权限映射、Java 默认菜单
- 「店铺数据记录」页内子 tab 移除,只保留店铺数据列表
- 「店铺管理」下新增「店铺数据重复检查」菜单(V106 导入测试菜单保留并新增
  V107 清理 + 幂等插入新菜单,Java 清单同步)
- 新面板:筛选/指标卡/店铺上架分布/矩阵表格/撞款详情卡片区(同 ASIN 跨店
  上架明细,查看明细开抽屉,上架时间倒序)
- 重复扫描结果改为 MySQL 缓存 + 每日定时扫描(start_duplicate_scan_scheduler)
- 新增 test_admin_shop_data_duplicate_check.py 单元测试
This commit is contained in:
2026-09-04 00:16:14 +08:00
parent 672b9f9b46
commit d0db577a9c
9 changed files with 2693 additions and 514 deletions
@@ -71,6 +71,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50, "admin_group_shop"), new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50, "admin_group_shop"),
new DefaultAdminMenu("最低价ASIN设置", "admin_skip_price_asin", "skip-price-asin", 60, "admin_group_shop"), new DefaultAdminMenu("最低价ASIN设置", "admin_skip_price_asin", "skip-price-asin", 60, "admin_group_shop"),
new DefaultAdminMenu("店铺数据记录", "admin_shop_data_crawl_tasks", "shop-data-crawl-tasks", 82, "admin_group_shop"), new DefaultAdminMenu("店铺数据记录", "admin_shop_data_crawl_tasks", "shop-data-crawl-tasks", 82, "admin_group_shop"),
new DefaultAdminMenu("店铺数据重复检查", "admin_shop_data_duplicate_check", "shop-data-duplicate-check", 84, "admin_group_shop"),
new DefaultAdminMenu("生成记录", "admin_history", "history", 70, "admin_group_record"), new DefaultAdminMenu("生成记录", "admin_history", "history", 70, "admin_group_record"),
new DefaultAdminMenu("视频任务记录", "admin_image_video_tasks", "image-video-tasks", 75, "admin_group_record"), new DefaultAdminMenu("视频任务记录", "admin_image_video_tasks", "image-video-tasks", 75, "admin_group_record"),
new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80, "admin_group_record"), new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80, "admin_group_record"),
@@ -0,0 +1,9 @@
-- V106: 新增「店铺导入测试」后台菜单(用于重复 ASIN 结果模拟验证)
-- 幂等:仅当 column_key 不存在时插入,绑定到「店铺管理」分组(admin_group_shop)。
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
SELECT '店铺导入测试', 'admin_shop_data_import', 'admin', 'shop-data-import', 83, parent.id
FROM columns parent
WHERE parent.column_key = 'admin_group_shop'
AND NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_import'
);
@@ -0,0 +1,18 @@
-- V107: 「店铺导入测试」菜单下线,新增「店铺数据重复检查」菜单
-- 1) 删除「店铺导入测试」(admin_shop_data_import)后台菜单行与其授权记录(幂等)
DELETE FROM user_column_permission
WHERE column_id IN (
SELECT id FROM columns WHERE column_key = 'admin_shop_data_import'
);
DELETE FROM columns WHERE column_key = 'admin_shop_data_import';
-- 2) 新增「店铺数据重复检查」菜单,挂到「店铺管理」分组(admin_group_shop
-- 幂等:仅当 column_key 不存在时插入。
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
SELECT '店铺数据重复检查', 'admin_shop_data_duplicate_check', 'admin', 'shop-data-duplicate-check', 84, parent.id
FROM columns parent
WHERE parent.column_key = 'admin_group_shop'
AND NOT EXISTS (
SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_duplicate_check'
);
+3 -1
View File
@@ -12,7 +12,7 @@ from flask_cors import CORS
from utils.db import init_db from utils.db import init_db
from blueprints.auth import auth from blueprints.auth import auth
from blueprints.main import main from blueprints.main import main
from blueprints.admin_api import admin_api from blueprints.admin_api import admin_api, start_duplicate_scan_scheduler
from blueprints.version import version_bp from blueprints.version import version_bp
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -50,6 +50,8 @@ app.register_blueprint(version_bp)
def run_app(host='0.0.0.0', port=15124): def run_app(host='0.0.0.0', port=15124):
init_db() init_db()
# 每日凌晨全量扫描重复 ASIN,结果缓存 MySQL,页面读取缓存不再实时拉取 Excel
start_duplicate_scan_scheduler(app)
app.run(host=host, port=port, threaded=True, use_reloader=False) app.run(host=host, port=port, threaded=True, use_reloader=False)
+722 -75
View File
@@ -8,8 +8,9 @@ import re
import secrets import secrets
import tempfile import tempfile
import threading import threading
import time
import zipfile import zipfile
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -52,6 +53,49 @@ IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data' SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data'
# ---------- 拼音首字母(分组管理通讯录用,无第三方依赖) ----------
# GB2312 区位码按拼音音序排列,用码位区间即可精确判定首字母;仅覆盖 GB2312 汉字。
_PYINYIN_BOUNDARIES = [
('A', 0xB0A1), ('B', 0xB0C5), ('C', 0xB2C1), ('D', 0xB4EE), ('E', 0xB6EA),
('F', 0xB7A2), ('G', 0xB8C1), ('H', 0xB9FE), ('J', 0xBBF7), ('K', 0xBFA6),
('L', 0xC0AC), ('M', 0xC2E8), ('N', 0xC4C3), ('O', 0xC5B6), ('P', 0xC5BE),
('Q', 0xC6DA), ('R', 0xC8BB), ('S', 0xC8F6), ('T', 0xCBFA), ('W', 0xCDDA),
('X', 0xCEF4), ('Y', 0xD1B9), ('Z', 0xD4D1),
]
_pinyin_boundaries_end = 0xF7FF
def _char_pinyin_initial(char):
"""返回单个汉字 / 数字 / 字母的拼音首字母,非 GB2312 汉字返回 '#'"""
code = ord(char)
if 0x30 <= code <= 0x39: # 0-9
return str(char)
if 0x41 <= code <= 0x5A: # A-Z
return chr(code)
if 0x61 <= code <= 0x7A: # a-z
return chr(code - 32)
if 0x4E00 <= code <= 0x9FA5: # GBK 汉字范围(含 GB2312
try:
gb = char.encode('gb2312')
except UnicodeEncodeError:
return '#'
low = (gb[0] << 8) | gb[1]
if low < 0xB0A1 or low >= _pinyin_boundaries_end:
return '#'
for letter, start in reversed(_PYINYIN_BOUNDARIES):
if low >= start:
return letter
return '#'
def _username_pinyin_abbr(username):
"""返回用户名各字符的拼音首字母串('张伟恒' -> 'ZWH'),供通讯录分组与模糊搜索使用。"""
name = (username or '').strip()
if not name:
return '#'
return ''.join(_char_pinyin_initial(ch) for ch in name)
def _internal_error(exc, status=500): def _internal_error(exc, status=500):
"""记录内部错误日志,仅向客户端返回通用消息,避免泄露内部信息。""" """记录内部错误日志,仅向客户端返回通用消息,避免泄露内部信息。"""
current_app.logger.error('[admin_api] internal error: %s', exc, exc_info=True) current_app.logger.error('[admin_api] internal error: %s', exc, exc_info=True)
@@ -107,6 +151,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'shop-data-crawl-tasks', 'route_path': 'shop-data-crawl-tasks',
'error': '无权访问店铺数据记录模块', 'error': '无权访问店铺数据记录模块',
}, },
'shop-data-duplicate-check': {
'column_key': 'admin_shop_data_duplicate_check',
'route_path': 'shop-data-duplicate-check',
'error': '无权访问店铺数据重复检查模块',
},
} }
ADMIN_MENU_ACCESS_CONFIG.update({ ADMIN_MENU_ACCESS_CONFIG.update({
@@ -183,8 +232,38 @@ def _backend_java_internal_headers():
return headers return headers
def _resolve_system_operator_id():
"""无请求上下文(定时扫描线程/脚本)时,取一个系统级超级管理员作为内部操作员。
Java 内部端点(如结果下载)要求 operatorId 指向真实存在的管理员;
传 0 会被 Java 判定「用户不存在」并返回 JSON 错误体。
优先超管(内部端点对超管直接放行);兜底任一管理员。
"""
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM users WHERE role = 'super_admin' "
"OR (is_admin = 1 AND created_by_id IS NULL) ORDER BY id LIMIT 1")
row = cur.fetchone()
if row and row.get('id'):
return int(row['id'])
cur.execute(
"SELECT id FROM users WHERE role = 'admin' OR is_admin = 1 "
"ORDER BY id LIMIT 1")
row = cur.fetchone()
if row and row.get('id'):
return int(row['id'])
finally:
conn.close()
return None
def _backend_java_internal_request(): def _backend_java_internal_request():
headers = _backend_java_internal_headers() headers = _backend_java_internal_headers()
# 无请求上下文(定时扫描线程/脚本)时没有当前管理员,
# 用系统级超级管理员代替内部操作员(传 0 会被 Java 端判定「用户不存在」)
if has_request_context():
_, current_row = get_current_admin_role() _, current_row = get_current_admin_role()
operator_id = _get_current_admin_id(current_row) operator_id = _get_current_admin_id(current_row)
try: try:
@@ -193,6 +272,10 @@ def _backend_java_internal_request():
raise ValueError('当前管理员身份无效') from exc raise ValueError('当前管理员身份无效') from exc
if operator_id <= 0: if operator_id <= 0:
raise ValueError('当前管理员身份无效') raise ValueError('当前管理员身份无效')
else:
operator_id = _resolve_system_operator_id()
if not operator_id:
raise ValueError('系统管理员不存在,无法调起内部接口')
return headers, {'operatorId': operator_id} return headers, {'operatorId': operator_id}
@@ -824,6 +907,55 @@ def _ensure_shop_data_crawl_data_access():
return role, current_row, (jsonify({'success': False, 'error': '无权查看店铺数据任务'}), 403) return role, current_row, (jsonify({'success': False, 'error': '无权查看店铺数据任务'}), 403)
def _shop_data_managed_groups(role, current_row):
"""当前主管可管理的分组 id 列表(超管返回 None 表示不限)。"""
if role == 'super_admin':
return None
admin_id = int(current_row['id']) if current_row and current_row.get('id') else 0
if not admin_id:
return []
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT id FROM biz_shop_manage_group '
'WHERE created_by_id = %s OR user_id = %s',
(admin_id, admin_id),
)
group_ids = [int(row['id']) for row in cur.fetchall() if row.get('id')]
finally:
conn.close()
return group_ids
def _shop_data_managed_shop_names(role, current_row):
"""当前主管可见的店铺名集合(含自身创建/所属组下的店铺)。
超管返回 None 表示全部;主管返回小写归一后的名字集合,用于过滤缓存明细。
"""
group_ids = _shop_data_managed_groups(role, current_row)
if group_ids is None:
return None
if not group_ids:
return set()
conn = get_db()
try:
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(group_ids))
cur.execute(
f'SELECT TRIM(sm.shop_name) AS shop_name FROM biz_shop_manage sm WHERE sm.group_id IN ({placeholders})',
tuple(group_ids),
)
shop_names = {
_shop_data_crawl_shop_key(row.get('shop_name'))
for row in cur.fetchall()
if row.get('shop_name')
}
finally:
conn.close()
return shop_names
def _ensure_product_category_access(): def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items() role, current_row, items, denied = _load_current_backend_menu_items()
if denied: if denied:
@@ -1020,6 +1152,7 @@ def list_users():
'created_by_id': r.get('created_by_id'), 'created_by_id': r.get('created_by_id'),
'creator_username': r.get('creator_username') or '', 'creator_username': r.get('creator_username') or '',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
'pinyin_abbr': _username_pinyin_abbr(r.get('username')),
} }
for r in rows for r in rows
] ]
@@ -1696,6 +1829,109 @@ def _load_shop_data_crawl_download_rows(result_ids):
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES = 256 * 1024 * 1024 _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES = 256 * 1024 * 1024
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60) _SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60)
# 重复 ASIN 扫描缓存:结果存 MySQLshop_data_duplicate_scan 表),
# 定时任务每天凌晨执行一次全量扫描,页面读取最近一次结果,避免实时拉取 Excel 消耗资源。
_DUPLICATE_SCAN_TABLE = 'shop_data_duplicate_scan'
_duplicate_scan_lock = threading.Lock()
_duplicate_scan_running = False
def _ensure_duplicate_scan_table():
"""按需建表:重复 ASIN 扫描结果缓存表。"""
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
f"""
CREATE TABLE IF NOT EXISTS {_DUPLICATE_SCAN_TABLE} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
status VARCHAR(16) NOT NULL,
error_text TEXT NULL,
summary_json MEDIUMTEXT NULL,
payload_json MEDIUMTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME NULL,
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
finally:
conn.close()
def _save_duplicate_scan(status, summary=None, payload=None, error_text=None):
"""保存一次扫描结果记录;status: SUCCESS / FAILED。"""
import json as _json
conn = get_db()
scan_id = None
try:
with conn.cursor() as cur:
cur.execute(
f'INSERT INTO {_DUPLICATE_SCAN_TABLE} (status, error_text, summary_json, payload_json, created_at, finished_at) '
'VALUES (%s, %s, %s, %s, NOW(), NOW())',
(
status,
error_text,
_json.dumps(summary or {}, ensure_ascii=False),
_json.dumps(payload or [], ensure_ascii=False),
),
)
scan_id = cur.lastrowid
conn.commit()
return scan_id
finally:
conn.close()
def _latest_duplicate_scan():
"""最近一次成功扫描记录:{'scanned_at': str, 'summary': dict, 'items': list, 'shops': list}|None。
兼容两种缓存格式:
- 新版(重复检查页):payload = {'shops': [...], 'items': [...]}
- 旧版(跨店重复明细):payload 直接为 items 列表,shops 由明细推导。
"""
import json as _json
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
f'SELECT id, summary_json, payload_json, created_at FROM {_DUPLICATE_SCAN_TABLE} '
"WHERE status = 'SUCCESS' ORDER BY id DESC LIMIT 1"
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return None
summary_raw = row.get('summary_json') or {}
payload = row.get('payload_json') or '[]'
# summary_json / payload_json 在 MySQL 中为 JSON 字符串,读取后需反序列化
summary = _json.loads(summary_raw) if isinstance(summary_raw, str) else (summary_raw or {})
summary = summary if isinstance(summary, dict) else {}
payload = _json.loads(payload) if isinstance(payload, str) else (payload or [])
if isinstance(payload, dict):
shops = payload.get('shops') or []
items = payload.get('items') or []
else:
# 旧格式缓存:兼容旧页面,店铺概览由明细推导
items = payload if isinstance(payload, list) else []
shop_seen = {}
for item in items:
for occ in (item.get('occurrences') or []):
shop_name = occ.get('shop_name') or ''
shop_seen[shop_name] = shop_seen.get(shop_name, 0) + 1
shops = [
{'shop_name': shop_name, 'asin_count': 0, 'group_name': ''}
for shop_name in sorted(shop_seen, key=lambda name: (-shop_seen[name], name))
]
return {
'scanned_at': str(row.get('created_at') or ''),
'summary': summary,
'items': items if isinstance(items, list) else [],
'shops': shops if isinstance(shops, list) else [],
}
def _shop_data_crawl_fetch_result_bytes(row, timeout=None): def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
"""从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。""" """从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。"""
@@ -1721,7 +1957,18 @@ def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
if total > _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES: if total > _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES:
raise ValueError('结果文件过大,无法分析') raise ValueError('结果文件过大,无法分析')
chunks.append(chunk) chunks.append(chunk)
return b''.join(chunks) raw = b''.join(chunks)
if not raw:
raise ValueError(f'结果文件为空 result_id={result_id}')
# Java 内部端点鉴权/归属失败时返回 JSON 错误体(HTTP 200),解析报错更直观
if raw.lstrip().startswith(b'{'):
try:
err_payload = json.loads(raw.decode('utf-8', 'replace'))
message = err_payload.get('message') or err_payload.get('error') or '未知错误'
except ValueError:
message = raw[:200].decode('utf-8', 'replace')
raise ValueError(f'结果文件下载失败 result_id={result_id}: {message}')
return raw
finally: finally:
response.close() response.close()
@@ -1763,8 +2010,26 @@ def _shop_data_date_key(date_text):
return text return text
_SHOP_DATA_SHEET_COUNTRY_MAP = {
'英国': 'UK', 'uk': 'UK', 'u.k.': 'UK', 'united kingdom': 'UK',
'德国': 'DE', 'de': 'DE', 'germany': 'DE',
'法国': 'FR', 'fr': 'FR', 'france': 'FR',
'意大利': 'IT', 'it': 'IT', 'italy': 'IT',
'西班牙': 'ES', 'es': 'ES', 'spain': 'ES',
}
def _shop_data_sheet_country(sheet_name):
"""结果文件 sheet 名 → 国家码(英国/德国/法国…);无法识别返回空串。"""
key = str(sheet_name or '').strip().lower()
return _SHOP_DATA_SHEET_COUNTRY_MAP.get(key, '')
def _shop_data_crawl_parse_workbook(workbook): def _shop_data_crawl_parse_workbook(workbook):
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。""" """从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。
每行附带所在 sheet 映射的国家码 country(记录级站点,供重复检查页按站点筛选/展示)。
"""
rows = [] rows = []
for sheet in workbook.worksheets: for sheet in workbook.worksheets:
header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), [])) header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), []))
@@ -1776,6 +2041,7 @@ def _shop_data_crawl_parse_workbook(workbook):
date_col = header.index('日期') if '日期' in header else None date_col = header.index('日期') if '日期' in header else None
price_col = header.index('价格') if '价格' in header else None price_col = header.index('价格') if '价格' in header else None
brand_col = header.index('品牌') if '品牌' in header else None brand_col = header.index('品牌') if '品牌' in header else None
sheet_country = _shop_data_sheet_country(sheet.title)
for sheet_row in sheet.iter_rows(min_row=2): for sheet_row in sheet.iter_rows(min_row=2):
asin = _shop_data_crawl_cell_text(sheet_row[asin_col]) asin = _shop_data_crawl_cell_text(sheet_row[asin_col])
if not asin: if not asin:
@@ -1785,42 +2051,19 @@ def _shop_data_crawl_parse_workbook(workbook):
'date': _shop_data_crawl_cell_text(sheet_row[date_col]) if date_col is not None else '', 'date': _shop_data_crawl_cell_text(sheet_row[date_col]) if date_col is not None else '',
'price': _shop_data_crawl_cell_text(sheet_row[price_col]) if price_col is not None else '', 'price': _shop_data_crawl_cell_text(sheet_row[price_col]) if price_col is not None else '',
'brand': _shop_data_crawl_cell_text(sheet_row[brand_col]) if brand_col is not None else '', 'brand': _shop_data_crawl_cell_text(sheet_row[brand_col]) if brand_col is not None else '',
'country': sheet_country,
}) })
return rows return rows
@admin_api.route('/shop-data-crawl/duplicate-asins') def _shops_with_latest_results():
@login_required """查询每家店铺最新结果行(用于重复 ASIN 扫描)。返回 shop_items 列表。"""
def shop_data_crawl_duplicate_asins():
"""按当前店铺列表筛选条件,分析跨店铺重复的 ASIN 明细。
读取当前页每家店铺最新结果 Excel(经 Java 下载接口拉取),按 ASIN 聚合
其出现的店铺、国家与「日期/价格/品牌」细节,仅返回出现在 2 家及以上店铺
的 ASIN(按店铺数降序、ASIN 升序),空 pagination 参数时返回全量用于导出。
"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
asin_filter = (request.args.get('asin') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
conditions = [ conditions = [
"r.module_type = 'SHOP_DATA_CRAWL'", "r.module_type = 'SHOP_DATA_CRAWL'",
"t.module_type = 'SHOP_DATA_CRAWL'", "t.module_type = 'SHOP_DATA_CRAWL'",
"TRIM(COALESCE(r.result_file_url, '')) <> ''", "TRIM(COALESCE(r.result_file_url, '')) <> ''",
] ]
params = []
where_sql = ' AND '.join(conditions) where_sql = ' AND '.join(conditions)
offset = (page - 1) * page_size
conn = get_db() conn = get_db()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -1832,26 +2075,15 @@ def shop_data_crawl_duplicate_asins():
'LEFT JOIN users u ON u.id = r.user_id ' 'LEFT JOIN users u ON u.id = r.user_id '
'WHERE ' + where_sql 'WHERE ' + where_sql
) )
cur.execute(
'SELECT COUNT(*) AS total FROM ('
'SELECT ' + shop_key_sql + ' AS shop_key' + grouped_from_sql +
' GROUP BY ' + shop_key_sql +
') shop_groups',
tuple(params),
)
total = int((cur.fetchone() or {}).get('total') or 0)
cur.execute( cur.execute(
'SELECT ' + shop_key_sql + ' AS shop_name, MAX(' 'SELECT ' + shop_key_sql + ' AS shop_name, MAX('
+ _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at' + _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at'
+ grouped_from_sql + + grouped_from_sql +
' GROUP BY ' + shop_key_sql + ' GROUP BY ' + shop_key_sql +
' ORDER BY latest_created_at DESC, shop_name ASC LIMIT %s OFFSET %s', ' ORDER BY latest_created_at DESC, shop_name ASC',
tuple(params + [page_size, offset]),
) )
group_rows = cur.fetchall() group_rows = cur.fetchall()
group_names = _shop_data_crawl_group_names(cur, group_rows) group_names = _shop_data_crawl_group_names(cur, group_rows)
result_rows_by_shop = {} result_rows_by_shop = {}
selected_shop_names = [row.get('shop_name') for row in group_rows] selected_shop_names = [row.get('shop_name') for row in group_rows]
if selected_shop_names: if selected_shop_names:
@@ -1869,7 +2101,7 @@ def shop_data_crawl_duplicate_asins():
f' AND {shop_key_sql} IN ({placeholders})' + f' AND {shop_key_sql} IN ({placeholders})' +
') ranked WHERE ranked.shop_row_number <= 1 ' ') ranked WHERE ranked.shop_row_number <= 1 '
'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC', 'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC',
tuple(params + selected_shop_names), tuple(selected_shop_names),
) )
for row in cur.fetchall(): for row in cur.fetchall():
shop_key = _shop_data_crawl_shop_key(row.get('shop_name')) shop_key = _shop_data_crawl_shop_key(row.get('shop_name'))
@@ -1877,7 +2109,7 @@ def shop_data_crawl_duplicate_asins():
finally: finally:
conn.close() conn.close()
# 逐店读取结果文件并解析(每店最多一个结果文件,共 page_size 个) # 逐店读取结果文件并解析
shop_items = [] shop_items = []
for rows in result_rows_by_shop.values(): for rows in result_rows_by_shop.values():
for row in rows: for row in rows:
@@ -1889,7 +2121,7 @@ def shop_data_crawl_duplicate_asins():
try: try:
parsed = _shop_data_crawl_parse_workbook( parsed = _shop_data_crawl_parse_workbook(
load_workbook(io.BytesIO(raw), read_only=True, data_only=True)) load_workbook(io.BytesIO(raw), read_only=True, data_only=True))
except (InvalidFileException, KeyError, ValueError) as exc: except (InvalidFileException, KeyError, ValueError, zipfile.BadZipFile) as exc:
current_app.logger.warning( current_app.logger.warning(
'[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc) '[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc)
continue continue
@@ -1903,14 +2135,22 @@ def shop_data_crawl_duplicate_asins():
except (requests.RequestException, ValueError) as exc: except (requests.RequestException, ValueError) as exc:
current_app.logger.warning( current_app.logger.warning(
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc) '[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
return shop_items
# 先全量聚合所有行,再用筛选条件圈定「命中 ASIN」;
# 命中且跨店重复的 ASIN 展示完整 occurrences(方便看该 ASIN 与哪些店重复)。 def _build_duplicate_scan_cache(shop_items, source='job'):
"""由 shop_items 构建全量重复检查缓存:shops 概览 + 明细 items + 指标统计。
返回 {'shops': [...], 'items': [...], 'summary': {唯一ASIN/上架记录/重复ASIN…}}
items 内 occurrences 记录带 country(结果文件 sheet 映射的站点)。
"""
asin_occurrences = {} asin_occurrences = {}
matched_asins = set() shop_agg = {}
for shop_item in shop_items: for shop_item in shop_items:
shop_name = shop_item['shop_name'] or '未命名' shop_name = shop_item['shop_name'] or '未命名'
group_name = shop_item['group_name'] or ''
country_codes = shop_item['country_codes'] or [] country_codes = shop_item['country_codes'] or []
shop_agg[shop_name] = {'group_name': group_name, 'country_codes': country_codes}
for row in shop_item['rows']: for row in shop_item['rows']:
asin = row['asin'].strip().upper() asin = row['asin'].strip().upper()
if not asin: if not asin:
@@ -1921,40 +2161,445 @@ def shop_data_crawl_duplicate_asins():
'price': row['price'], 'price': row['price'],
'brand': row['brand'], 'brand': row['brand'],
'shop_name': shop_name, 'shop_name': shop_name,
'group_name': shop_item['group_name'], 'group_name': group_name,
'country_codes': shop_item['country_codes'], 'country_codes': country_codes,
'country': row.get('country') or '',
}) })
# 行级筛选:命中则将此 ASIN 加入待展示集合 items = []
if asin_filter and asin_filter not in asin: total_records = 0
continue for asin, occurrences in asin_occurrences.items():
if shop_name_filter and shop_name_filter.lower() not in (shop_name or '').lower() \
and shop_name_filter.lower() not in (shop_item['group_name'] or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in country_codes]:
continue
row_date_key = _shop_data_date_key((row['date'] or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
matched_asins.add(asin)
occurrences_list = []
for asin in matched_asins:
occurrences = asin_occurrences.get(asin, [])
shop_count = len({item['shop_name'] for item in occurrences}) shop_count = len({item['shop_name'] for item in occurrences})
if shop_count < 2: total_records += len(occurrences)
continue items.append({
occurrences_list.append({
'asin': asin, 'asin': asin,
'shop_count': shop_count, 'shop_count': shop_count,
'record_count': len(occurrences), 'record_count': len(occurrences),
'occurrences': occurrences, 'occurrences': occurrences,
}) })
occurrences_list.sort(key=lambda item: (-item['shop_count'], item['asin'])) items.sort(key=lambda item: (-item['shop_count'], item['asin']))
repeated = sum(1 for item in items if item['shop_count'] >= 2)
total_details = len(occurrences_list) shops = []
paged_details = occurrences_list[offset:offset + page_size] for shop_name, agg in sorted(shop_agg.items()):
shop_asin_set = {
occ['asin']
for occ_list in (oo for oo in asin_occurrences.values())
for occ in occ_list
if occ['shop_name'] == shop_name
}
shops.append({
'shop_name': shop_name,
'group_name': agg['group_name'],
'country_codes': agg['country_codes'],
'asin_count': len(shop_asin_set),
'record_count': sum(1 for occ_list in asin_occurrences.values() for occ in occ_list if occ['shop_name'] == shop_name),
})
shops.sort(key=lambda shop: (-shop['asin_count'], shop['shop_name']))
summary = {
'shop_count': len(shops),
'asin_total': len(items),
'record_total': total_records,
'duplicate_asin_total': repeated,
'duplicate_shop_count': len({occ['shop_name'] for item in items if item['shop_count'] >= 2 for occ in item['occurrences']}),
'site_count': len({occ['country'] for occ_list in asin_occurrences.values() for occ in occ_list if occ.get('country')}),
'asin_per_shop': round(float(total_records) / len(shops), 1) if shops else 0.0,
'source': source,
}
return {'shops': shops, 'items': items, 'summary': summary}
def _build_duplicate_scan_items(shop_items):
"""由 shop_items 聚合跨店重复 ASIN 完整结果(不含筛选,返回全量列表)。
保持旧接口语义(仅跨店重复),供导入模拟重分析等旧逻辑复用;
新页面走 _build_duplicate_scan_cache 全量缓存。
"""
cache = _build_duplicate_scan_cache(shop_items)
return cache['items']
def _run_duplicate_scan_job():
"""执行一次全量重复检查扫描并落库(新格式:shops + items + summary)。返回 (ok, scanned_at, summary)。"""
global _duplicate_scan_running
if _duplicate_scan_running:
return False, '', '扫描进行中'
_duplicate_scan_running = True
try:
shop_items = _shops_with_latest_results()
cache = _build_duplicate_scan_cache(shop_items, source='job')
_save_duplicate_scan('SUCCESS', summary=cache['summary'], payload={
'shops': cache['shops'],
'items': cache['items'],
})
return True, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), cache['summary']
except Exception as exc:
current_app.logger.exception('[shop-data-crawl] 重复检查定时扫描失败: %s', exc)
try:
_save_duplicate_scan('FAILED', error_text=str(exc))
except Exception:
current_app.logger.exception('[shop-data-crawl] 重复检查扫描失败记录落库失败')
return False, '', '执行失败:%s' % exc
finally:
_duplicate_scan_running = False
def start_duplicate_scan_scheduler(app):
"""每天凌晨 03:10 执行一次重复 ASIN 全量扫描(后台守护线程)。"""
_ensure_duplicate_scan_table()
def _loop():
while True:
now = datetime.now()
# 下一个 03:10(含今天,若已过则明天)
next_run = now.replace(hour=3, minute=10, second=0, microsecond=0)
if next_run <= now:
next_run = next_run.replace(day=next_run.day) + timedelta(days=1)
delay = (next_run - now).total_seconds()
app.logger.info('[shop-data-crawl] 重复ASIN定时扫描将于 %s 执行(%d 秒后)', next_run, int(delay))
time.sleep(delay)
try:
with app.app_context():
ok, scanned_at, summary = _run_duplicate_scan_job()
app.logger.info('[shop-data-crawl] 重复ASIN定时扫描结束 ok=%s summary=%s', ok, summary)
except Exception as exc:
app.logger.exception('[shop-data-crawl] 重复ASIN定时扫描异常: %s', exc)
threading.Thread(target=_loop, daemon=True, name='duplicate-scan-scheduler').start()
def _duplicate_check_filter_cache(cache, role, current_row):
"""按角色数据范围裁剪重复检查缓存:超管返回全量,主管只返回自己组的店铺。
返回 (shops, items)shops 为可见店铺概览;items 为裁剪 occurrences 后的明细
(裁剪后同一 ASIN 只剩 1 家店时也会保留 —— 展示完整台账,是否重复由 shop_count>=2 标记)。
"""
visible_shops = _shop_data_managed_shop_names(role, current_row)
if visible_shops is None:
return cache.get('shops') or [], cache.get('items') or []
shops = []
for shop in (cache.get('shops') or []):
if _shop_data_crawl_shop_key(shop.get('shop_name')) in visible_shops:
shops.append(shop)
items = []
for item in (cache.get('items') or []):
occurrences = [
occ for occ in (item.get('occurrences') or [])
if _shop_data_crawl_shop_key(occ.get('shop_name')) in visible_shops
]
if occurrences:
items.append({
'asin': item.get('asin'),
'shop_count': len({occ.get('shop_name') for occ in occurrences}),
'record_count': len(occurrences),
'occurrences': occurrences,
})
return shops, items
@admin_api.route('/shop-data-crawl/duplicate-check-overview')
@login_required
def shop_data_crawl_duplicate_check_overview():
"""店铺数据重复检查:指标卡 + 店铺上架分布(按当前角色数据范围裁剪)。"""
role, current_row, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes')
if force:
if not _duplicate_scan_lock.acquire(blocking=False):
return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409
try:
ok, scanned_at, summary = _run_duplicate_scan_job()
if not ok:
return jsonify({'success': False, 'error': summary}), 500
cache = _latest_duplicate_scan()
finally:
_duplicate_scan_lock.release()
else:
cache = _latest_duplicate_scan()
if not cache:
return jsonify({
'success': True, 'pending': True,
'scanned_at': '', 'source': '',
'summary': {}, 'shops': [],
})
shops, items = _duplicate_check_filter_cache(cache, role, current_row)
asin_total = len(items)
record_total = sum(item.get('record_count') or 0 for item in items)
repeated = [item for item in items if item['shop_count'] >= 2]
shop_asin_totals = {}
duplicate_shops = set()
site_codes = set()
for item in items:
occs = item.get('occurrences') or []
for occ in occs:
shop_asin_totals[occ.get('shop_name')] = shop_asin_totals.get(occ.get('shop_name'), set())
shop_asin_totals[occ.get('shop_name')].add(item['asin'])
if item['shop_count'] >= 2:
duplicate_shops.add(occ.get('shop_name'))
if occ.get('country'):
site_codes.add(occ['country'])
summary = {
'shop_count': len(shops),
'asin_total': asin_total,
'record_total': record_total,
'duplicate_asin_total': len(repeated),
'duplicate_shop_count': len(duplicate_shops),
'site_count': len(site_codes),
'asin_per_shop': round(float(record_total) / len(shops), 1) if shops else 0.0,
'source': (cache.get('summary') or {}).get('source', ''),
}
return jsonify({
'success': True,
'pending': False,
'scanned_at': cache['scanned_at'],
'summary': summary,
'shops': shops,
})
except Exception as exc:
return _internal_error(exc)
@admin_api.route('/shop-data-crawl/duplicate-check-items')
@login_required
def shop_data_crawl_duplicate_check_items():
"""店铺数据重复检查矩阵/台账明细:行 ASIN、列店铺,支持筛选与分页。"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
role, current_row = get_current_admin_role()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
view = (request.args.get('view') or 'monitor').strip().lower()
asin_filter = (request.args.get('asin') or '').strip().upper()
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
site_filter = (request.args.get('site') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
cache = _latest_duplicate_scan()
if not cache:
return jsonify({
'success': True, 'pending': True,
'items': [], 'shops': [],
'total': 0, 'page': page, 'page_size': page_size,
'scanned_at': '',
})
shops, all_items = _duplicate_check_filter_cache(cache, role, current_row)
has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to)
matched = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if view == 'monitor' and item['shop_count'] < 2:
continue
if not has_filter:
matched.append(item)
continue
hit = False
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
hit = True
break
if hit:
matched.append(item)
total = len(matched)
offset = (page - 1) * page_size
return jsonify({
'success': True,
'pending': False,
'items': matched[offset:offset + page_size],
'shops': shops,
'total': total,
'page': page,
'page_size': page_size,
'scanned_at': cache['scanned_at'],
})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return _internal_error(exc)
def _duplicate_check_effective_site(country, country_codes):
"""导出用站点:优先记录级 country,其次店铺 country_codes 汇总。"""
if country:
return country.upper()
codes = [c.upper() for c in (country_codes or []) if c]
return ''.join(codes) if codes else ''
@admin_api.route('/shop-data-crawl/duplicate-check-export')
@login_required
def shop_data_crawl_duplicate_check_export():
"""店铺数据重复检查导出 CSV:筛选逻辑与矩阵接口一致,导出全量匹配行。"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
role, current_row = get_current_admin_role()
view = (request.args.get('view') or 'monitor').strip().lower()
asin_filter = (request.args.get('asin') or '').strip().upper()
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
site_filter = (request.args.get('site') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
cache = _latest_duplicate_scan()
if not cache:
return jsonify({'success': False, 'error': '暂无扫描结果,请先点击「重新分析」'}), 400
shops, all_items = _duplicate_check_filter_cache(cache, role, current_row)
has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to)
# 展平为逐行记录(每行=一条上架记录),筛选逻辑与矩阵接口一致
flat = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if view == 'monitor' and item['shop_count'] < 2:
continue
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
flat.append({
'asin': item['asin'],
'shop_count': item['shop_count'],
'shop_name': occ.get('shop_name') or '',
'group_name': occ.get('group_name') or '',
'country': _duplicate_check_effective_site(occ.get('country'), occ.get('country_codes')),
'date': occ.get('date') or '',
'price': occ.get('price') or '',
'brand': occ.get('brand') or '',
})
flat.sort(key=lambda r: (-r['shop_count'], r['asin'], r['shop_name'], r['date']))
import csv
stream = io.StringIO()
writer = csv.writer(stream)
writer.writerow(['ASIN', '店铺数', '店铺', '分组', '站点', '上架时间', '价格', '品牌'])
for row in flat:
writer.writerow([row['asin'], row['shop_count'], row['shop_name'], row['group_name'],
row['country'], row['date'], row['price'], row['brand']])
payload = ('' + stream.getvalue()).encode('utf-8')
response = Response(payload, mimetype='text/csv; charset=utf-8')
response.headers['Content-Disposition'] = 'attachment; filename="shop-data-duplicate-check.csv"'
response.headers['Cache-Control'] = 'no-store'
return response
except Exception as exc:
return _internal_error(exc)
@admin_api.route('/shop-data-crawl/duplicate-asins')
@login_required
def shop_data_crawl_duplicate_asins():
"""按当前筛选条件展示跨店铺重复的 ASIN 明细。
默认读取最近一次成功扫描的缓存结果(定时任务每天凌晨全量扫描),
页面点「重新分析」携带 force=1 触发一次实时扫描(锁防并发,重复触发返回 409)。
"""
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes')
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
country_filter = (request.args.get('country') or '').strip().upper()
asin_filter = (request.args.get('asin') or '').strip().upper()
date_from = (request.args.get('date_from') or '').strip()[:10]
date_to = (request.args.get('date_to') or '').strip()[:10]
if force:
# 实时扫描:持锁执行,避免与定时任务/其他请求并发
if not _duplicate_scan_lock.acquire(blocking=False):
return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409
try:
ok, scanned_at, summary = _run_duplicate_scan_job()
if not ok:
return jsonify({'success': False, 'error': summary}), 500
cache = _latest_duplicate_scan()
finally:
_duplicate_scan_lock.release()
else:
cache = _latest_duplicate_scan()
if not cache:
return jsonify({
'success': True,
'items': [],
'total': 0,
'page': page,
'page_size': page_size,
'analyzed_shop_count': 0,
'analyzed_result_count': 0,
'scanned_at': '',
'pending': True,
})
all_items = cache['items'] or []
# 旧接口只展示跨店重复:由全量缓存裁剪出 shop_count>=2 的 ASIN
all_items = [item for item in all_items if (item.get('shop_count') or 0) >= 2]
has_filter = bool(asin_filter or shop_name_filter or country_filter or date_from or date_to)
matched = []
for item in all_items:
if asin_filter and asin_filter not in item['asin']:
continue
if not has_filter:
matched.append(item)
continue
# 有筛选时:任一 occurrence 命中即保留该 ASIN(展示完整记录)
hit = False
for occ in item['occurrences']:
if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \
and shop_name_filter.lower() not in (occ.get('group_name') or '').lower():
continue
if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]:
continue
row_date_key = _shop_data_date_key((occ.get('date') or '').strip())
if date_from and row_date_key and row_date_key < date_from:
continue
if date_to and row_date_key and row_date_key > date_to:
continue
hit = True
break
if hit:
matched.append(item)
total_details = len(matched)
offset = (page - 1) * page_size
paged_details = matched[offset:offset + page_size]
return jsonify({ return jsonify({
'success': True, 'success': True,
@@ -1962,8 +2607,10 @@ def shop_data_crawl_duplicate_asins():
'total': total_details, 'total': total_details,
'page': page, 'page': page,
'page_size': page_size, 'page_size': page_size,
'analyzed_shop_count': len(shop_items), 'analyzed_shop_count': cache['summary'].get('shop_count', 0) if cache.get('summary') else 0,
'analyzed_result_count': len(shop_items), 'analyzed_result_count': len(all_items),
'scanned_at': cache['scanned_at'],
'source': cache.get('summary', {}).get('source', ''),
}) })
except ValueError as exc: except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400 return jsonify({'success': False, 'error': str(exc)}), 400
+571 -196
View File
@@ -97,7 +97,7 @@
var ADMIN_MENU_GROUPS = [ var ADMIN_MENU_GROUPS = [
{ key: 'account', title: '账号与权限', items: ['users', 'columns', 'group-manage'] }, { key: 'account', title: '账号与权限', items: ['users', 'columns', 'group-manage'] },
{ key: 'data', title: '数据管理', items: ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories'] }, { key: 'data', title: '数据管理', items: ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories'] },
{ key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks'] }, { key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks', 'shop-data-duplicate-check'] },
{ key: 'record', title: '记录与版本', items: ['history', 'version', 'digital-human-version', 'image-video-tasks'] } { key: 'record', title: '记录与版本', items: ['history', 'version', 'digital-human-version', 'image-video-tasks'] }
]; ];
var ADMIN_MENU_ICONS = { var ADMIN_MENU_ICONS = {
@@ -113,6 +113,7 @@
'product-categories': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg>', 'product-categories': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg>',
'image-video-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect></svg>', 'image-video-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect></svg>',
'shop-data-crawl-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M3 5v14a9 3 0 0 0 18 0V5"></path><path d="M3 12a9 3 0 0 0 18 0"></path></svg>', 'shop-data-crawl-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M3 5v14a9 3 0 0 0 18 0V5"></path><path d="M3 12a9 3 0 0 0 18 0"></path></svg>',
'shop-data-duplicate-check': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.35 11.1h-9.17a2 2 0 0 1-1.75-2.98l1.67-2.79a2 2 0 0 0-1.75-2.98l-5.98-.01a2 2 0 0 0-2 2v1.5a2 2 0 0 0 2 2h3.36l-2.38 3.97a2 2 0 0 0 1.75 2.98h11.5a2 2 0 0 0 2-2v-1.5a2 2 0 0 0-2-2Z"></path><path d="M3 21h18"></path></svg>',
'history': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>', 'history': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>',
'version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15"></path><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="M3.3 7 12 12l8.7-5"></path><path d="M12 22V12"></path></svg>', 'version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15"></path><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="M3.3 7 12 12l8.7-5"></path><path d="M12 22V12"></path></svg>',
'digital-human-version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>' 'digital-human-version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>'
@@ -139,6 +140,7 @@
'product-categories': 'panel-product-categories', 'product-categories': 'panel-product-categories',
'image-video-tasks': 'panel-image-video-tasks', 'image-video-tasks': 'panel-image-video-tasks',
'shop-data-crawl-tasks': 'panel-shop-data-crawl-tasks', 'shop-data-crawl-tasks': 'panel-shop-data-crawl-tasks',
'shop-data-duplicate-check': 'panel-shop-data-duplicate-check',
'history': 'panel-history', 'history': 'panel-history',
'version': 'panel-version', 'version': 'panel-version',
'digital-human-version': 'panel-digital-human-version' 'digital-human-version': 'panel-digital-human-version'
@@ -162,6 +164,7 @@
else if (tabName === 'product-categories') loadProductCategories(); else if (tabName === 'product-categories') loadProductCategories();
else if (tabName === 'image-video-tasks') loadImageVideoTasks(1); else if (tabName === 'image-video-tasks') loadImageVideoTasks(1);
else if (tabName === 'shop-data-crawl-tasks') loadShopDataCrawlTasks(1); else if (tabName === 'shop-data-crawl-tasks') loadShopDataCrawlTasks(1);
else if (tabName === 'shop-data-duplicate-check') loadShopDataDuplicateCheckOverview();
else if (tabName === 'history') loadHistory(1); else if (tabName === 'history') loadHistory(1);
else if (tabName === 'version') loadSoftwareVersions(); else if (tabName === 'version') loadSoftwareVersions();
else if (tabName === 'digital-human-version') loadDigitalHumanVersions(); else if (tabName === 'digital-human-version') loadDigitalHumanVersions();
@@ -1778,22 +1781,45 @@
} }
function renderShopDataRecordTable() { function renderShopDataRecordTable() {
var rows = shopDataTasks.map(renderShopDataRecordRow).join('');
if (!shopDataTasks.length) { if (!shopDataTasks.length) {
return '<div class="shop-data-empty-hint">暂无符合条件的店铺数据任务</div>'; return '<div class="shop-data-empty-hint">暂无符合条件的店铺数据任务</div>';
} }
return '<table>' + // 卡片网格:每店铺一张卡片,按截图样式(店铺名、当日累计文件、分组、最新时间、任务号+状态、国家、文件名、下载/删除)
'<thead><tr>' + var cards = shopDataTasks.map(function (item) {
'<th style="width:36px;"><input type="checkbox" id="shopDataTaskSelectAll"></th>' + var resultId = shopDataResultId(item);
'<th style="width:18%;">店铺</th>' + var selected = resultId > 0 && selectedShopDataResultIds.has(resultId);
'<th style="width:14%;">分组</th>' + var status = String(item.status || item.file_status || '').toUpperCase();
'<th style="width:16%;">国家</th>' + var terminal = ['SUCCESS', 'FAILED', 'CANCELLED'].indexOf(status) >= 0;
'<th style="width:10%;">状态</th>' + var countryCodes = item.country_codes != null ? item.country_codes : item.countryCodes;
'<th style="width:18%;">更新时间</th>' + var updatedAt = item.updated_at || item.latest_created_at || item.created_at || item.finished_at || '-';
'<th style="width:170px;">操作</th>' + var taskNo = item.task_id || item.taskId || '-';
'</tr></thead>' + var errorTitle = item.error ? ' title="' + escapeHtml(item.error) + '"' : '';
'<tbody>' + rows + '</tbody>' + return '<article class="shop-data-card" data-shop-data-card="' + (resultId || '') + '">' +
'</table>'; '<div class="shop-data-card-head">' +
'<span class="shop-data-card-shop">' + escapeHtml(item.shop_name || '-') + '</span>' +
'</div>' +
'<div class="shop-data-card-select-row">' +
'<input type="checkbox" data-shop-data-select="' + (resultId || '') + '"' +
(selected ? ' checked' : '') + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>' +
'<span class="shop-data-card-task-no">任务 ' + escapeHtml(String(taskNo)) + '</span>' +
'<span class="shop-data-status ' + (terminal ? (status === 'SUCCESS' ? 'success' : 'failed') : 'running') + '"' + errorTitle + '>' +
escapeHtml(imageVideoStatusLabel(status)) + '</span>' +
'</div>' +
'<div class="shop-data-card-row"><span class="shop-data-card-label">分组</span>' +
'<span class="shop-data-card-value">' + escapeHtml(item.group_name || '-') + '</span></div>' +
'<div class="shop-data-card-row"><span class="shop-data-card-label">最新</span>' +
'<span class="shop-data-card-value">' + escapeHtml(updatedAt) + '</span></div>' +
'<div class="shop-data-card-row"><span class="shop-data-card-label">国家</span>' +
'<span class="shop-data-card-value">' + escapeHtml(countryListLabel(countryCodes)) + '</span></div>' +
'<div class="shop-data-card-row"><span class="shop-data-card-label">文件</span>' +
'<span class="shop-data-card-value shop-data-card-file">' + escapeHtml(item.output_filename || '-') + '</span></div>' +
'<div class="shop-data-card-actions">' +
'<button class="shop-data-record-action" type="button" data-shop-data-download="' + (resultId || '') + '"' + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载文件</button>' +
'<button class="shop-data-record-action danger" type="button" data-shop-data-delete="' + (resultId || '') + '"' + (terminal && resultId > 0 ? '' : ' disabled') + '>' + shopDataDeleteIcon() + '删除</button>' +
'</div>' +
'</article>';
}).join('');
return '<div class="shop-data-card-grid">' + cards + '</div>';
} }
function renderShopDataTasks() { function renderShopDataTasks() {
@@ -1858,21 +1884,27 @@
}); });
} }
// ========== 重复 ASIN 分析 ========== // ========== 店铺数据重复检查(指标卡 + 分布 + 矩阵表格 + 抽屉)==========
var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 10; var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 20;
var shopDataDuplicateItems = []; var shopDataDuplicateItems = [];
var shopDataDuplicateShops = [];
var shopDataDuplicateTotal = 0; var shopDataDuplicateTotal = 0;
var shopDataDuplicateAnalyzed = { shopCount: 0, resultCount: 0 }; var shopDataDuplicateView = 'monitor';
var shopDataDuplicateLoading = false; var shopDataDuplicateLoading = false;
var shopDataDuplicateScannedAt = '';
var shopDataDuplicateOverviewPending = false;
var shopDataDuplicateExporting = false;
function buildShopDataDuplicateQuery(page) { function buildShopDataDuplicateQuery(page) {
var params = new URLSearchParams(); var params = new URLSearchParams();
params.set('page', String(page || 1)); params.set('page', String(page || 1));
params.set('page_size', String(shopDataDuplicatePageSize)); params.set('page_size', String(shopDataDuplicatePageSize));
params.set('view', shopDataDuplicateView);
var values = { var values = {
asin: document.getElementById('shopDataDupFilterAsin').value.trim(), asin: document.getElementById('shopDataDupFilterAsin').value.trim(),
shop_name: document.getElementById('shopDataDupFilterShop').value.trim(), shop_name: document.getElementById('shopDataDupFilterShop').value.trim(),
country: document.getElementById('shopDataDupFilterCountry').value.trim(), country: document.getElementById('shopDataDupFilterCountry').value.trim(),
site: document.getElementById('shopDataDupFilterSite').value.trim(),
date_from: document.getElementById('shopDataDupFilterDateFrom').value, date_from: document.getElementById('shopDataDupFilterDateFrom').value,
date_to: document.getElementById('shopDataDupFilterDateTo').value date_to: document.getElementById('shopDataDupFilterDateTo').value
}; };
@@ -1882,166 +1914,372 @@
return params.toString(); return params.toString();
} }
function shopDataDuplicateHeader(header) { function pad2(value) {
return '<thead><tr>' + header.map(function (col) { var n = parseInt(value, 10);
return '<th style="width:' + (col.width || '') + ';">' + col.label + '</th>'; if (isNaN(n)) return String(value);
}).join('') + '</tr></thead>'; return n < 10 ? '0' + n : String(n);
} }
function renderShopDataDuplicateCard(item) { // 中文日期(如 2026年8月18日 上午4:34)归一化为 YYYY-MM-DD HH:MM[:SS],便于展示与排序
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : []; function normalizeDuplicateTime(raw) {
var brand = ''; if (!raw) return '';
occurrences.forEach(function (occ) { if (occ.brand && !brand) brand = occ.brand; }); var s = String(raw).trim();
var rows = occurrences.map(function (occ) { var m = s.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(.*)$/);
var countries = countryListLabel(occ.country_codes); if (m) {
return '<tr>' + var md = m[1] + '-' + pad2(m[2]) + '-' + pad2(m[3]);
'<td class="dup-shop">' + escapeHtml(occ.shop_name || '-') + '</td>' + var tm = m[4].match(/(上午|下午|晚上|凌晨)?\s*(\d{1,2})[:](\d{2})(?:[:](\d{1,2}))?/);
'<td class="dup-country">' + escapeHtml(occ.group_name || '-') + '</td>' + if (!tm) return md;
'<td class="dup-country">' + escapeHtml(countries) + '</td>' + var hour = parseInt(tm[2], 10);
'<td class="dup-date">' + escapeHtml(occ.date || '-') + '</td>' + var period = tm[1] || '';
'<td class="dup-date">' + escapeHtml(occ.price || '-') + '</td>' + if (period === '下午' || period === '晚上') hour = hour % 12 + 12;
'</tr>'; if ((period === '上午' || period === '凌晨') && hour === 12) hour = 0;
return md + ' ' + pad2(hour) + ':' + tm[3] + (tm[4] ? ':' + pad2(tm[4]) : '');
}
var dm = s.replace(/\//g, '-').match(/^(\d{4})-(\d{1,2})-(\d{1,2})(.*)$/);
if (dm) return dm[1] + '-' + pad2(dm[2]) + '-' + pad2(dm[3]) + (dm[4] || '');
return s;
}
function renderShopDataDuplicateMetrics(summary) {
var metricsEl = document.getElementById('shopDataDuplicateMetrics');
if (!summary || summary.asin_total == null) {
metricsEl.innerHTML = '<div class="dup-check-metric" style="grid-column:1/-1;"><span class="dup-check-metric-label">暂无扫描结果</span>' +
'<span class="dup-check-metric-value">-</span></div>';
return;
}
var defs = [
{ label: '唯一ASIN', value: summary.asin_total, accent: true },
{ label: '上架记录', value: summary.record_total, accent: true },
{ label: '在线店铺', value: summary.shop_count, accent: true },
{ label: '重复ASIN', value: summary.duplicate_asin_total, warn: true },
{ label: '重复店铺', value: summary.duplicate_shop_count, warn: true },
{ label: '店铺平均ASIN', value: summary.asin_per_shop }
];
metricsEl.innerHTML = defs.map(function (def) {
return '<div class="dup-check-metric' + (def.accent ? ' accent' : '') + (def.warn ? ' warn' : '') + '">' +
'<span class="dup-check-metric-label">' + def.label + '</span>' +
'<span class="dup-check-metric-value">' + def.value + '</span></div>';
}).join(''); }).join('');
return '<article class="duplicate-asin-card" data-duplicate-asin="' + escapeHtml(item.asin) + '">' + }
'<div class="duplicate-asin-card-head">' +
'<span class="dup-asin">' + escapeHtml(item.asin) + '</span>' + function renderShopDataDuplicateDistribution(shops) {
'<span class="dup-count">' + item.shop_count + ' 家店铺</span>' + var barsEl = document.getElementById('shopDataDuplicateDistribution');
'<span class="dup-brand" title="' + escapeHtml(brand) + '">' + escapeHtml(brand || '') + '</span>' + var noteEl = document.getElementById('shopDataDuplicateDistributionNote');
'</div>' + if (!shops || !shops.length) {
'<table class="duplicate-asin-table">' + barsEl.innerHTML = '<div class="shop-data-empty-hint">暂无店铺数据</div>';
shopDataDuplicateHeader([ noteEl.textContent = '';
{ label: '店铺', width: '18%' }, return;
{ label: '分组', width: '14%' }, }
{ label: '国家', width: '16%' }, var max = 0;
{ label: '日期', width: '14%' }, shops.forEach(function (shop) { if (shop.asin_count > max) max = shop.asin_count; });
{ label: '价格', width: '12%' } barsEl.innerHTML = shops.map(function (shop) {
]) + var width = max ? Math.max(3, Math.round(shop.asin_count / max * 100)) : 0;
'<tbody>' + rows + '</tbody>' + return '<div class="dup-chart-row">' +
'</table>' + '<span class="dup-chart-name" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</span>' +
'</article>'; '<span class="dup-chart-track"><span class="dup-chart-fill" style="width:' + width + '%"></span></span>' +
'<span class="dup-chart-num">' + shop.asin_count + '</span></div>';
}).join('');
noteEl.textContent = '共 ' + shops.length + ' 家店铺 · 按 ASIN 数量排序';
}
function shopDataDuplicateCellCount(item, shopName) {
var count = 0;
(item.occurrences || []).forEach(function (occ) {
if ((occ.shop_name || '') === shopName) count++;
});
return count;
}
function renderShopDataDuplicateMatrix() {
var list = document.getElementById('shopDataDuplicateList');
if (!shopDataDuplicateItems.length) {
list.innerHTML = '<div class="shop-data-empty-hint">暂无数据'
+ (shopDataDuplicateView === 'monitor' && !shopDataDuplicateTotal ? ':当前没有跨店铺重复的 ASIN' : '')
+ '。可点击右上角「重新分析」重新扫描。</div>';
return;
}
var shops = shopDataDuplicateShops;
var thead = '<thead><tr><th>ASIN</th><th class="dup-cell">店铺数</th>' +
shops.map(function (shop) {
return '<th class="dup-cell" title="' + escapeHtml(shop.shop_name) + '">' + escapeHtml(shop.shop_name) + '</th>';
}).join('') + '</tr></thead>';
var tbody = shopDataDuplicateItems.map(function (item) {
var cells = shops.map(function (shop) {
var count = shopDataDuplicateCellCount(item, shop.shop_name);
var cls = count === 0 ? 'zero' : (item.shop_count >= 2 ? 'danger' : 'has');
return '<td class="dup-cell"><span class="dup-cell-num ' + cls + '"'
+ (count ? ' data-open-drawer="' + escapeHtml(item.asin) + '" title="' + escapeHtml(item.asin) + ' 在 ' + escapeHtml(shop.shop_name) + '' + count + ' 条"' : '')
+ '>' + (count === 0 ? '0' : count) + '</span></td>';
}).join('');
return '<tr>' +
'<td class="dup-asin-col" data-open-drawer="' + escapeHtml(item.asin) + '" title="查看详情">' + escapeHtml(item.asin) + '</td>' +
'<td class="dup-cell"><span class="dup-cell-num ' + (item.shop_count >= 2 ? 'danger' : 'has') + '">' + item.shop_count + '</span></td>' +
cells + '</tr>';
}).join('');
list.innerHTML = '<table class="dup-check-table"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table>';
} }
function renderShopDataDuplicateList() { function renderShopDataDuplicateList() {
var list = document.getElementById('shopDataDuplicateList'); renderShopDataDuplicateMatrix();
if (!shopDataDuplicateItems.length) { }
list.innerHTML = '<div class="shop-data-empty-hint">暂无重复 ASIN。请在筛选条件后点击"查询",或"重新分析"。' +
'<span id="dupEmptyHint"></span></div>'; // 主时间戳(转数值失败时按字符串比较)
function dupTimeRank(raw) {
var t = Date.parse(normalizeDuplicateTime(raw));
return isNaN(t) ? -1 : t;
}
// 「查看明细」按钮点击开抽屉(动态元素,事件委托)
function handleDuplicateDetailClick(event) {
var button = event.target.closest('[data-open-asin-detail]');
if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail);
}
// 撞款详情卡片区:同一 ASIN 在多条店铺的上架明细(仅跨店铺重复,按店铺数倒序)
function renderShopDataDuplicateDetailCards() {
var block = document.getElementById('dupCheckDetailBlock');
var cardsEl = document.getElementById('dupCheckDetailCards');
if (!block || !cardsEl) return;
var repeated = (shopDataDuplicateItems || []).filter(function (item) {
return Number(item.shop_count) >= 2;
}).slice().sort(function (a, b) {
return (Number(b.shop_count) - Number(a.shop_count)) || dupTimeRank(b.first_date || '') - dupTimeRank(a.first_date || '');
});
if (!repeated.length) {
block.style.display = 'none';
cardsEl.innerHTML = '';
return; return;
} }
var header = [ block.style.display = '';
{ label: 'ASIN', width: '14%' }, cardsEl.innerHTML = repeated.map(function (item) {
{ label: '店铺数', width: '8%' }, // 按店铺分组:名称 + 次数 + 站点 + 上架时间(倒序)
{ label: '记录条数', width: '10%' }, var shopsMap = {};
{ label: '品牌', width: '12%' }, (item.occurrences || []).forEach(function (occ) {
{ label: '对应店铺', width: '22%' }, var key = occ.shop_name || '-';
{ label: '国家', width: '14%' }, if (!shopsMap[key]) shopsMap[key] = { shop: key, sites: {}, times: [], count: 0 };
{ label: '上架时间', width: '12%' }, var info = shopsMap[key];
{ label: '操作', width: '8%' } info.count++;
]; if (occ.country) info.sites[occ.country] = true;
var tbody = shopDataDuplicateItems.map(function (item) { var d = normalizeDuplicateTime(occ.date);
return renderShopDataDuplicateRow(item); if (d) info.times.push(d);
}).join('');
list.innerHTML = '<table>' + shopDataDuplicateHeader(header) + '<tbody>' + tbody + '</tbody></table>';
}
function renderShopDataDuplicateRow(item) {
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
var brand = '';
var countries = {};
var shops = {};
occurrences.forEach(function (occ) {
if (occ.brand && !brand) brand = occ.brand;
(occ.country_codes || []).forEach(function (c) { countries[c] = true; });
shops[occ.shop_name || '-'] = true;
}); });
var countryLabel = countryListLabel(Object.keys(countries)); var rows = Object.keys(shopsMap).sort().map(function (key) {
var shopList = Object.keys(shops).join('、'); var info = shopsMap[key];
var dateList = occurrences.map(function (occ) { return occ.date; }) info.times.sort(function (a, b) { return (a < b ? 1 : (a > b ? -1 : 0)); });
.filter(function (d) { return d; }).join('、'); info.times = Array.from(new Set(info.times));
return '<tr data-duplicate-asin="' + escapeHtml(item.asin) + '">' + var rowSites = Object.keys(info.sites).sort().map(function (site) {
'<td><span class="dup-asin">' + escapeHtml(item.asin) + '</span></td>' + return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
'<td><span class="dup-count">' + (item.shop_count || 1) + ' 家</span></td>' + }).join('');
'<td>' + (item.record_count || occurrences.length) + ' 条</td>' + return '<tr>' +
'<td>' + escapeHtml(brand || '-') + '</td>' + '<td class="dup-shop">' + escapeHtml(info.shop) + '<span class="dup-detail-count">' + info.count + ' 次</span></td>' +
'<td>' + escapeHtml(shopList || '-') + '</td>' + '<td>' + (rowSites || '-') + '</td>' +
'<td>' + escapeHtml(countryLabel || '-') + '</td>' + '<td class="dup-date">' + info.times.map(escapeHtml).join('') + '</td>' +
'<td>' + escapeHtml(dateList || '-') + '</td>' +
'<td><button class="btn btn-sm" type="button" data-dup-toggle>详情</button></td>' +
'</tr>' + renderShopDataDuplicateDetailRows(item);
}
function renderShopDataDuplicateDetailRows(item) {
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
if (!occurrences.length) return '';
var rows = occurrences.map(function (occ) {
var countries = countryListLabel(occ.country_codes);
return '<tr class="dup-detail-row" style="display:none;">' +
'<td></td>' +
'<td class="dup-shop">' + escapeHtml(occ.shop_name || '-') + '</td>' +
'<td class="dup-country">' + escapeHtml(occ.group_name || '-') + '</td>' +
'<td class="dup-country">' + escapeHtml(countries) + '</td>' +
'<td class="dup-date">' + escapeHtml(occ.date || '-') + '</td>' +
'<td class="dup-date">' + escapeHtml(occ.price || '-') + '</td>' +
'<td>' + escapeHtml(occ.brand || '-') + '</td>' +
'<td></td>' +
'</tr>'; '</tr>';
}).join(''); }).join('');
return rows; return '<div class="duplicate-asin-card">' +
'<div class="duplicate-asin-card-head">' +
'<span class="dup-asin" title="' + escapeHtml(item.asin) + '">' + escapeHtml(item.asin) + '</span>' +
'<span class="dup-count">' + item.shop_count + ' 家店铺</span>' +
'<span class="dup-brand">' + escapeHtml(item.brand || '') + '</span>' +
'<button class="btn btn-sm btn-secondary dup-detail-view-btn" type="button" data-open-asin-detail="' + escapeHtml(item.asin) + '">查看明细</button>' +
'</div>' +
'<table class="duplicate-asin-table dup-card-table"><thead>' +
'<tr><th>店铺</th><th>站点</th><th>上架时间</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table>' +
'</div>';
}).join('');
} }
function loadShopDataDuplicateAsins(page) { function openShopDataDuplicateDrawer(asin) {
var item = null;
(shopDataDuplicateItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
if (!item) return;
var occurrences = item.occurrences || [];
var brand = '', dateMin = '', dateMax = '', sites = {}, prices = [];
var shopsMap = {};
occurrences.forEach(function (occ) {
if (!brand && occ.brand) brand = occ.brand;
var d = normalizeDuplicateTime(occ.date);
if (d) {
if (!dateMin || d < dateMin) dateMin = d;
if (!dateMax || d > dateMax) dateMax = d;
}
if (occ.country) sites[occ.country] = true;
if (occ.price && prices.indexOf(occ.price) < 0) prices.push(occ.price);
var shopKey = occ.shop_name || '-';
if (!shopsMap[shopKey]) {
shopsMap[shopKey] = { shop: occ.shop_name || '-', group: occ.group_name || '', sites: {}, times: {}, price: '', count: 0 };
}
var info = shopsMap[shopKey];
info.count++;
if (occ.country) info.sites[occ.country] = true;
if (d) info.times[d] = true;
if (occ.price && !info.price) info.price = occ.price;
});
var dateRange = dateMin && dateMax ? (dateMin === dateMax ? dateMin : dateMin + ' ~ ' + dateMax) : '-';
var priceRange = prices.length ? prices.slice(0, 4).join(' / ') + (prices.length > 4 ? ' 等' : '') : '-';
var siteBadges = Object.keys(sites).sort().map(function (site) {
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
}).join('');
var rows = Object.keys(shopsMap).sort().map(function (key) {
var info = shopsMap[key];
var times = Object.keys(info.times).sort().reverse();
var rowSites = Object.keys(info.sites).sort().map(function (site) {
return '<span class="dup-check-site">' + escapeHtml(site) + '</span>';
}).join('');
return '<tr>' +
'<td><span class="dup-asin-cell">' + escapeHtml(info.shop) + '</span></td>' +
'<td>' + escapeHtml(info.group || '-') + '</td>' +
'<td>' + (rowSites || '-') + '</td>' +
'<td class="dup-date">' + times.map(escapeHtml).join('、') + '</td>' +
'<td class="dup-date">' + escapeHtml(info.price || '-') + '</td>' +
'<td>' + info.count + '</td></tr>';
}).join('');
document.getElementById('dupCheckDrawerAsin').textContent = item.asin;
document.getElementById('dupCheckDrawerSubtitle').textContent =
item.shop_count + ' 家店铺 · ' + item.record_count + ' 条上架记录';
document.getElementById('dupCheckDrawerBody').innerHTML =
'<dl class="dup-check-drawer-meta">' +
'<dt>品牌</dt><dd>' + escapeHtml(brand || '-') + '</dd>' +
'<dt>价格</dt><dd>' + escapeHtml(priceRange) + '</dd>' +
'<dt>日期范围</dt><dd>' + escapeHtml(dateRange) + '</dd>' +
'<dt>站点</dt><dd>' + (siteBadges || '-') + '</dd></dl>' +
'<h4 style="margin:14px 0 6px;font-size:13.5px;">店铺 / 站点 / 上架时间 / 次数</h4>' +
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
'<thead><tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间</th><th>价格</th><th>次数</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>';
document.getElementById('dupCheckDrawerMask').classList.add('show');
}
function closeShopDataDuplicateDrawer() {
document.getElementById('dupCheckDrawerMask').classList.remove('show');
}
function loadShopDataDuplicateCheckOverview(force) {
var progress = document.getElementById('shopDataDuplicateProgress');
var button = document.getElementById('btnRefreshShopDataDuplicates');
if (button) button.disabled = true;
if (force) progress.textContent = '正在全量扫描各店铺结果文件,请稍候...';
fetch('/api/admin/shop-data-crawl/duplicate-check-overview' + (force ? '?force=1' : ''))
.then(function (response) { return response.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '加载失败');
shopDataDuplicateOverviewPending = !!res.pending;
shopDataDuplicateScannedAt = res.scanned_at || '';
var summary = res.summary || {};
renderShopDataDuplicateMetrics(summary);
renderShopDataDuplicateDistribution(res.shops || []);
var dupTabCount = document.getElementById('dupCheckMonitorCount');
var allTabCount = document.getElementById('dupCheckAllCount');
if (dupTabCount) dupTabCount.textContent = summary.duplicate_asin_total != null ? summary.duplicate_asin_total : 0;
if (allTabCount) allTabCount.textContent = summary.asin_total != null ? summary.asin_total : 0;
loadShopDataDuplicateCheckItems(1);
})
.catch(function (error) {
var metricsEl = document.getElementById('shopDataDuplicateMetrics');
if (metricsEl) {
metricsEl.innerHTML = '<div class="shop-data-empty-hint">加载失败:'
+ escapeHtml(error.message || '') + '</div>';
}
})
.finally(function () {
if (button) button.disabled = false;
if (progress) progress.textContent = '';
});
}
function loadShopDataDuplicateCheckItems(page) {
if (shopDataDuplicateLoading) return; if (shopDataDuplicateLoading) return;
shopDataDuplicatePage = page || 1; shopDataDuplicatePage = page || 1;
shopDataDuplicateLoading = true; shopDataDuplicateLoading = true;
var list = document.getElementById('shopDataDuplicateList'); var list = document.getElementById('shopDataDuplicateList');
var progress = document.getElementById('shopDataDuplicateProgress'); var progress = document.getElementById('shopDataDuplicateProgress');
var button = document.getElementById('btnRefreshShopDataDuplicates'); var exportButton = document.getElementById('btnExportShopDataDuplicates');
progress.textContent = '正在读取各店铺结果文件并分析,请稍候...'; list.innerHTML = '<div class="shop-data-empty-hint">加载中...</div>';
button.disabled = true; if (exportButton) exportButton.disabled = true;
list.innerHTML = '<div class="shop-data-empty-hint">分析中...</div>'; fetch('/api/admin/shop-data-crawl/duplicate-check-items?' + buildShopDataDuplicateQuery(shopDataDuplicatePage))
fetch('/api/admin/shop-data-crawl/duplicate-asins?' + buildShopDataDuplicateQuery(shopDataDuplicatePage))
.then(function (response) { return response.json(); }) .then(function (response) { return response.json(); })
.then(function (res) { .then(function (res) {
if (!res.success) throw new Error(res.error || '分析失败'); if (!res.success) throw new Error(res.error || '加载失败');
shopDataDuplicateItems = res.items || []; shopDataDuplicateItems = res.items || [];
shopDataDuplicateShops = res.shops || [];
shopDataDuplicateTotal = Number(res.total) || 0; shopDataDuplicateTotal = Number(res.total) || 0;
shopDataDuplicateAnalyzed.shopCount = Number(res.analyzed_shop_count) || 0; if (res.scanned_at) shopDataDuplicateScannedAt = res.scanned_at;
shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0;
var totalEl = document.getElementById('shopDataDuplicateTotal'); var totalEl = document.getElementById('shopDataDuplicateTotal');
totalEl.textContent = '共 ' + shopDataDuplicateTotal + ' 个重复 ASIN · 已分析 ' + shopDataDuplicateAnalyzed.shopCount + ' 家店铺'; totalEl.textContent = (shopDataDuplicateView === 'monitor' ? '重复监控' : '全部ASIN台账')
var tabCount = document.getElementById('shopDataDuplicateTabCount'); + ' · 共 ' + shopDataDuplicateTotal + ' 个 ASIN'
if (tabCount) tabCount.textContent = shopDataDuplicateTotal; + (shopDataDuplicateScannedAt ? ' · 扫描时间 ' + shopDataDuplicateScannedAt
renderShopDataDuplicateList(); : (shopDataDuplicateOverviewPending ? ' · 尚无扫描结果(点击「重新分析」立即扫描)' : ''));
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateAsins); renderShopDataDuplicateMatrix();
renderShopDataDuplicateDetailCards();
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal,
shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateCheckItems);
}) })
.catch(function (error) { .catch(function (error) {
shopDataDuplicateItems = []; shopDataDuplicateItems = [];
shopDataDuplicateTotal = 0; shopDataDuplicateTotal = 0;
list.innerHTML = '<div class="shop-data-empty-hint">分析失败:' + escapeHtml(error.message || '') + '</div>'; list.innerHTML = '<div class="shop-data-empty-hint">加载失败:' + escapeHtml(error.message || '') + '</div>';
document.getElementById('shopDataDuplicateTotal').textContent = ''; document.getElementById('shopDataDuplicateTotal').textContent = '';
renderShopDataDuplicateDetailCards();
}) })
.finally(function () { .finally(function () {
shopDataDuplicateLoading = false; shopDataDuplicateLoading = false;
progress.textContent = ''; if (progress) progress.textContent = '';
button.disabled = false; if (exportButton) exportButton.disabled = false;
}); });
} }
function switchShopDataSubTab(view) { function switchDupCheckView(view) {
var recordsView = document.getElementById('shopDataRecordsView'); if (view === shopDataDuplicateView) return;
var duplicatesView = document.getElementById('shopDataDuplicatesView'); shopDataDuplicateView = view;
var recordsTab = document.getElementById('shopDataSubTabRecords'); var monitorTab = document.getElementById('dupCheckTabMonitor');
var duplicatesTab = document.getElementById('shopDataSubTabDuplicates'); var allTab = document.getElementById('dupCheckTabAll');
var recordsActive = view === 'records'; var monitorActive = view === 'monitor';
recordsView.style.display = recordsActive ? '' : 'none'; monitorTab.classList.toggle('active', monitorActive);
duplicatesView.style.display = recordsActive ? 'none' : ''; monitorTab.setAttribute('aria-selected', monitorActive ? 'true' : 'false');
recordsTab.classList.toggle('active', recordsActive); allTab.classList.toggle('active', !monitorActive);
recordsTab.setAttribute('aria-selected', recordsActive ? 'true' : 'false'); allTab.setAttribute('aria-selected', !monitorActive ? 'true' : 'false');
duplicatesTab.classList.toggle('active', !recordsActive); loadShopDataDuplicateCheckItems(1);
duplicatesTab.setAttribute('aria-selected', !recordsActive ? 'true' : 'false');
if (!recordsActive) {
loadShopDataDuplicateAsins(1);
} }
function exportShopDataDuplicates() {
if (shopDataDuplicateExporting) return;
shopDataDuplicateExporting = true;
var progress = document.getElementById('shopDataDuplicateProgress');
var button = document.getElementById('btnExportShopDataDuplicates');
if (button) button.disabled = true;
if (progress) progress.textContent = '正在导出当前筛选结果...';
var query = new URLSearchParams(buildShopDataDuplicateQuery(1));
query.delete('page');
query.delete('page_size');
fetch('/api/admin/shop-data-crawl/duplicate-check-export?' + query.toString())
.then(function (response) {
if (!response.ok) {
return response.json().then(function (data) { throw new Error(data.error || '导出失败'); });
}
return response.blob();
})
.then(function (blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
var now = new Date();
function pad(n) { return n < 10 ? '0' + n : String(n); }
a.download = '店铺数据重复检查_' + now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate())
+ '_' + pad(now.getHours()) + pad(now.getMinutes()) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
})
.catch(function (error) {
if (progress) progress.textContent = '导出失败:' + (error.message || '');
})
.finally(function () {
shopDataDuplicateExporting = false;
if (button) button.disabled = false;
if (progress) progress.textContent = '';
});
} }
function downloadShopDataTask(item) { function downloadShopDataTask(item) {
@@ -2223,27 +2461,33 @@
document.getElementById('btnFilterShopDataTasks').onclick = function () { document.getElementById('btnFilterShopDataTasks').onclick = function () {
loadShopDataCrawlTasks(1); loadShopDataCrawlTasks(1);
if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') {
loadShopDataDuplicateAsins(1);
}
}; };
document.getElementById('btnResetShopDataTasks').onclick = function () { document.getElementById('btnResetShopDataTasks').onclick = function () {
['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterCountry', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo'] ['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterCountry', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo']
.forEach(function (id) { document.getElementById(id).value = ''; }); .forEach(function (id) { document.getElementById(id).value = ''; });
loadShopDataCrawlTasks(1); loadShopDataCrawlTasks(1);
if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') {
loadShopDataDuplicateAsins(1);
}
}; };
document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); }; document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckOverview(true); };
document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); }; document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckItems(1); };
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
document.getElementById('btnResetShopDataDuplicates').onclick = function () { document.getElementById('btnResetShopDataDuplicates').onclick = function () {
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo'] ['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterSite', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
.forEach(function (id) { document.getElementById(id).value = ''; }); .forEach(function (id) { document.getElementById(id).value = ''; });
loadShopDataDuplicateAsins(1); loadShopDataDuplicateCheckItems(1);
}; };
document.getElementById('dupCheckTabMonitor').onclick = function () { switchDupCheckView('monitor'); };
document.getElementById('dupCheckTabAll').onclick = function () { switchDupCheckView('all'); };
document.getElementById('btnExportShopDataDuplicates').onclick = exportShopDataDuplicates;
document.getElementById('btnCloseDupCheckDrawer').onclick = closeShopDataDuplicateDrawer;
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
if (event.target === this) closeShopDataDuplicateDrawer();
};
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
document.getElementById('shopDataDuplicateList').onclick = function (event) {
var target = event.target.closest('[data-open-drawer]');
if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer);
};
// 撞款详情卡片区「查看明细」按钮(动态元素,事件委托)
document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick;
// 全选移到表格表头后为动态元素,用事件委托 // 全选移到表格表头后为动态元素,用事件委托
document.getElementById('shopDataTaskGrid').onchange = function (event) { document.getElementById('shopDataTaskGrid').onchange = function (event) {
var checkbox = event.target.closest('[data-shop-data-select]'); var checkbox = event.target.closest('[data-shop-data-select]');
@@ -2253,9 +2497,10 @@
if (checkbox.checked) selectedShopDataResultIds.add(resultId); if (checkbox.checked) selectedShopDataResultIds.add(resultId);
else selectedShopDataResultIds.delete(resultId); else selectedShopDataResultIds.delete(resultId);
syncShopDataSelectionUi(); syncShopDataSelectionUi();
return;
} }
if (event.target.closest('#shopDataTaskSelectAll')) { };
// 全选移到工具栏后不在 grid 容器内,单独绑定 change 事件
document.getElementById('shopDataTaskSelectAll').onchange = function (event) {
shopDataTasks.forEach(function (item) { shopDataTasks.forEach(function (item) {
var sid = shopDataResultId(item); var sid = shopDataResultId(item);
if (!item.file_ready || !sid) return; if (!item.file_ready || !sid) return;
@@ -2263,7 +2508,6 @@
else selectedShopDataResultIds.delete(sid); else selectedShopDataResultIds.delete(sid);
}); });
syncShopDataSelectionUi(); syncShopDataSelectionUi();
}
}; };
document.getElementById('shopDataTaskGrid').onclick = function (event) { document.getElementById('shopDataTaskGrid').onclick = function (event) {
var downloadButton = event.target.closest('[data-shop-data-download]'); var downloadButton = event.target.closest('[data-shop-data-download]');
@@ -2279,28 +2523,6 @@
} }
}; };
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip; document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
document.getElementById('shopDataDuplicateList').onclick = function (event) {
var toggle = event.target.closest('[data-dup-toggle]');
if (!toggle) return;
var row = toggle.closest('tr');
var allDetailRows = document.querySelectorAll('.dup-detail-row');
// 若当前 ASIN 的详情行已展开,则本次点击收起
var cur = row ? row.nextElementSibling : null;
var wasExpanded = false;
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
if (cur.style.display !== 'none') { wasExpanded = true; break; }
cur = cur.nextElementSibling;
}
allDetailRows.forEach(function (dr) { dr.style.display = 'none'; });
if (row && !wasExpanded) {
// 展开当前 ASIN 的详情行(紧随其后)
cur = row.nextElementSibling;
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
cur.style.display = '';
cur = cur.nextElementSibling;
}
}
};
document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions; document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions;
document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions; document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions; document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
@@ -2541,30 +2763,160 @@
String(u.created_by_id || '') === String(leaderUserId || ''); String(u.created_by_id || '') === String(leaderUserId || '');
}); });
} }
// ========== 分组管理通讯录:按拼音分组 + 模糊搜索 + 字母索引 ==========
// 隐藏 selectshopManageGroupMemberSelect)仅作为数据交换层,保存逻辑不变。
var shopManageGroupEligibleUsers = [];
var shopManageGroupSelectedIds = {};
var shopManageGroupSearchKeyword = '';
var shopManageGroupContactGroupsCache = [];
var SHOP_GROUP_AVATAR_COLORS = ['#f97316', '#0ea5e9', '#8b5cf6', '#10b981', '#ef4444', '#eab308', '#14b8a6', '#6366f1'];
function shopGroupAvatarColor(name) {
var code = String(name || '#').charCodeAt(0) || 0;
return SHOP_GROUP_AVATAR_COLORS[code % SHOP_GROUP_AVATAR_COLORS.length];
}
function shopGroupUserInitial(user) {
var abbr = String(user.pinyin_abbr || '').trim();
if (abbr) {
var first = abbr.charAt(0).toUpperCase();
return /^[A-Z0-9]$/.test(first) ? first : '#';
}
var name = String(user.username || '').trim();
if (!name) return '#';
var first = name.charAt(0);
if (/[A-Za-z0-9]/.test(first)) return first.toUpperCase();
return '#';
}
function shopGroupMatchSearch(user, keyword) {
if (!keyword) return true;
var kw = keyword.toLowerCase();
if (String(user.username || '').toLowerCase().indexOf(kw) !== -1) return true;
if (String(user.pinyin_abbr || '').toLowerCase().indexOf(kw) !== -1) return true;
return false;
}
function buildShopManageGroupContactGroups(users) {
var groups = {};
users.forEach(function (u) {
var initial = shopGroupUserInitial(u);
var key = /^[A-Z]$/.test(initial) ? initial : '#';
(groups[key] = groups[key] || []).push(u);
});
var keys = Object.keys(groups).sort(function (a, b) {
if (a === '#') return 1;
if (b === '#') return -1;
return a < b ? -1 : 1;
});
return keys.map(function (key) {
var list = groups[key].slice().sort(function (a, b) {
return String(a.username || '').localeCompare(String(b.username || ''), 'zh');
});
return { key: key, items: list };
});
}
function renderShopManageGroupContact() {
var contactEl = document.getElementById('shopManageGroupContact');
var indexEl = document.getElementById('shopManageGroupMemberIndex');
if (!contactEl) return;
var keyword = (shopManageGroupSearchKeyword || '').trim();
var filtered = shopManageGroupEligibleUsers.filter(function (u) {
return shopGroupMatchSearch(u, keyword);
});
if (!shopManageGroupEligibleUsers.length) {
contactEl.innerHTML = '<div class="shop-group-contact-empty">当前组长名下暂无可添加的普通员工账号。</div>';
if (indexEl) indexEl.innerHTML = '';
return;
}
if (!filtered.length) {
contactEl.innerHTML = '<div class="shop-group-contact-empty">未找到与「' + escapeHtml(keyword) + '」匹配的用户,可尝试输入拼音首字母,如 zwh。</div>';
if (indexEl) indexEl.innerHTML = '';
return;
}
shopManageGroupContactGroupsCache = buildShopManageGroupContactGroups(filtered);
contactEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) {
var itemsHtml = group.items.map(function (u) {
var selected = !!shopManageGroupSelectedIds[String(u.id)];
var name = u.username || '';
return '<div class="shop-group-contact-item' + (selected ? ' is-selected' : '') + '" data-user-id="' + escapeHtml(u.id) + '" title="' + escapeHtml(name) + '">' +
'<span class="shop-group-item-avatar" style="background:' + shopGroupAvatarColor(name) + ';">' + escapeHtml(shopGroupUserInitial(u)) + '</span>' +
'<span class="shop-group-item-name">' + escapeHtml(name) + '</span>' +
'<span class="shop-group-item-check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg></span>' +
'</div>';
}).join('');
return '<div class="shop-group-contact-group" data-letter="' + escapeHtml(group.key) + '">' + escapeHtml(group.key) + '</div>' +
'<div class="shop-group-contact-items">' + itemsHtml + '</div>';
}).join('');
if (indexEl) {
indexEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) {
return '<button type="button" data-letter="' + escapeHtml(group.key) + '">' + escapeHtml(group.key) + '</button>';
}).join('');
}
}
function syncShopManageGroupMemberSelect() {
var sel = document.getElementById('shopManageGroupMemberSelect');
if (!sel) return;
sel.innerHTML = shopManageGroupEligibleUsers.map(function (u) {
var selected = !!shopManageGroupSelectedIds[String(u.id)];
return '<option value="' + escapeHtml(u.id) + '"' + (selected ? ' selected' : '') + '>' + escapeHtml(u.username || '') + '</option>';
}).join('');
}
function updateShopManageGroupMemberCount() {
var countEl = document.getElementById('shopManageGroupMemberCount');
if (countEl) {
countEl.textContent = '已选 ' + Object.keys(shopManageGroupSelectedIds).length + ' 人';
}
}
function toggleShopManageGroupMember(userId) {
var key = String(userId || '');
if (!key) return;
var contactEl = document.getElementById('shopManageGroupContact');
var itemEl = contactEl ? Array.prototype.find.call(
contactEl.querySelectorAll('.shop-group-contact-item'),
function (el) { return el.dataset.userId === key; }
) : null;
if (shopManageGroupSelectedIds[key]) {
delete shopManageGroupSelectedIds[key];
} else {
shopManageGroupSelectedIds[key] = true;
}
if (itemEl) itemEl.classList.toggle('is-selected', !!shopManageGroupSelectedIds[key]);
syncShopManageGroupMemberSelect();
updateShopManageGroupMemberCount();
}
function shopGroupScrollToLetter(letter) {
var contactEl = document.getElementById('shopManageGroupContact');
if (!contactEl) return;
var groupEl = Array.prototype.find.call(
contactEl.querySelectorAll('.shop-group-contact-group'),
function (el) { return el.dataset.letter === letter; }
);
if (groupEl) groupEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function resetShopManageGroupSearch() {
shopManageGroupSearchKeyword = '';
var searchInput = document.getElementById('shopManageGroupMemberSearch');
if (searchInput) searchInput.value = '';
}
function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) { function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) {
var sel = document.getElementById('shopManageGroupMemberSelect'); var sel = document.getElementById('shopManageGroupMemberSelect');
var helpEl = document.getElementById('shopManageGroupMemberHelp'); var helpEl = document.getElementById('shopManageGroupMemberHelp');
if (!sel) return; if (!sel) return;
var selectedMap = {}; resetShopManageGroupSearch();
shopManageGroupEligibleUsers = getEligibleShopManageGroupUsers(leaderUserId);
shopManageGroupSelectedIds = {};
(selectedUserIds || []).forEach(function (id) { (selectedUserIds || []).forEach(function (id) {
selectedMap[String(id)] = true; shopManageGroupSelectedIds[String(id)] = true;
}); });
var eligibleUsers = getEligibleShopManageGroupUsers(leaderUserId); syncShopManageGroupMemberSelect();
var options = eligibleUsers.map(function (u) { renderShopManageGroupContact();
return '<option value="' + escapeHtml(u.id) + '"' + (selectedMap[String(u.id)] ? ' selected' : '') + '>' + escapeHtml(u.username || '') + '</option>'; updateShopManageGroupMemberCount();
});
if (options.length) {
sel.innerHTML = options.join('');
if (helpEl) { if (helpEl) {
helpEl.textContent = '可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。'; helpEl.textContent = shopManageGroupEligibleUsers.length
} ? '可添加当前组长创建的普通员工账号,点击用户即可选中 / 取消;顶部支持按用户名或拼音首字母搜索。'
return; : (currentUserRole === 'normal'
}
sel.innerHTML = '<option value="" disabled>当前组长暂无可添加组员</option>';
if (helpEl) {
helpEl.textContent = currentUserRole === 'normal'
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。' ? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
: '当前组长名下暂无可添加的普通员工账号。'; : '当前组长名下暂无可添加的普通员工账号。');
} }
} }
function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) { function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) {
@@ -2595,7 +2947,8 @@
id: u.id, id: u.id,
username: u.username || '', username: u.username || '',
role: u.role || 'normal', role: u.role || 'normal',
created_by_id: u.created_by_id || null created_by_id: u.created_by_id || null,
pinyin_abbr: u.pinyin_abbr || ''
}; };
}); });
setShopManageGroupLeader( setShopManageGroupLeader(
@@ -3891,6 +4244,28 @@
document.getElementById('btnCloseShopManageGroupModal').onclick = function () { document.getElementById('btnCloseShopManageGroupModal').onclick = function () {
document.getElementById('shopManageGroupModal').classList.remove('show'); document.getElementById('shopManageGroupModal').classList.remove('show');
}; };
// 组员通讯录:搜索 / 字母索引 / 点击选中(打开弹窗时 refresh 会重置搜索词)
var groupMemberSearchInput = document.getElementById('shopManageGroupMemberSearch');
if (groupMemberSearchInput) {
groupMemberSearchInput.addEventListener('input', function () {
shopManageGroupSearchKeyword = groupMemberSearchInput.value || '';
renderShopManageGroupContact();
});
}
var groupMemberIndexEl = document.getElementById('shopManageGroupMemberIndex');
if (groupMemberIndexEl) {
groupMemberIndexEl.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-letter]');
if (btn) shopGroupScrollToLetter(btn.dataset.letter);
});
}
var groupMemberContactEl = document.getElementById('shopManageGroupContact');
if (groupMemberContactEl) {
groupMemberContactEl.addEventListener('click', function (e) {
var item = e.target.closest('.shop-group-contact-item');
if (item) toggleShopManageGroupMember(item.dataset.userId);
});
}
document.getElementById('btnSearchShopManage').onclick = function () { document.getElementById('btnSearchShopManage').onclick = function () {
loadShopManage(1); loadShopManage(1);
}; };
@@ -30,6 +30,7 @@ def _make_workbook(rows_by_sheet):
class ShopDataDuplicateAsinTest(unittest.TestCase): class ShopDataDuplicateAsinTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.app = Flask(__name__) self.app = Flask(__name__)
self.app.config['SECRET_KEY'] = 'test-secret'
self.group_rows = [ self.group_rows = [
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 1)}, {'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 1)},
{'shop_name': 'Shop B', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 2)}, {'shop_name': 'Shop B', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 2)},
@@ -133,23 +134,38 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
self.shop_files[shop_name].save(stream) self.shop_files[shop_name].save(stream)
return stream.getvalue() return stream.getvalue()
def _run_request(self, query=''): def _build_cache(self):
cursor = self._FakeCursor( """构造与旧扫描一致的缓存:3 家店铺、跨店重复 B0ABC111。"""
self.group_rows, return {
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']]) 'scanned_at': '2026-09-03 03:10:00',
for i, row in enumerate(self.group_rows)], 'summary': {'shop_count': 3, 'total': 1},
) 'items': [{
connection = self._FakeConnection(cursor) 'asin': 'B0ABC111',
'shop_count': 2,
'record_count': 3,
'occurrences': [
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['UK', 'DE']},
{'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['DE']},
{'asin': 'B0ABC111', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
'shop_name': 'Shop B', 'group_name': 'Group-Shop B', 'country_codes': ['UK']},
],
}],
}
def _run_request(self, query='', cache=None):
# 界面默认读缓存;force 走实时扫描(需 mock 扫描核心)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query): with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query):
with patch.object(admin_api, 'get_db', return_value=connection), \ with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=cache), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \ patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \ patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)):
patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes', return admin_api.shop_data_crawl_duplicate_asins()
side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])):
return admin_api.shop_data_crawl_duplicate_asins.__wrapped__()
def test_detects_duplicate_asins_across_shops(self): def test_detects_duplicate_asins_across_shops(self):
response = self._run_request('page=1&page_size=10') response = self._run_request('page=1&page_size=10', cache=self._build_cache())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = response.get_json() body = response.get_json()
self.assertTrue(body['success']) self.assertTrue(body['success'])
@@ -165,44 +181,103 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
shop_a = next(occ for occ in item['occurrences'] if occ['shop_name'] == 'Shop A') shop_a = next(occ for occ in item['occurrences'] if occ['shop_name'] == 'Shop A')
self.assertIn('UK', shop_a['country_codes']) self.assertIn('UK', shop_a['country_codes'])
self.assertEqual(shop_a['date'], '2026-08-30') self.assertEqual(shop_a['date'], '2026-08-30')
self.assertEqual(body['scanned_at'], '2026-09-03 03:10:00')
def test_filters_asin_and_date_range(self): def test_no_cache_returns_pending_empty(self):
# asin 模糊:匹配 B0ABC222 的只有 Shop B 一家,不构成跨店重复 → total 0 # 无缓存时(定时任务尚未执行):返回空 + pending 提示
response = self._run_request('page=1&page_size=10&asin=B0ABC222') response = self._run_request('page=1&page_size=10', cache=None)
body = response.get_json() body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 0) self.assertEqual(body['total'], 0)
# 日期范围 08-31B0ABC111 在 Shop B 有该日记录 → 命中,展示完整记录(跨店仍 2 家)
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
self.assertEqual(body['items'][0]['shop_count'], 2) # 完整记录仍跨店
# 日期范围 08-30:Shop A 两条命中,同样返回完整记录
response = self._run_request('page=1&page_size=10&date_from=2026-08-30T00:00&date_to=2026-08-30T23:59')
body = response.get_json()
self.assertEqual(body['total'], 1)
def test_filters_shop_and_country(self):
# 店铺过滤 Shop B:命中含 Shop B 记录的 ASIN → B0ABC111,展示完整记录跨店
response = self._run_request('page=1&page_size=10&shop_name=Shop+B')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
# 国家过滤 FR:只有 Shop C 有 FR,其 ASIN 仅单店 → 不构成重复 → total 0
response = self._run_request('page=1&page_size=10&country=FR')
body = response.get_json()
self.assertEqual(body['total'], 0)
# 国家过滤 DEShop A 命中 → B0ABC111 完整记录 → total 1
response = self._run_request('page=1&page_size=10&country=DE')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
def test_pagination_when_page_out_of_range(self):
response = self._run_request('page=2&page_size=10')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'], []) self.assertEqual(body['items'], [])
self.assertTrue(body['pending'])
def test_cache_filters_match_live_semantics(self):
cache = self._build_cache()
# 店铺过滤 Shop B:命中 → 完整记录
response = self._run_request('page=1&page_size=10&shop_name=Shop+B', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
# 日期范围 08-31 命中
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 1)
# 国家 FR 无命中
response = self._run_request('page=1&page_size=10&country=FR', cache=cache)
body = response.get_json()
self.assertEqual(body['total'], 0)
def test_force_runs_live_scan(self):
# force=1 触发实时扫描并保存缓存后返回
cursor = self._FakeCursor(
self.group_rows,
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']])
for i, row in enumerate(self.group_rows)],
)
connection = self._FakeConnection(cursor)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
patch.object(admin_api, '_save_duplicate_scan', return_value=1), \
patch.object(admin_api, '_latest_duplicate_scan',
return_value=self._build_cache()), \
patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes',
side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])):
response = admin_api.shop_data_crawl_duplicate_asins()
self.assertIsNotNone(response)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
def test_force_conflict_when_lock_held(self):
# 锁被占(定时任务/他请求在扫)时 force 返回 409
cursor = self._FakeCursor(self.group_rows[:0], [])
connection = self._FakeConnection(cursor)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \
patch.object(admin_api, '_duplicate_scan_lock'):
admin_api._duplicate_scan_lock.acquire.return_value = False
response = admin_api.shop_data_crawl_duplicate_asins()
# 409 返回 (jsonify, status) tuple
self.assertEqual(response[1], 409)
def test_latest_duplicate_scan_deserializes_json_columns(self):
"""真实读库:summary_json / payload_json 为 JSON 字符串,需反序列化为 dict/list。"""
class _ScanCursor(self._FakeCursor):
def __init__(self):
self.kind = None
def execute(self, sql, params=()):
self.kind = 'scan'
def fetchone(self):
return {
'id': 9,
'summary_json': '{"shop_count": 3, "total": 1, "source": "import"}',
'payload_json': '[{"asin": "B0ABC111", "shop_count": 2}]',
'created_at': datetime(2026, 9, 3, 13, 35, 25),
}
class _ScanConnection(self._FakeConnection):
def cursor(self):
return _ScanCursor()
with patch.object(admin_api, 'get_db', return_value=_ScanConnection(None)):
cache = admin_api._latest_duplicate_scan()
self.assertIsNotNone(cache)
self.assertEqual(cache['summary']['shop_count'], 3)
self.assertEqual(cache['summary']['source'], 'import')
self.assertEqual(cache['items'][0]['asin'], 'B0ABC111')
self.assertEqual(cache['scanned_at'], '2026-09-03 13:35:25')
def test_parse_workbook_skips_unknown_sheets(self): def test_parse_workbook_skips_unknown_sheets(self):
wb = _make_workbook({'英国': [('2026-08-30', 'B0TEST01', 'GBP 1.00', '')]}) wb = _make_workbook({'英国': [('2026-08-30', 'B0TEST01', 'GBP 1.00', '')]})
@@ -0,0 +1,225 @@
"""店铺数据重复检查接口单元测试:全量缓存格式、矩阵分页、主管/超管数据范围过滤。"""
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
def _shop(name, group, country_codes=None):
return {
'shop_name': name,
'group_name': group,
'country_codes': country_codes or [],
'rows': [],
}
class DuplicateCheckApiTest(unittest.TestCase):
"""直接对接口函数做单元测试:缓存通过 _latest_duplicate_scan mock 注入。"""
def setUp(self):
self.app = Flask(__name__)
self.app.config['SECRET_KEY'] = 'test-secret'
# 4 家店 / 3 个组;ASIN 分布:
# A1: ShopA(UK) + ShopB(UK) —— 跨店重复
# B1: ShopA(DE) 唯一
# C1: ShopC(FR) 唯一
# D1: ShopD(UK) 唯一
self.cache = {
'scanned_at': '2026-09-04 03:10:00',
'summary': {
'shop_count': 4, 'asin_total': 4, 'record_total': 5,
'duplicate_asin_total': 1, 'duplicate_shop_count': 2,
'site_count': 3, 'asin_per_shop': 1.2, 'source': 'job',
},
'shops': [
{'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'asin_count': 2, 'record_count': 3},
{'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
{'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'asin_count': 1, 'record_count': 1},
{'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1},
],
'items': [
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
{'asin': 'A0000001', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'country': 'UK'},
{'asin': 'A0000001', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
]},
{'asin': 'B0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'B0000001', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['DE'], 'country': 'DE'},
]},
{'asin': 'C0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'C0000001', 'date': '2026-08-29', 'price': 'EUR 7.50', 'brand': 'BrandC',
'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'},
]},
{'asin': 'D0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'D0000001', 'date': '2026-08-28', 'price': 'GBP 5.00', 'brand': 'BrandD',
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
]},
],
}
def _access_patches(self, role='super_admin', current_row=None):
return [
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=(role, current_row or {'id': 1}, None)),
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=(role, current_row or {'id': 1}, None)),
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache),
]
def _call(self, url, role='super_admin', current_row=None):
with self.app.test_request_context(url):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True):
for p in self._access_patches(role, current_row):
p.start()
try:
return admin_api.shop_data_crawl_duplicate_check_items()
finally:
for p in reversed(self._access_patches(role, current_row)):
p.stop()
def test_overview_super_admin_sees_all(self):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_overview()
body = response.get_json()
self.assertTrue(body['success'])
self.assertFalse(body['pending'])
self.assertEqual(body['summary']['asin_total'], 4)
self.assertEqual(body['summary']['record_total'], 5)
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
self.assertEqual(len(body['shops']), 4)
def test_items_matix_columns_are_shops(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor')
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 1) # monitor 只保留跨店重复
self.assertEqual(body['items'][0]['asin'], 'A0000001')
self.assertEqual([shop['shop_name'] for shop in body['shops']],
['ShopA', 'ShopB', 'ShopC', 'ShopD'])
def test_items_all_view_includes_unique_asins(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all')
body = response.get_json()
self.assertEqual(body['total'], 4)
def test_items_filter_by_asin_and_site(self):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&asin=A0000001&site=UK')
body = response.get_json()
self.assertEqual(body['total'], 1)
self.assertEqual(body['items'][0]['asin'], 'A0000001')
def test_leader_sees_only_own_group_shops(self):
# 主管 id=653 只管理 GroupA(含 ShopA/ShopB
with patch.object(admin_api, '_shop_data_managed_shop_names',
return_value={'shopa', 'shopb'}):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
role='admin', current_row={'id': 653})
body = response.get_json()
self.assertEqual([shop['shop_name'] for shop in body['shops']], ['ShopA', 'ShopB'])
self.assertEqual(body['total'], 2) # A0000001(跨店)+ B0000001(唯一)
# 跨店 ASIN 在主管范围内仍是 2 家店
item = next(i for i in body['items'] if i['asin'] == 'A0000001')
self.assertEqual(item['shop_count'], 2)
def test_leader_overview_stats_recomputed_after_filter(self):
# 主管见 2 家店:唯一ASIN 2、上架记录 3、重复ASIN 1、重复店铺 2
with patch.object(admin_api, '_shop_data_managed_shop_names',
return_value={'shopa', 'shopb'}):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('admin', {'id': 653}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('admin', {'id': 653}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_overview()
body = response.get_json()
self.assertEqual(body['summary']['shop_count'], 2)
self.assertEqual(body['summary']['asin_total'], 2)
self.assertEqual(body['summary']['record_total'], 3)
self.assertEqual(body['summary']['duplicate_asin_total'], 1)
def test_leader_sees_nothing_when_no_group(self):
with patch.object(admin_api, '_shop_data_managed_shop_names', return_value=set()):
response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
role='admin', current_row={'id': 999999})
body = response.get_json()
self.assertEqual(body['shops'], [])
self.assertEqual(body['total'], 0)
def test_export_csv_contains_filtered_rows(self):
"""导出 CSV:行=上架记录,含 BOM,按筛选裁剪。"""
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_export()
self.assertEqual(response.status_code, 200)
text = response.get_data(as_text=True)
self.assertTrue(text.startswith(''))
lines = text.lstrip('').strip().splitlines()
self.assertEqual(lines[0], 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌')
self.assertEqual(len(lines), 3) # header + 两条上架记录(A0000001 两店各一条)
def test_export_monitor_view_excludes_unique_asins(self):
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?view=monitor'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache):
response = admin_api.shop_data_crawl_duplicate_check_export()
lines = response.get_data(as_text=True).lstrip('').strip().splitlines()
self.assertEqual(len(lines), 3)
self.assertIn('A0000001', lines[1])
def test_internal_request_falls_back_to_system_operator(self):
"""无请求上下文(定时扫描线程)时,内部请求用系统级超管作为 operatorId。"""
with patch('utils.auth.session', {'user_id': 1}): # 仅用于模拟无异常环境
# 无请求上下文:has_request_context() 为 False,直接走 _resolve_system_operator_id
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
patch.object(admin_api, '_resolve_system_operator_id', return_value=7):
headers, params = admin_api._backend_java_internal_request()
self.assertEqual(headers.get('X-Internal-Token'), 'test-token')
self.assertEqual(params, {'operatorId': 7})
def test_internal_request_system_operator_missing_fails(self):
"""无请求上下文且系统中没有任何管理员时,直接报错而不是传 0。"""
with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \
patch.object(admin_api, '_resolve_system_operator_id', return_value=None):
with self.assertRaises(ValueError):
admin_api._backend_java_internal_request()
def test_fetch_result_bytes_rejects_json_error_body(self):
"""Java 内部端点返回 JSON 错误体(如鉴权失败)时,抛出业务错误而不是 BadZipFile。"""
fake_response = Mock()
fake_response.raise_for_status = Mock()
fake_response.iter_content = Mock(return_value=[
'{"success":false,"message":"用户不存在","data":null,"code":401}'.encode('utf-8')])
fake_response.close = Mock()
with patch.object(admin_api, '_backend_java_internal_request',
return_value=({'X-Internal-Token': 'test-token'}, {'operatorId': 7})), \
patch.object(admin_api, '_get_backend_java_session') as fake_session:
fake_session.return_value.get = Mock(return_value=fake_response)
with self.assertRaises(ValueError) as ctx:
admin_api._shop_data_crawl_fetch_result_bytes({'result_id': 123})
self.assertIn('用户不存在', str(ctx.exception))
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large Load Diff