新需求更新 同步更新

This commit is contained in:
supernijia
2026-08-06 01:11:54 +08:00
parent 9048bbb7f8
commit 28e7fce11c
112 changed files with 7739 additions and 637 deletions
+579 -8
View File
@@ -23,6 +23,7 @@ from flask import (
g,
Response,
send_file,
stream_with_context,
has_request_context,
)
@@ -44,6 +45,7 @@ admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
_backend_java_session_local = threading.local()
_internal_token_lock = threading.Lock()
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data'
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
@@ -81,6 +83,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'image-video-tasks',
'error': '无权访问视频任务管理模块',
},
'shop-data-crawl-tasks': {
'column_key': 'admin_shop_data_crawl_tasks',
'route_path': 'shop-data-crawl-tasks',
'error': '无权访问店铺数据任务管理模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
@@ -147,6 +154,29 @@ def _backend_java_forward_headers():
return headers
def _backend_java_internal_headers():
"""Headers for Java routes that are callable only from the Flask admin service."""
internal_token = _resolve_internal_token()
if not internal_token:
raise ValueError('内部凭据服务未配置')
headers = _backend_java_forward_headers()
headers['X-Internal-Token'] = internal_token
return headers
def _backend_java_internal_request():
headers = _backend_java_internal_headers()
_, current_row = get_current_admin_role()
operator_id = _get_current_admin_id(current_row)
try:
operator_id = int(operator_id)
except (TypeError, ValueError) as exc:
raise ValueError('当前管理员身份无效') from exc
if operator_id <= 0:
raise ValueError('当前管理员身份无效')
return headers, {'operatorId': operator_id}
def _proxy_backend_java(
method,
path,
@@ -751,6 +781,27 @@ def _ensure_image_video_data_access():
return role, current_row, (jsonify({'success': False, 'error': '无权查看视频任务数据'}), 403)
def _ensure_shop_data_crawl_data_access():
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if not role or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403)
try:
_, key_set, route_set = _effective_permission_sets(
_get_current_admin_id(current_row),
menu_type=None,
current_row=current_row,
role=role,
)
except _PermissionProxyError as exc:
return role, current_row, (exc.response, exc.status)
if (SHOP_DATA_CRAWL_DATA_PERMISSION_KEY in key_set
or 'shop-data-crawl-task-data' in route_set):
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权查看店铺数据任务'}), 403)
def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items()
if denied:
@@ -1283,6 +1334,458 @@ def _parse_admin_datetime_arg(name):
raise ValueError(f'{name} 时间格式无效') from exc
_SHOP_DATA_CRAWL_ADMIN_COLUMNS = """
r.id AS result_id, r.task_id, r.user_id, r.source_filename AS shop_name,
r.source_file_url AS shop_id, r.result_filename, r.result_file_url,
r.result_file_size, r.result_content_type, r.row_count,
r.success AS result_success, r.error_message AS result_error,
r.created_at AS result_created_at,
t.task_no, t.status AS task_status, t.request_json, t.result_json,
t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at,
u.username,
(SELECT j.id FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_job_id,
(SELECT j.status FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_status,
(SELECT j.error_message FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_error
"""
def _shop_data_crawl_country_codes(request_json):
payload = _parse_json_value(request_json, {})
if not isinstance(payload, dict):
return []
raw = payload.get('countryCodes')
if raw is None:
raw = payload.get('country_codes')
if not isinstance(raw, list):
return []
return [str(value).strip().upper() for value in raw if str(value or '').strip()]
def _shop_data_crawl_group_names(cursor, rows):
shop_names = sorted({
_shop_data_crawl_shop_key(row.get('shop_name'))
for row in rows
if _shop_data_crawl_shop_key(row.get('shop_name'))
})
if not shop_names:
return {}
placeholders = ','.join(['%s'] * len(shop_names))
cursor.execute(
"SELECT TRIM(sm.shop_name) AS shop_name, "
"GROUP_CONCAT(DISTINCT COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) "
"ORDER BY sm.id SEPARATOR '') AS group_name "
"FROM biz_shop_manage sm "
"LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id "
f"WHERE TRIM(sm.shop_name) IN ({placeholders}) GROUP BY TRIM(sm.shop_name)",
tuple(shop_names),
)
return {
_shop_data_crawl_shop_key(row.get('shop_name')): row.get('group_name') or ''
for row in cursor.fetchall()
}
def _shop_data_crawl_shop_key(value):
"""Normalize a shop name for grouping while preserving the display value."""
return str(value or '').strip().casefold()
def _shop_data_crawl_group_name(group_names, shop_name):
"""Resolve a group label from either normalized or legacy exact-key maps."""
if not group_names:
return ''
normalized = _shop_data_crawl_shop_key(shop_name)
return group_names.get(normalized, group_names.get(str(shop_name or '').strip(), '')) or ''
def _shop_data_crawl_admin_item(row, group_names=None):
result_success = row.get('result_success')
file_ready = bool((row.get('result_file_url') or '').strip())
if result_success is None or int(result_success) < 0:
success = None
else:
success = bool(int(result_success))
group_names = group_names or {}
shop_name = row.get('shop_name') or ''
file_status = row.get('file_status') or ('SUCCESS' if file_ready else '')
return {
'task_id': row.get('task_id'),
'task_no': row.get('task_no') or '',
'result_id': row.get('result_id'),
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'shop_name': shop_name,
'shop_id': row.get('shop_id') or '',
'group_name': _shop_data_crawl_group_name(group_names, shop_name),
'status': row.get('task_status') or '',
'success': success,
'error': row.get('result_error') or row.get('task_error') or row.get('file_error') or '',
'country_codes': _shop_data_crawl_country_codes(row.get('request_json')),
'output_filename': row.get('result_filename') or '',
'result_file_url': row.get('result_file_url') or '',
'file_ready': file_ready,
'file_job_id': row.get('file_job_id'),
'file_status': file_status,
'file_error': row.get('file_error') or '',
'file_size': int(row.get('result_file_size') or 0),
'row_count': int(row.get('row_count') or 0),
'created_at': _format_admin_datetime(row.get('created_at') or row.get('result_created_at')),
'updated_at': _format_admin_datetime(row.get('updated_at')),
'finished_at': _format_admin_datetime(row.get('finished_at')),
}
def _shop_data_crawl_group_item(group_row, result_rows, group_names):
"""Build one shop group and cap its children to the newest three results."""
raw_shop_name = group_row.get('shop_name') or ''
display_shop_name = raw_shop_name or '未命名'
group_key = _shop_data_crawl_shop_key(raw_shop_name)
children = result_rows.get(group_key)
if children is None:
children = result_rows.get(str(raw_shop_name).strip(), [])
children = children[:3]
result_items = [_shop_data_crawl_admin_item(row, group_names) for row in children]
latest_created_at = group_row.get('latest_created_at')
if latest_created_at is None and result_items:
latest_created_at = result_items[0].get('created_at')
return {
'shop_name': display_shop_name,
'shop_id': result_items[0].get('shop_id', '') if result_items else '',
'group_name': _shop_data_crawl_group_name(group_names, raw_shop_name),
'latest_created_at': _format_admin_datetime(latest_created_at),
'results': result_items,
}
@admin_api.route('/shop-data-crawl-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_shop_data_crawl_task_permissions():
json_data = None
if request.method == 'PUT':
data = request.get_json(silent=True) or {}
raw_user_ids = data.get('user_ids') if 'user_ids' in data else data.get('userIds')
json_data = {'userIds': raw_user_ids}
result, error_response, status = _proxy_permission_java(
request.method,
'/api/admin/shop-data-crawl-task-permissions',
json_data=json_data,
)
if error_response is not None:
return error_response, status
if request.method == 'GET':
return jsonify({'success': True, 'items': _permission_response_items(result)})
return jsonify({
'success': True,
'granted_count': result.get('data'),
'msg': result.get('message') or '店铺数据任务权限已更新',
})
@admin_api.route('/shop-data-crawl-tasks')
@login_required
def list_shop_data_crawl_tasks():
_, _, 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()
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 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 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(t.created_at) 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 t.created_at 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 users u ON u.id = r.user_id '
'WHERE ' + where_sql +
f' AND {shop_key_sql} IN ({placeholders})' +
') ranked WHERE ranked.shop_row_number <= 3 '
'ORDER BY ranked.created_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()
payload = {
'items': [
_shop_data_crawl_group_item(group, result_rows_by_shop, group_names)
for group in group_rows
],
'total': total,
'page': page,
'page_size': page_size,
}
# Keep the existing admin response shape while exposing the grouped
# payload for clients that use the newer data envelope.
return jsonify({'success': True, **payload, 'data': payload})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
def _load_shop_data_crawl_download_rows(result_ids):
normalized = sorted({int(result_id) for result_id in result_ids if int(result_id) > 0})
if not normalized:
return {}
placeholders = ','.join(['%s'] * len(normalized))
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT r.id, r.task_id, r.user_id, r.result_filename, r.source_filename, '
'r.result_file_url, t.status AS task_status '
'FROM biz_file_result r JOIN biz_file_task t ON t.id = r.task_id '
'WHERE r.module_type = %s AND t.module_type = %s '
f'AND r.id IN ({placeholders})',
tuple(['SHOP_DATA_CRAWL', 'SHOP_DATA_CRAWL'] + normalized),
)
return {int(row['id']): row for row in cur.fetchall()}
finally:
conn.close()
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()
return _get_backend_java_session().get(
url,
params=params,
headers=headers,
stream=True,
timeout=(10, 180),
)
@admin_api.route('/shop-data-crawl-tasks/<int:result_id>/download')
@login_required
def download_shop_data_crawl_task(result_id):
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
rows = _load_shop_data_crawl_download_rows([result_id])
row = rows.get(result_id)
if not row or not (row.get('result_file_url') or '').strip():
return jsonify({'success': False, 'error': '结果文件不存在或尚未生成'}), 404
try:
remote = _open_shop_data_crawl_download(row)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 503
except requests.RequestException as exc:
return jsonify({'success': False, 'error': f'结果文件下载失败: {exc}'}), 502
if remote.status_code != 200:
message = remote.text[:500] if remote.content else ''
remote.close()
return jsonify({'success': False, 'error': message or '结果文件下载失败'}), remote.status_code
def generate():
try:
for chunk in remote.iter_content(chunk_size=1024 * 1024):
if chunk:
yield chunk
finally:
remote.close()
response = Response(
stream_with_context(generate()),
content_type=remote.headers.get('Content-Type') or
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
disposition = remote.headers.get('Content-Disposition')
if disposition:
response.headers['Content-Disposition'] = disposition
else:
filename = row.get('result_filename') or f"{row.get('source_filename') or result_id}.xlsx"
response.headers['Content-Disposition'] = "attachment; filename*=UTF-8''" + quote(filename)
response.headers['Cache-Control'] = 'no-store'
return response
@admin_api.route('/shop-data-crawl-tasks/<int:result_id>', methods=['DELETE'])
@login_required
def delete_shop_data_crawl_task(result_id):
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
rows = _load_shop_data_crawl_download_rows([result_id])
row = rows.get(result_id)
if not row:
return jsonify({'success': False, 'error': '店铺数据任务不存在'}), 404
if (row.get('task_status') or '').upper() not in {'SUCCESS', 'FAILED', 'CANCELLED'}:
return jsonify({'success': False, 'error': '任务仍在处理中,不能删除'}), 409
try:
headers, params = _backend_java_internal_request()
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 503
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-data-crawl/history/{result_id}',
params=params,
headers=headers,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/shop-data-crawl-tasks/download-zip', methods=['POST'])
@login_required
def download_shop_data_crawl_tasks_zip():
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
data = request.get_json(silent=True) or {}
raw_ids = data.get('result_ids') if 'result_ids' in data else data.get('resultIds')
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({'success': False, 'error': '请至少选择一个结果文件'}), 400
if len(raw_ids) > 100:
return jsonify({'success': False, 'error': '单次最多打包 100 个结果文件'}), 400
try:
result_ids = []
for raw_id in raw_ids:
result_id = int(raw_id)
if result_id <= 0:
raise ValueError
if result_id not in result_ids:
result_ids.append(result_id)
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '结果文件参数无效'}), 400
rows = _load_shop_data_crawl_download_rows(result_ids)
archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b')
errors = []
file_count = 0
used_names = set()
try:
with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip:
for result_id in result_ids:
row = rows.get(result_id)
if not row or not (row.get('result_file_url') or '').strip():
errors.append(f'result-{result_id}: 结果文件不存在或尚未生成')
continue
filename = row.get('result_filename') or f"{row.get('source_filename') or result_id}.xlsx"
filename = re.sub(r'[\\/:*?"<>|]+', '_', filename).strip() or f'result-{result_id}.xlsx'
if filename in used_names:
stem, extension = os.path.splitext(filename)
filename = f'{stem}-{result_id}{extension or ".xlsx"}'
used_names.add(filename)
remote = None
try:
remote = _open_shop_data_crawl_download(row)
remote.raise_for_status()
with output_zip.open(filename, mode='w', force_zip64=True) as target:
for chunk in remote.iter_content(chunk_size=1024 * 1024):
if chunk:
target.write(chunk)
file_count += 1
except (requests.RequestException, ValueError) as exc:
errors.append(f'{filename}: 下载失败 ({exc})')
finally:
if remote is not None:
remote.close()
if errors:
output_zip.writestr('download-errors.txt', '\n'.join(errors).encode('utf-8'))
archive.seek(0)
response = send_file(
archive,
mimetype='application/zip',
as_attachment=True,
download_name=f"shop-data-tasks-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip",
max_age=0,
)
response.headers['X-Archive-File-Count'] = str(file_count)
response.headers['X-Archive-Error-Count'] = str(len(errors))
response.call_on_close(archive.close)
return response
except Exception:
archive.close()
raise
@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_image_video_task_permissions():
@@ -1525,7 +2028,10 @@ def list_columns():
return error_response, status
items = [
item for item in items
if (item.get('column_key') or '').strip() != IMAGE_VIDEO_DATA_PERMISSION_KEY
if (item.get('column_key') or '').strip() not in {
IMAGE_VIDEO_DATA_PERMISSION_KEY,
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY,
}
]
# Keep the legacy `items` field; some Java-aware callers use `data`/`columns`.
return jsonify({'success': True, 'items': items, 'columns': items, 'data': items})
@@ -2250,6 +2756,9 @@ def list_shop_keys():
'remark_name': item.get('remarkName') or '',
'ziniao_account_name': item.get('ziniaoAccountName') or '',
'ziniao_token': item.get('ziniaoToken') or '',
'ip_whitelist_status': item.get('ipWhitelistStatus') or 'UNKNOWN',
'ip_whitelist_checked_at': (item.get('ipWhitelistCheckedAt') or '').replace('T', ' ')[:19],
'ip_whitelist_message': item.get('ipWhitelistMessage') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
@@ -2362,16 +2871,23 @@ def list_dedupe_total_data():
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
keyword = (request.args.get('keyword') or '').strip()
username = (request.args.get('username') or '').strip()
start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip()
end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip()
params = {
'page': page,
'pageSize': page_size,
'keyword': keyword,
'username': username,
'operatorId': current_row.get('id'),
}
if start_date:
params['startDate'] = start_date
if end_date:
params['endDate'] = end_date
data, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/dedupe-total-data',
params={
'page': page,
'pageSize': page_size,
'keyword': keyword,
'username': username,
'operatorId': current_row.get('id'),
},
params=params,
)
if error_response is not None:
return error_response, status
@@ -2830,6 +3346,61 @@ def list_shop_manages():
})
@admin_api.route('/shop-manage/<int:item_id>/credential')
@login_required
def get_shop_manage_credential(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
if denied:
return denied
shop_name = (request.args.get('shop_name') or '').strip()
if not shop_name:
return jsonify({'success': False, 'error': '店铺名不能为空'}), 400
access_params = {
'page': 1,
'pageSize': 100,
'shopName': shop_name,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if current_row and current_row.get('id'):
access_params['operatorId'] = current_row.get('id')
access_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages',
params=access_params,
)
if error_response is not None:
return error_response, status
accessible_items = ((access_result.get('data') or {}).get('items') or [])
accessible_item = next((
item for item in accessible_items
if str(item.get('id')) == str(item_id) and (item.get('shopName') or '') == shop_name
), None)
if accessible_item is None:
return jsonify({'success': False, 'error': '店铺不存在或无权访问'}), 404
internal_token = _resolve_internal_token()
if not internal_token:
return jsonify({'success': False, 'error': '内部凭据服务未配置'}), 503
credential_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/credential',
params={'shopName': shop_name},
headers={'X-Internal-Token': internal_token},
)
if error_response is not None:
return error_response, status
credential = credential_result.get('data') or {}
if str(credential.get('id')) != str(item_id):
return jsonify({'success': False, 'error': '店铺凭据不匹配'}), 409
response = jsonify({'success': True, 'password': credential.get('password') or ''})
response.headers['Cache-Control'] = 'no-store'
return response
@admin_api.route('/shop-manage', methods=['POST'])
@login_required
def create_shop_manage():