后台店铺数据记录页新增 Tab 切换与重复 ASIN 分析;task-169/170 HTTP 客户端配置接入;dedupe/brand 相关改造
Build Backend JAR / build (push) Has been cancelled

- 管理后台:店铺数据记录改为列表分页展示(去任务号/文件列/累计标题,'最新'改'更新时间'),新增'重复 ASIN' Tab 按跨店聚合展示 ASIN/店铺/分组/国家/日期/价格;修复失败/进行中任务被过滤不显示的问题
- task-169: BrandCheckHttpConfigResolver 从 aiimage.http-client.* 读取品牌检查超时/重试(默认同现状、钳制),附 8 条测试
- task-170: surefire 内存调整为 1536m
- dedupe: DedupeRunService 重构(事务边界/进度)、新增 DedupeRunProgressVo、Controller 与结果 VO 调整,PermissionMenuService 相应适配
- brand/dedupe 前端:BrandDedupeTab/BrandPatrolDeleteTab 改造、新增 CountrySelector 组件与 country-options、类型与接口端对齐、测试更新
- 移除无引用文件:backend/static/logo.jpg、prompts/
This commit is contained in:
2026-09-03 01:57:41 +08:00
parent 152ea6eec0
commit 417a2bf831
20 changed files with 1883 additions and 475 deletions
+265
View File
@@ -1,6 +1,7 @@
"""
管理员 API 蓝图:用户管理、生成历史、版本管理(后台)
"""
import io
import json
import os
import re
@@ -13,6 +14,8 @@ from pathlib import Path
from urllib.parse import quote
import requests
from openpyxl import load_workbook
from openpyxl.utils.exceptions import InvalidFileException
from requests.adapters import HTTPAdapter
from flask import (
Blueprint,
@@ -1690,6 +1693,268 @@ def _load_shop_data_crawl_download_rows(result_ids):
conn.close()
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES = 256 * 1024 * 1024
_SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60)
def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
"""从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。"""
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(row['id'])}/download"
headers, params = _backend_java_internal_request()
response = _get_backend_java_session().get(
url,
params=params,
headers=headers,
stream=True,
timeout=timeout or _SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT,
)
try:
response.raise_for_status()
total = 0
chunks = []
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
total += len(chunk)
if total > _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES:
raise ValueError('结果文件过大,无法分析')
chunks.append(chunk)
return b''.join(chunks)
finally:
response.close()
def _shop_data_crawl_cell_text(cell):
"""读取单元格文本:日期/数字等统一转字符串,None 返回空串。"""
if cell is None:
return ''
value = cell.value
if value is None:
return ''
if isinstance(value, datetime):
return value.strftime('%Y-%m-%d')
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _shop_data_crawl_parse_workbook(workbook):
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。"""
rows = []
for sheet in workbook.worksheets:
header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), []))
header = [_shop_data_crawl_cell_text(cell) for cell in header_cells]
try:
asin_col = header.index('ASIN')
except ValueError:
continue
date_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
for sheet_row in sheet.iter_rows(min_row=2):
asin = _shop_data_crawl_cell_text(sheet_row[asin_col])
if not asin:
continue
rows.append({
'asin': asin,
'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 '',
'brand': _shop_data_crawl_cell_text(sheet_row[brand_col]) if brand_col is not None else '',
})
return rows
@admin_api.route('/shop-data-crawl/duplicate-asins')
@login_required
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 = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
group_name = (request.args.get('group_name') or request.args.get('group') or '').strip()
country = (request.args.get('country') or '').strip().upper()
created_from = _parse_admin_datetime_arg('created_from')
created_to = _parse_admin_datetime_arg('created_to')
conditions = [
"r.module_type = 'SHOP_DATA_CRAWL'",
"t.module_type = 'SHOP_DATA_CRAWL'",
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
]
params = []
if shop_name:
conditions.append('r.source_filename LIKE %s')
params.append('%' + shop_name + '%')
if group_name:
conditions.append(
'EXISTS (SELECT 1 FROM biz_shop_manage sm '
'LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id '
'WHERE TRIM(COALESCE(sm.shop_name, \'\')) = '
'TRIM(COALESCE(r.source_filename, \'\')) '
"AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) LIKE %s)"
)
params.append('%' + group_name + '%')
if country:
if country not in ('DE', 'UK', 'FR', 'IT', 'ES'):
raise ValueError('不支持的国家代码: ' + country)
conditions.append(
'JSON_CONTAINS('
'COALESCE(df.country_codes_json, '
'JSON_EXTRACT(t.request_json, \'$.countryCodes\'), '
'JSON_EXTRACT(t.request_json, \'$.country_codes\'), \'[]\'), %s)'
)
params.append('"' + country + '"')
if created_from:
conditions.append('t.created_at >= %s')
params.append(created_from)
if created_to:
conditions.append('t.created_at <= %s')
params.append(created_to)
where_sql = ' AND '.join(conditions)
offset = (page - 1) * page_size
conn = get_db()
try:
with conn.cursor() as cur:
shop_key_sql = "TRIM(COALESCE(r.source_filename, ''))"
grouped_from_sql = (
' FROM biz_file_result r '
'JOIN biz_file_task t ON t.id = r.task_id '
'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id '
'LEFT JOIN users u ON u.id = r.user_id '
'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(
'SELECT ' + shop_key_sql + ' AS shop_name, MAX('
+ _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at'
+ grouped_from_sql +
' GROUP BY ' + shop_key_sql +
' ORDER BY latest_created_at DESC, shop_name ASC LIMIT %s OFFSET %s',
tuple(params + [page_size, offset]),
)
group_rows = cur.fetchall()
group_names = _shop_data_crawl_group_names(cur, group_rows)
result_rows_by_shop = {}
selected_shop_names = [row.get('shop_name') for row in group_rows]
if selected_shop_names:
placeholders = ','.join(['%s'] * len(selected_shop_names))
cur.execute(
'SELECT ranked.* FROM (SELECT ' + _SHOP_DATA_CRAWL_ADMIN_COLUMNS +
', ROW_NUMBER() OVER (PARTITION BY ' + shop_key_sql +
' ORDER BY ' + _SHOP_DATA_CRAWL_LATEST_TIME_SQL
+ ' DESC, r.id DESC) AS shop_row_number '
' FROM biz_file_result r '
'JOIN biz_file_task t ON t.id = r.task_id '
'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id '
'LEFT JOIN users u ON u.id = r.user_id '
'WHERE ' + where_sql +
f' AND {shop_key_sql} IN ({placeholders})' +
') ranked WHERE ranked.shop_row_number <= 1 '
'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC',
tuple(params + selected_shop_names),
)
for row in cur.fetchall():
shop_key = _shop_data_crawl_shop_key(row.get('shop_name'))
result_rows_by_shop.setdefault(shop_key, []).append(row)
finally:
conn.close()
# 逐店读取结果文件并解析(每店最多一个结果文件,共 page_size 个)
shop_items = []
for rows in result_rows_by_shop.values():
for row in rows:
result_id = int(row.get('result_id') or 0)
if result_id <= 0:
continue
try:
raw = _shop_data_crawl_fetch_result_bytes(row)
try:
parsed = _shop_data_crawl_parse_workbook(
load_workbook(io.BytesIO(raw), read_only=True, data_only=True))
except (InvalidFileException, KeyError, ValueError) as exc:
current_app.logger.warning(
'[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc)
continue
shop_items.append({
'shop_name': row.get('shop_name') or '未命名',
'group_name': _shop_data_crawl_group_name(group_names, row.get('shop_name')),
'country_codes': _shop_data_crawl_country_codes_from_json(row.get('country_codes_json'))
or _shop_data_crawl_country_codes(row.get('request_json')),
'rows': parsed,
})
except (requests.RequestException, ValueError) as exc:
current_app.logger.warning(
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
# 按 ASIN 聚合其出现的店铺/分组/国家与行细节
asin_occurrences = {}
for shop_item in shop_items:
shop_name = shop_item['shop_name'] or '未命名'
for row in shop_item['rows']:
asin = row['asin'].strip().upper()
if not asin:
continue
asin_occurrences.setdefault(asin, []).append({
'asin': asin,
'date': row['date'],
'price': row['price'],
'brand': row['brand'],
'shop_name': shop_name,
'group_name': shop_item['group_name'],
'country_codes': shop_item['country_codes'],
})
occurrences_list = []
for asin, occurrences in asin_occurrences.items():
shop_count = len({item['shop_name'] for item in occurrences})
if shop_count < 2:
continue
occurrences_list.append({
'asin': asin,
'shop_count': shop_count,
'occurrences': occurrences,
})
occurrences_list.sort(key=lambda item: (-item['shop_count'], item['asin']))
total_details = len(occurrences_list)
paged_details = occurrences_list[offset:offset + page_size]
return jsonify({
'success': True,
'items': paged_details,
'total': total_details,
'page': page,
'page_size': page_size,
'analyzed_shop_count': len(shop_items),
'analyzed_result_count': len(shop_items),
})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return _internal_error(exc)
def _open_shop_data_crawl_download(row):
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(row['id'])}/download"
headers, params = _backend_java_internal_request()