# coding=utf-8 """菜品识别管理系统公开接口客户端(X-API-Key)。 对接文档:docs/第三方接口文档.md / docs/第三方接口文档_mgmt.md """ from datetime import datetime try: import requests except ImportError: requests = None from dao.settings import ( DISH_BASE_CODE, DISH_BASE_NAME, DISH_DEVICE_CODE, DISH_DEVICE_NAME, DISH_MGMT_API_KEY, DISH_MGMT_API_ORIGIN, DISH_MGMT_API_TIMEOUT, DISH_MGMT_API_URL, DISH_MGMT_DEVICE_STATUS_URL, DISH_MGMT_DISH_LEARNING_STATUS_URL, DISH_MGMT_DISHES_API_URL, DISH_MGMT_MEAL_API_URL, DISH_MGMT_RESULT_API_URL, DISH_MGMT_UNRECOGNIZED_API_URL, ) class MgmtApiError(RuntimeError): pass def _headers(api_key=None, json_body=False): key = (api_key if api_key is not None else DISH_MGMT_API_KEY) or '' key = str(key).strip() if not key: raise MgmtApiError('未配置 DISH_MGMT_API_KEY') headers = { 'X-API-Key': key, 'Accept': 'application/json', } if json_body: headers['Content-Type'] = 'application/json' return headers def _request(method, url, *, params=None, json_body=None, api_key=None, timeout=None): if requests is None: raise MgmtApiError('缺少 requests 库,请 pip install requests') url = (url or '').strip() if not url: raise MgmtApiError('未配置接口 URL') timeout = float(timeout if timeout is not None else DISH_MGMT_API_TIMEOUT) headers = _headers(api_key=api_key, json_body=json_body is not None) try: resp = requests.request( method.upper(), url, headers=headers, params=params, json=json_body, timeout=timeout, ) except Exception as exc: raise MgmtApiError(f'请求管理系统失败: {exc}') from exc if resp.status_code >= 400: body = (resp.text or '')[:300] raise MgmtApiError(f'管理系统 HTTP {resp.status_code}: {body}') if not resp.content: return {} try: return resp.json() except Exception as exc: raise MgmtApiError(f'管理系统返回非 JSON: {exc}') from exc # ---------- 1) 基地列表 ---------- def fetch_bases(url=None, api_key=None, timeout=None): """GET /api/public/bases → items[]""" data = _request('GET', url or DISH_MGMT_API_URL, api_key=api_key, timeout=timeout) if isinstance(data, list): return data if isinstance(data, dict): for key in ('items', 'data', 'list', 'bases', 'rows'): if isinstance(data.get(key), list): return data[key] raise MgmtApiError(f'无法解析基地列表,响应类型={type(data).__name__}') # ---------- 2) 写入识别结果 ---------- def post_recognize_result(payload, url=None, api_key=None, timeout=None): """ POST /api/public/recognition-orders payload 字段: 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}] """ return _request( 'POST', url or DISH_MGMT_RESULT_API_URL, json_body=payload, api_key=api_key, timeout=timeout, ) def post_unrecognized_samples(payload, url=None, api_key=None, timeout=None): """ POST /api/public/unrecognized-samples 必填:order_no、device_code、capture_time、image_url """ return _request( 'POST', url or DISH_MGMT_UNRECOGNIZED_API_URL, json_body=payload, api_key=api_key, timeout=timeout, ) # ---------- 3) 更新设备状态 ---------- def build_device_status_payload( *, device_code=None, device_name=None, base_code=None, base_name=None, status=1, online=True, ip=None, app_version=None, reported_at=None, extra=None, ): """ 设备状态上报字段(camelCase,与管理系统其它公开接口风格一致)。 { "deviceCode", "deviceName", "baseCode", "baseName", "status", "online", "ip", "appVersion", "reportedAt" } """ payload = { 'deviceCode': (device_code or DISH_DEVICE_CODE or DISH_BASE_CODE or '').strip(), 'deviceName': (device_name or DISH_DEVICE_NAME or DISH_BASE_NAME or '').strip(), 'baseCode': (base_code or DISH_BASE_CODE or '').strip(), 'baseName': (base_name or DISH_BASE_NAME or '').strip(), 'status': int(status) if status is not None else 1, 'online': bool(online), 'ip': ip or '', 'appVersion': app_version or '', 'reportedAt': reported_at or datetime.now().strftime('%Y-%m-%d %H:%M:%S'), } if extra and isinstance(extra, dict): payload.update(extra) return payload def post_device_status(payload=None, url=None, api_key=None, timeout=None, **kwargs): """POST /api/public/devices/status(可用 DISH_MGMT_DEVICE_STATUS_URL 覆盖)""" body = payload if payload is not None else build_device_status_payload(**kwargs) return _request( 'POST', url or DISH_MGMT_DEVICE_STATUS_URL, json_body=body, api_key=api_key, timeout=timeout, ) # ---------- 4) 当天排餐 ---------- def fetch_today_meals(base_name=None, base_id=None, base_code=None, date=None, url=None, api_key=None, timeout=None): """ GET /api/public/meal-schedules/today 筛选优先级:baseId > baseCode > baseName """ params = {} if date: params['date'] = date if base_id is not None and str(base_id).strip() != '': params['baseId'] = base_id elif base_code: params['baseCode'] = base_code elif base_name: params['baseName'] = base_name else: params['baseName'] = DISH_BASE_NAME return _request( 'GET', url or DISH_MGMT_MEAL_API_URL, params=params, api_key=api_key, timeout=timeout, ) def extract_dish_names_from_meals(meal_data): """从排餐响应中提取全部菜名集合(跨餐段)。""" names = set() if not isinstance(meal_data, dict): return names for base in meal_data.get('bases') or []: for meal in base.get('meals') or []: for dish in meal.get('dishes') or []: name = (dish.get('name') or '').strip() if name: names.add(name) return names # ---------- 5) 菜品信息 ---------- def fetch_dishes(name=None, category=None, url=None, api_key=None, timeout=None): """ GET /api/public/dishes :param name: 菜名模糊查询(可选) :param category: 分类精确查询,传「全部」等同不筛选(可选) :return: dict,通常含 total / items;若服务端直接返回 list 也会包一层 """ params = {} if name is not None and str(name).strip() != '': params['name'] = str(name).strip() if category is not None and str(category).strip() != '': cat = str(category).strip() if cat != '全部': params['category'] = cat data = _request( 'GET', url or DISH_MGMT_DISHES_API_URL, params=params or None, api_key=api_key, timeout=timeout, ) if isinstance(data, list): return {'total': len(data), 'items': data} if isinstance(data, dict): if 'items' in data: return data for key in ('data', 'list', 'rows', 'dishes'): if isinstance(data.get(key), list): return { 'total': data.get('total', len(data[key])), 'items': data[key], **{k: v for k, v in data.items() if k not in ('total', 'items', key)}, } return data raise MgmtApiError(f'无法解析菜品列表,响应类型={type(data).__name__}') def patch_dish_learning_status(name, status, url=None, api_key=None, timeout=None): """ PATCH /api/public/dishes/learning-status :param name: 菜品名称(精确匹配) :param status: 已学习 / 未学习(也兼容 learned / unlearned) """ name = (name or '').strip() if not name: raise MgmtApiError('菜品名称不能为空') raw = (status or '').strip() status_map = { '已学习': '已学习', '未学习': '未学习', 'learned': '已学习', 'unlearned': '未学习', 'LEARNED': '已学习', 'UNLEARNED': '未学习', } normalized = status_map.get(raw) or status_map.get(raw.lower()) if not normalized: raise MgmtApiError('学习状态只能是「已学习」或「未学习」') return _request( 'PATCH', url or DISH_MGMT_DISH_LEARNING_STATUS_URL, json_body={'name': name, 'status': normalized}, api_key=api_key, timeout=timeout, ) def notify_dish_learned(name, timeout=None): """学菜成功后上报「已学习」;失败只打印警告,不抛异常。""" try: return patch_dish_learning_status(name, '已学习', timeout=timeout) except Exception as exc: print(f'[WARN] 上报菜品学习状态失败 ({name}): {exc}') return None # origin 仅用于调试 MGMT_ORIGIN = DISH_MGMT_API_ORIGIN