后台店铺数据记录页新增 Tab 切换与重复 ASIN 分析;task-169/170 HTTP 客户端配置接入;dedupe/brand 相关改造
Build Backend JAR / build (push) Has been cancelled
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user