746 lines
29 KiB
Python
746 lines
29 KiB
Python
# coding=utf-8
|
||
"""识别结果上报:先上传图片拿到 URL,再 POST 到菜品管理系统。"""
|
||
import copy
|
||
import json
|
||
import os
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from urllib.parse import urlparse, urlunparse
|
||
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
requests = None
|
||
|
||
from dish_server.media_store import MediaStore, is_http_url
|
||
from dao.settings import (
|
||
DISH_BASE_CODE,
|
||
DISH_BASE_NAME,
|
||
DISH_DEVICE_CODE,
|
||
DISH_MGMT_API_KEY,
|
||
DISH_MGMT_RESULT_API_URL,
|
||
DISH_MGMT_UNRECOGNIZED_API_URL,
|
||
DISH_RESULT_RETRY_ENABLED,
|
||
DISH_RESULT_RETRY_DIR,
|
||
DISH_RESULT_RETRY_INTERVAL_SECONDS,
|
||
DISH_RESULT_RETRY_MAX_ATTEMPTS,
|
||
DISH_RESULT_RETRY_BATCH,
|
||
)
|
||
from service.result_outbox import ResultOutbox
|
||
|
||
# ---------- 后续你只需改这里 ----------
|
||
# 默认发往菜品管理系统:POST /api/public/recognition-orders
|
||
RESULT_API_URL = os.environ.get('DISH_RESULT_API_URL', DISH_MGMT_RESULT_API_URL)
|
||
UNRECOGNIZED_API_URL = os.environ.get(
|
||
'DISH_UNRECOGNIZED_API_URL', DISH_MGMT_UNRECOGNIZED_API_URL,
|
||
)
|
||
# 默认真实上报;本地联调可设 DISH_RESULT_API_MOCK=1
|
||
RESULT_API_MOCK = os.environ.get('DISH_RESULT_API_MOCK', '0') == '1'
|
||
RESULT_API_TIMEOUT = float(os.environ.get('DISH_RESULT_API_TIMEOUT', '10'))
|
||
RESULT_API_BASE_ID = os.environ.get('DISH_RESULT_BASE_ID') or os.environ.get('DISH_BASE_CODE', DISH_BASE_CODE)
|
||
RESULT_API_BASE_NAME = os.environ.get('DISH_RESULT_BASE_NAME') or DISH_BASE_NAME
|
||
RESULT_API_DEVICE_CODE = os.environ.get('DISH_RESULT_DEVICE_CODE') or DISH_DEVICE_CODE
|
||
RESULT_API_TOKEN = os.environ.get('DISH_API_TOKEN', '').strip()
|
||
# 管理系统鉴权:优先专用结果 Key,否则用基地接口同一把 Key
|
||
RESULT_API_KEY = (
|
||
os.environ.get('DISH_RESULT_API_KEY', '').strip()
|
||
or os.environ.get('DISH_MGMT_API_KEY', '').strip()
|
||
or (DISH_MGMT_API_KEY or '').strip()
|
||
)
|
||
# 上报前把本机路径转成可访问 HTTP URL(YOLO 裁剪图必须)
|
||
UPLOAD_BEFORE_REPORT = os.environ.get('DISH_UPLOAD_BEFORE_REPORT', '1') == '1'
|
||
MEDIA_UPLOAD_TIMEOUT = float(os.environ.get('DISH_MEDIA_UPLOAD_TIMEOUT', '20'))
|
||
# 上传接口仍打云 dish_server(:9100);返回 URL 应为 https://cpsb.ruimeida.cc/tpweb/...
|
||
_DEFAULT_CLOUD_HOST = 'http://116.147.40.47:9100'
|
||
_DEFAULT_CLOUD_UPLOAD = f'{_DEFAULT_CLOUD_HOST}/api/dish/media/upload'
|
||
_TPWEB_PUBLIC_BASE = os.environ.get(
|
||
'DISH_MEDIA_PUBLIC_BASE', 'https://cpsb.ruimeida.cc/tpweb'
|
||
).rstrip('/')
|
||
if 'DISH_MEDIA_UPLOAD_URL' in os.environ:
|
||
_EXPLICIT_MEDIA_UPLOAD = os.environ.get('DISH_MEDIA_UPLOAD_URL', '').strip()
|
||
else:
|
||
_EXPLICIT_MEDIA_UPLOAD = _DEFAULT_CLOUD_UPLOAD
|
||
# 识别结果默认只打管理系统;需要云落盘时再设 DISH_CLOUD_RESULT_URL
|
||
CLOUD_RESULT_API_URL = os.environ.get('DISH_CLOUD_RESULT_URL', '').strip()
|
||
# ------------------------------------
|
||
|
||
|
||
def _is_private_http_url(url):
|
||
"""局域网/本机 HTTP,管理系统(公网 HTTPS)打不开。"""
|
||
text = (url or '').strip().lower()
|
||
if not text.startswith('http://') and not text.startswith('https://'):
|
||
return False
|
||
try:
|
||
host = (urlparse(text).hostname or '').lower()
|
||
except Exception:
|
||
return False
|
||
if host in ('localhost', '127.0.0.1', '0.0.0.0'):
|
||
return True
|
||
if host.startswith('192.168.') or host.startswith('10.'):
|
||
return True
|
||
# 172.16.0.0 – 172.31.255.255
|
||
if host.startswith('172.'):
|
||
try:
|
||
second = int(host.split('.')[1])
|
||
if 16 <= second <= 31:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _to_tpweb_public_url(url):
|
||
"""把旧 http://IP:9100/media|tpweb/... 归一成 HTTPS tpweb 地址。"""
|
||
if not url:
|
||
return url
|
||
text = str(url).strip()
|
||
prefixes = (
|
||
'http://116.147.40.47:9100/media/',
|
||
'https://116.147.40.47:9100/media/',
|
||
'http://116.147.40.47:9100/tpweb/',
|
||
'https://116.147.40.47:9100/tpweb/',
|
||
'http://cpsb.ruimeida.cc/tpweb/',
|
||
'http://cpsb.ruimeida.cc/media/',
|
||
'https://cpsb.ruimeida.cc/media/',
|
||
)
|
||
for prefix in prefixes:
|
||
if text.startswith(prefix):
|
||
return f'{_TPWEB_PUBLIC_BASE}/{text[len(prefix):]}'
|
||
return text
|
||
|
||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_MOCK_DIR = os.path.join(_PROJECT_ROOT, 'api_mock_results')
|
||
|
||
|
||
def _api_origin(result_url):
|
||
parsed = urlparse(result_url or '')
|
||
if not parsed.scheme or not parsed.netloc:
|
||
return 'http://127.0.0.1:9000'
|
||
return urlunparse((parsed.scheme, parsed.netloc, '', '', '', ''))
|
||
|
||
|
||
def default_media_upload_url(result_url=None):
|
||
"""默认云微服务上传地址;失败时 result_api 会回落本机 media server。"""
|
||
return _EXPLICIT_MEDIA_UPLOAD or ''
|
||
|
||
|
||
def _as_score(value):
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return round(float(value), 4)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _format_dish_text(is_unknown, name, score, suggested_name):
|
||
if is_unknown:
|
||
return '低于阈值,建议人工确认'
|
||
if name is not None and score is not None:
|
||
return f'识别为{name},相似度 {score}'
|
||
if name:
|
||
return f'识别为{name}'
|
||
return ''
|
||
|
||
|
||
def build_result_payload(
|
||
source_image,
|
||
dishes,
|
||
labeled_image=None,
|
||
base_id=None,
|
||
base_name=None,
|
||
device_code=None,
|
||
request_id=None,
|
||
):
|
||
"""
|
||
管理系统 recognition-orders 约定格式:
|
||
|
||
{
|
||
request_id, base_name, device_code, capture_time,
|
||
source_image, labeled_image, dish_count,
|
||
dishes: [{index, name, score, is_unknown, suggested_name, box, crop_image, text}]
|
||
}
|
||
"""
|
||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
day = datetime.now().strftime('%Y%m%d')
|
||
request_id = request_id or f"REQ_{day}_{int(time.time() * 1000) % 1000000:06d}"
|
||
items = []
|
||
for i, d in enumerate(dishes or [], 1):
|
||
is_unknown = bool(d.get('is_unknown'))
|
||
name = None if is_unknown else (d.get('label') or d.get('name'))
|
||
score = _as_score(d.get('score'))
|
||
suggested = d.get('suggested_name')
|
||
if suggested is None:
|
||
suggested = d.get('suggested_category')
|
||
if not is_unknown:
|
||
suggested = None
|
||
items.append({
|
||
'index': int(d.get('index') or i),
|
||
'name': name,
|
||
'score': score,
|
||
'is_unknown': is_unknown,
|
||
'suggested_name': suggested,
|
||
'box': d.get('box'),
|
||
'crop_image': d.get('crop_image'),
|
||
'text': _format_dish_text(is_unknown, name, score, suggested),
|
||
})
|
||
bid = (base_id or RESULT_API_BASE_ID or '').strip()
|
||
return {
|
||
'request_id': request_id,
|
||
'base_id': bid,
|
||
'base_name': (base_name or RESULT_API_BASE_NAME or '').strip(),
|
||
'device_code': (device_code or RESULT_API_DEVICE_CODE or bid or '').strip(),
|
||
'capture_time': now,
|
||
'source_image': source_image,
|
||
'labeled_image': labeled_image,
|
||
'dish_count': len(items),
|
||
'dishes': items,
|
||
}
|
||
|
||
|
||
def _gen_order_no():
|
||
"""生成无法识别上报用的 order_no,形如 HW26071012341817。"""
|
||
return f"HW{datetime.now().strftime('%y%m%d%H%M%S')}{int(time.time() * 1000) % 100:02d}"
|
||
|
||
|
||
def build_unrecognized_payload(
|
||
source_image,
|
||
unknown_dishes,
|
||
*,
|
||
base_code=None,
|
||
base_name=None,
|
||
device_code=None,
|
||
request_id=None,
|
||
order_no=None,
|
||
capture_time=None,
|
||
):
|
||
"""管理系统 unrecognized-samples 约定格式(图片须为 HTTPS tpweb URL)。"""
|
||
now = capture_time or datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
day = datetime.now().strftime('%Y%m%d')
|
||
request_id = request_id or f"REQ_{day}_{int(time.time() * 1000) % 1000000:06d}"
|
||
order_no = order_no or _gen_order_no()
|
||
crops = []
|
||
suggested_names = []
|
||
for i, d in enumerate(unknown_dishes or [], 1):
|
||
if not d.get('is_unknown', True):
|
||
continue
|
||
suggested = d.get('suggested_name')
|
||
if suggested is None:
|
||
suggested = d.get('suggested_category')
|
||
suggested = (suggested or '').strip() or None
|
||
if suggested:
|
||
suggested_names.append(suggested)
|
||
crops.append({
|
||
'index': int(d.get('index') or i),
|
||
'image_url': d.get('crop_image'), # 本地路径,发送前再转 URL
|
||
'suggested_name': suggested,
|
||
'score': _as_score(d.get('score')),
|
||
})
|
||
bid = (base_code or RESULT_API_BASE_ID or '').strip()
|
||
bname = (base_name or RESULT_API_BASE_NAME or '').strip()
|
||
# 与管理系统约定一致:base_code / base_name 至少其一
|
||
payload = {
|
||
'request_id': request_id,
|
||
'order_no': order_no,
|
||
'base_name': bname,
|
||
'device_code': (device_code or RESULT_API_DEVICE_CODE or bid or '').strip(),
|
||
'capture_time': now,
|
||
'image_url': source_image,
|
||
'unknown_count': len(crops),
|
||
'suggested_names': suggested_names,
|
||
'crops': crops,
|
||
}
|
||
if bid:
|
||
payload['base_code'] = bid
|
||
return payload
|
||
|
||
|
||
class ResultApiClient:
|
||
def __init__(self, api_url=None, mock=None, timeout=None, base_id=None,
|
||
base_name=None, device_code=None,
|
||
media_upload_url=None, upload_before_report=None, token=None, api_key=None,
|
||
cloud_result_url=None, unrecognized_api_url=None):
|
||
self.api_url = api_url or RESULT_API_URL
|
||
self.unrecognized_api_url = unrecognized_api_url or UNRECOGNIZED_API_URL
|
||
self.mock = RESULT_API_MOCK if mock is None else mock
|
||
self.timeout = timeout or RESULT_API_TIMEOUT
|
||
self.base_id = base_id or RESULT_API_BASE_ID
|
||
self.base_name = base_name or RESULT_API_BASE_NAME
|
||
self.device_code = device_code or RESULT_API_DEVICE_CODE or self.base_id
|
||
if media_upload_url is None:
|
||
self.media_upload_url = default_media_upload_url(self.api_url)
|
||
else:
|
||
self.media_upload_url = media_upload_url or ''
|
||
self.upload_before_report = (
|
||
UPLOAD_BEFORE_REPORT if upload_before_report is None else bool(upload_before_report)
|
||
)
|
||
self.token = RESULT_API_TOKEN if token is None else (token or '')
|
||
self.api_key = RESULT_API_KEY if api_key is None else (api_key or '')
|
||
self.cloud_result_url = (
|
||
CLOUD_RESULT_API_URL if cloud_result_url is None else (cloud_result_url or '')
|
||
)
|
||
self._store = MediaStore()
|
||
os.makedirs(_MOCK_DIR, exist_ok=True)
|
||
self.retry_enabled = bool(DISH_RESULT_RETRY_ENABLED)
|
||
self._outbox = ResultOutbox(DISH_RESULT_RETRY_DIR) if self.retry_enabled else None
|
||
self._retry_interval = max(5.0, float(DISH_RESULT_RETRY_INTERVAL_SECONDS or 20))
|
||
self._retry_max_attempts = int(DISH_RESULT_RETRY_MAX_ATTEMPTS or 0)
|
||
self._retry_batch = max(1, int(DISH_RESULT_RETRY_BATCH or 10))
|
||
self._retry_stop = threading.Event()
|
||
self._retry_thread = None
|
||
if self._outbox is not None:
|
||
self._start_retry_worker()
|
||
|
||
def _refresh_media_store(self):
|
||
"""确保本机 media HTTP 服务已启动,并用公网/局域网前缀生成 URL。"""
|
||
try:
|
||
from service.media_server import ensure_media_server, get_public_base
|
||
ensure_media_server()
|
||
self._store.public_base = get_public_base()
|
||
except Exception as exc:
|
||
print(f'[WARN] media server: {exc}')
|
||
|
||
def _headers(self):
|
||
headers = {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-Base-Code': self.base_id or self.device_code or '',
|
||
}
|
||
if self.api_key:
|
||
headers['X-API-Key'] = self.api_key
|
||
if self.token:
|
||
headers['Authorization'] = f'Bearer {self.token}'
|
||
return headers
|
||
|
||
def send(self, source_image, dishes, labeled_image=None, async_mode=True):
|
||
payload = build_result_payload(
|
||
source_image=source_image,
|
||
dishes=dishes,
|
||
labeled_image=labeled_image,
|
||
base_id=self.base_id,
|
||
base_name=self.base_name,
|
||
device_code=self.device_code,
|
||
)
|
||
if not payload.get('dishes'):
|
||
print('[API] skip report: empty dishes')
|
||
payload['skipped'] = True
|
||
payload['ok'] = True
|
||
return payload
|
||
|
||
# local_keep 保留本地图片路径,失败重发时可重新上传
|
||
local_keep = copy.deepcopy(payload)
|
||
to_send = copy.deepcopy(payload)
|
||
if async_mode:
|
||
threading.Thread(
|
||
target=self._deliver,
|
||
args=('recognition', to_send, local_keep),
|
||
daemon=True,
|
||
).start()
|
||
return payload
|
||
return self._deliver('recognition', to_send, local_keep)
|
||
|
||
def send_unrecognized(self, source_image, unknown_dishes, async_mode=True):
|
||
"""上报无法识别样本到管理系统「无法识别统计」。"""
|
||
unknowns = [d for d in (unknown_dishes or []) if d.get('is_unknown', True)]
|
||
if not unknowns:
|
||
return {'ok': True, 'skipped': True, 'reason': 'no_unknown'}
|
||
if not source_image:
|
||
print('[API] skip unrecognized: missing source_image')
|
||
return {'ok': False, 'skipped': True, 'reason': 'no_source'}
|
||
|
||
payload = build_unrecognized_payload(
|
||
source_image=source_image,
|
||
unknown_dishes=unknowns,
|
||
base_code=self.base_id,
|
||
base_name=self.base_name,
|
||
device_code=self.device_code,
|
||
)
|
||
if not payload.get('crops'):
|
||
print('[API] skip unrecognized: empty crops')
|
||
payload['skipped'] = True
|
||
payload['ok'] = True
|
||
return payload
|
||
|
||
local_keep = copy.deepcopy(payload)
|
||
to_send = copy.deepcopy(payload)
|
||
if async_mode:
|
||
threading.Thread(
|
||
target=self._deliver,
|
||
args=('unrecognized', to_send, local_keep),
|
||
daemon=True,
|
||
).start()
|
||
return payload
|
||
return self._deliver('unrecognized', to_send, local_keep)
|
||
|
||
def outbox_stats(self):
|
||
if self._outbox is None:
|
||
return {'pending': 0, 'dead': 0, 'total': 0}
|
||
return self._outbox.stats()
|
||
|
||
def flush_outbox_now(self):
|
||
"""立刻尝试重发队列(供设置页手动触发)。"""
|
||
return self._flush_outbox()
|
||
|
||
def _start_retry_worker(self):
|
||
if self._retry_thread is not None and self._retry_thread.is_alive():
|
||
return
|
||
|
||
def _loop():
|
||
# 启动稍等,避开开机抢网
|
||
self._retry_stop.wait(3.0)
|
||
while not self._retry_stop.is_set():
|
||
try:
|
||
self._flush_outbox()
|
||
except Exception as exc:
|
||
print(f'[outbox] flush loop error: {exc}')
|
||
self._retry_stop.wait(self._retry_interval)
|
||
|
||
self._retry_thread = threading.Thread(
|
||
target=_loop, name='result-outbox-retry', daemon=True,
|
||
)
|
||
self._retry_thread.start()
|
||
print(
|
||
f'[outbox] retry worker started dir={getattr(self._outbox, "root_dir", "")} '
|
||
f'interval={self._retry_interval:g}s'
|
||
)
|
||
|
||
def _deliver(self, kind, to_send, local_keep):
|
||
"""发送一次;失败则落盘,成功则从出站队列剔除。"""
|
||
if kind == 'unrecognized':
|
||
result = self._post_unrecognized(to_send)
|
||
else:
|
||
result = self._post(to_send)
|
||
if not isinstance(result, dict):
|
||
result = {'ok': False, 'error': 'empty result'}
|
||
if result.get('skipped'):
|
||
return result
|
||
request_id = (local_keep or {}).get('request_id') or (to_send or {}).get('request_id')
|
||
if result.get('ok'):
|
||
if self._outbox is not None and request_id:
|
||
self._outbox.ack(kind, request_id)
|
||
return result
|
||
err = result.get('error') or result.get('status_code') or 'report failed'
|
||
if self._outbox is not None and local_keep:
|
||
self._outbox.enqueue(kind, local_keep, error=err, next_delay=self._retry_interval)
|
||
print(f'[outbox] will retry {kind} request_id={request_id} ({err})')
|
||
return result
|
||
|
||
def _flush_outbox(self):
|
||
if self._outbox is None:
|
||
return {'ok': 0, 'fail': 0}
|
||
due = self._outbox.due_items(limit=self._retry_batch)
|
||
if not due:
|
||
return {'ok': 0, 'fail': 0}
|
||
ok_n = fail_n = 0
|
||
print(f'[outbox] flushing {len(due)} item(s)…')
|
||
for item in due:
|
||
kind = item.get('kind') or 'recognition'
|
||
payload = copy.deepcopy(item.get('payload') or {})
|
||
request_id = item.get('request_id') or payload.get('request_id')
|
||
try:
|
||
if kind == 'unrecognized':
|
||
result = self._post_unrecognized(payload)
|
||
else:
|
||
result = self._post(payload)
|
||
except Exception as exc:
|
||
result = {'ok': False, 'error': str(exc)}
|
||
if isinstance(result, dict) and result.get('ok'):
|
||
self._outbox.ack(kind, request_id)
|
||
ok_n += 1
|
||
else:
|
||
err = None
|
||
if isinstance(result, dict):
|
||
err = result.get('error') or result.get('status_code')
|
||
self._outbox.bump(
|
||
kind,
|
||
request_id,
|
||
error=err,
|
||
next_delay=self._retry_interval,
|
||
max_attempts=self._retry_max_attempts,
|
||
)
|
||
fail_n += 1
|
||
if ok_n or fail_n:
|
||
print(f'[outbox] flush done ok={ok_n} fail={fail_n} left={self._outbox.pending_count()}')
|
||
return {'ok': ok_n, 'fail': fail_n}
|
||
|
||
def _ensure_unrecognized_urls(self, payload):
|
||
"""本地 source/crop 路径 → HTTPS tpweb URL。"""
|
||
if not self.upload_before_report:
|
||
# 字段名已是 image_url;crops[].image_url 也可能仍是本地路径
|
||
return payload
|
||
try:
|
||
self._refresh_media_store()
|
||
except Exception as exc:
|
||
print(f'[WARN] media server: {exc}')
|
||
request_id = payload.get('request_id')
|
||
base_id = (
|
||
payload.get('base_code')
|
||
or payload.get('device_code')
|
||
or self.device_code
|
||
or self.base_id
|
||
)
|
||
payload['image_url'] = self._to_url(
|
||
payload.get('image_url'), 'source', base_id, request_id,
|
||
)
|
||
for crop in payload.get('crops') or []:
|
||
crop['image_url'] = self._to_url(
|
||
crop.get('image_url'), 'crop', base_id, request_id,
|
||
)
|
||
# 丢掉没有图的 crop
|
||
payload['crops'] = [
|
||
c for c in (payload.get('crops') or []) if c.get('image_url')
|
||
]
|
||
payload['unknown_count'] = len(payload['crops'])
|
||
return payload
|
||
|
||
def _post_unrecognized(self, payload):
|
||
try:
|
||
payload = self._ensure_unrecognized_urls(payload)
|
||
except Exception as exc:
|
||
print(f'[API] unrecognized media prepare failed: {exc}')
|
||
if not payload.get('image_url'):
|
||
print('[API] unrecognized aborted: image_url 上传失败')
|
||
return {'ok': False, 'error': 'image_url missing', 'payload': payload}
|
||
if not payload.get('crops'):
|
||
print('[API] unrecognized aborted: crops 上传失败')
|
||
return {'ok': False, 'error': 'crops empty', 'payload': payload}
|
||
|
||
url = (self.unrecognized_api_url or '').strip()
|
||
if self.mock or not url:
|
||
name = f"unrecognized_{payload.get('request_id') or int(time.time())}.json"
|
||
path = os.path.join(_MOCK_DIR, name)
|
||
with open(path, 'w', encoding='utf-8') as f:
|
||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||
print(f"[API-MOCK] unrecognized saved {path}")
|
||
return {'ok': True, 'mock': True, 'path': path, 'payload': payload}
|
||
if requests is None:
|
||
print('[WARN] requests 未安装,无法上报无法识别样本')
|
||
return {'ok': False, 'error': 'no requests'}
|
||
|
||
try:
|
||
resp = requests.post(
|
||
url, json=payload, timeout=self.timeout, headers=self._headers(),
|
||
)
|
||
ok = 200 <= resp.status_code < 300
|
||
print(f"[API] POST unrecognized {url} -> {resp.status_code}")
|
||
if not ok:
|
||
print(f"[API] unrecognized request: {json.dumps(payload, ensure_ascii=False)[:1200]}")
|
||
print(f"[API] unrecognized response: {(resp.text or '')[:500]}")
|
||
else:
|
||
print(
|
||
f"[API] unrecognized ok order_no={payload.get('order_no')} "
|
||
f"unknowns={payload.get('unknown_count')}"
|
||
)
|
||
out = {
|
||
'ok': ok,
|
||
'status_code': resp.status_code,
|
||
'payload': payload,
|
||
'response': resp.text,
|
||
}
|
||
if not ok:
|
||
out['error'] = f'HTTP {resp.status_code}'
|
||
return out
|
||
except Exception as exc:
|
||
print(f"[API] unrecognized POST failed: {exc}")
|
||
return {'ok': False, 'error': str(exc), 'payload': payload}
|
||
|
||
def _post(self, payload):
|
||
try:
|
||
payload = self._ensure_remote_urls(payload)
|
||
except Exception as exc:
|
||
print(f'[API] media prepare failed: {exc}')
|
||
if self.mock:
|
||
return self._mock_save(payload)
|
||
if requests is None:
|
||
print('[WARN] requests 未安装,回退模拟保存')
|
||
return self._mock_save(payload)
|
||
|
||
# 1) 云微服务落盘(图已在云上,结果 JSON 一并存)
|
||
cloud_ok = self._post_cloud_result(payload)
|
||
|
||
# 2) 菜品管理系统(业务侧)
|
||
try:
|
||
resp = requests.post(
|
||
self.api_url, json=payload, timeout=self.timeout, headers=self._headers(),
|
||
)
|
||
ok = 200 <= resp.status_code < 300
|
||
print(f"[API] POST {self.api_url} -> {resp.status_code}")
|
||
if not ok:
|
||
print(f"[API] request: {json.dumps(payload, ensure_ascii=False)[:1200]}")
|
||
print(f"[API] response: {(resp.text or '')[:500]}")
|
||
out = {
|
||
'ok': ok,
|
||
'status_code': resp.status_code,
|
||
'payload': payload,
|
||
'response': resp.text,
|
||
'cloud_ok': cloud_ok,
|
||
}
|
||
if not ok:
|
||
out['error'] = f'HTTP {resp.status_code}'
|
||
return out
|
||
except Exception as exc:
|
||
print(f"[API] POST failed: {exc}")
|
||
print(f"[API] request: {json.dumps(payload, ensure_ascii=False)[:1200]}")
|
||
saved = self._mock_save(payload)
|
||
saved['ok'] = False
|
||
saved['error'] = str(exc)
|
||
saved['cloud_ok'] = cloud_ok
|
||
return saved
|
||
|
||
def _post_cloud_result(self, payload):
|
||
"""识别结果写入云 dish_server(data/results/*.json)。"""
|
||
url = (self.cloud_result_url or '').strip()
|
||
if not url:
|
||
return None
|
||
# 与管理系统 URL 相同时避免重复 POST
|
||
if url.rstrip('/') == (self.api_url or '').rstrip('/'):
|
||
return None
|
||
body = copy.deepcopy(payload)
|
||
if not body.get('base_id'):
|
||
body['base_id'] = self.base_id or body.get('device_code') or ''
|
||
try:
|
||
headers = {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-Base-Code': body.get('base_id') or self.base_id or '',
|
||
}
|
||
if self.token:
|
||
headers['Authorization'] = f'Bearer {self.token}'
|
||
resp = requests.post(url, json=body, timeout=self.timeout, headers=headers)
|
||
ok = 200 <= resp.status_code < 300
|
||
print(f"[API] CLOUD POST {url} -> {resp.status_code}")
|
||
if not ok:
|
||
print(f"[API] cloud response: {(resp.text or '')[:300]}")
|
||
return ok
|
||
except Exception as exc:
|
||
print(f"[API] CLOUD POST failed: {exc}")
|
||
return False
|
||
|
||
def _ensure_remote_urls(self, payload):
|
||
"""本地图片路径 → 云 tpweb HTTPS URL(不上报局域网地址)。"""
|
||
if not self.upload_before_report:
|
||
return payload
|
||
print(
|
||
f"[API] media upload -> {self.media_upload_url or '(disabled)'} "
|
||
f"| public={_TPWEB_PUBLIC_BASE} | mock={self.mock}"
|
||
)
|
||
# 仅本机兜底服务需要;失败回落时不会再用局域网 URL 上报
|
||
try:
|
||
self._refresh_media_store()
|
||
except Exception as exc:
|
||
print(f'[WARN] media server: {exc}')
|
||
request_id = payload.get('request_id')
|
||
base_id = (
|
||
payload.get('base_id')
|
||
or payload.get('device_code')
|
||
or payload.get('base_name')
|
||
or self.device_code
|
||
or self.base_id
|
||
)
|
||
payload['source_image'] = self._to_url(
|
||
payload.get('source_image'), 'source', base_id, request_id,
|
||
)
|
||
payload['labeled_image'] = self._to_url(
|
||
payload.get('labeled_image'), 'labeled', base_id, request_id,
|
||
)
|
||
for dish in payload.get('dishes') or []:
|
||
dish['crop_image'] = self._to_url(
|
||
dish.get('crop_image'), 'crop', base_id, request_id,
|
||
)
|
||
return payload
|
||
|
||
def _to_url(self, path_or_url, media_type, base_id, request_id):
|
||
if not path_or_url:
|
||
return path_or_url
|
||
if is_http_url(path_or_url):
|
||
fixed = _to_tpweb_public_url(path_or_url)
|
||
if _is_private_http_url(fixed):
|
||
print(f'[API] WARN 丢弃局域网图片地址({media_type}): {fixed}')
|
||
return None
|
||
return fixed
|
||
local = os.path.abspath(path_or_url) if not os.path.isabs(path_or_url) else path_or_url
|
||
if not os.path.isfile(local):
|
||
return path_or_url
|
||
|
||
# 已配置云上传:必须上传成功,禁止回落 192.168 地址
|
||
if self.media_upload_url and requests is not None and not self.mock:
|
||
url = self._http_upload(local, media_type, base_id, request_id)
|
||
if url:
|
||
return url
|
||
# 重试一次
|
||
url = self._http_upload(local, media_type, base_id, request_id)
|
||
if url:
|
||
return url
|
||
print(
|
||
f'[API] ERROR {media_type} 云上传失败,不会使用局域网 URL 上报。'
|
||
f'请检查云 dish_server 与网络: {self.media_upload_url}'
|
||
)
|
||
return None
|
||
|
||
# 未配置云上传时才允许本机 media(仅本地联调)
|
||
saved = self._store.save_file(
|
||
local, media_type=media_type, base_id=base_id, request_id=request_id,
|
||
)
|
||
if saved and saved.get('url'):
|
||
url = _to_tpweb_public_url(saved['url'])
|
||
if _is_private_http_url(url):
|
||
print(f'[API] WARN 本机 media URL 为局域网,管理系统不可用: {url}')
|
||
return None
|
||
print(f"[API] {media_type} -> {url}")
|
||
return url
|
||
return path_or_url
|
||
|
||
def _http_upload(self, file_path, media_type, base_id, request_id):
|
||
if not self.media_upload_url:
|
||
return None
|
||
try:
|
||
with open(file_path, 'rb') as fh:
|
||
files = {'file': (os.path.basename(file_path), fh, 'application/octet-stream')}
|
||
data = {
|
||
'type': media_type,
|
||
'base_id': base_id or '',
|
||
'request_id': request_id or '',
|
||
}
|
||
headers = {'X-Base-Code': base_id or ''}
|
||
if self.api_key:
|
||
headers['X-API-Key'] = self.api_key
|
||
if self.token:
|
||
headers['Authorization'] = f'Bearer {self.token}'
|
||
resp = requests.post(
|
||
self.media_upload_url,
|
||
files=files,
|
||
data=data,
|
||
headers=headers,
|
||
timeout=MEDIA_UPLOAD_TIMEOUT,
|
||
)
|
||
if not (200 <= resp.status_code < 300):
|
||
print(f"[API] upload {media_type} -> {resp.status_code} {resp.text[:200]}")
|
||
return None
|
||
body = resp.json() if resp.content else {}
|
||
data = body.get('data') if isinstance(body, dict) else None
|
||
url = None
|
||
if isinstance(data, dict):
|
||
url = data.get('url') or data.get('path')
|
||
if not url and isinstance(body, dict):
|
||
url = body.get('url')
|
||
# 相对路径 → HTTPS tpweb;旧 http://IP:9100/media 也改写
|
||
if url and not is_http_url(url):
|
||
url = f"{_TPWEB_PUBLIC_BASE}/{str(url).lstrip('/')}"
|
||
url = _to_tpweb_public_url(url)
|
||
if url:
|
||
print(f"[API] {media_type} -> {url}")
|
||
return url
|
||
except Exception as exc:
|
||
print(f"[API] upload {media_type} failed: {exc}")
|
||
return None
|
||
|
||
def _mock_save(self, payload):
|
||
name = f"{payload['request_id']}.json"
|
||
path = os.path.join(_MOCK_DIR, name)
|
||
with open(path, 'w', encoding='utf-8') as f:
|
||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||
print(f"[API-MOCK] saved {path}")
|
||
print(f"[API-MOCK] dishes={payload['dish_count']} url={self.api_url}")
|
||
return {'ok': True, 'mock': True, 'path': path, 'payload': payload}
|