# 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}