{
  "openapi": "3.0.3",
  "info": {
    "title": "饮食上传—识别回读—关联保存最小契约（待评审）",
    "version": "0.3.0-draft",
    "description": "两个既有接口增量（upload/image、meal.meal/add）与一个建议新增只读接口meal.photo/info。未实现，Apifox401未导入。"
  },
  "x-review-status": "DRAFT_NOT_IMPORTED",
  "x-code-baseline": {
    "repository": "ai_api",
    "releaseSha": "e48002242f71189cb68286bcf01008fff559d0ff"
  },
  "paths": {
    "/upload/image": {
      "post": {
        "operationId": "uploadImageDietContextDraft",
        "summary": "图片上传（增量饮食上下文，待评审）",
        "tags": [
          "文件上传"
        ],
        "description": "# AI 运动营养师：拍照上传接续接口草案\n\n状态：DRAFT / 待接口评审。仅为设计，尚未实现或上线；Apifox 当前访问返回 401，未导入、未回读。目标项目为 AI运动营养师（6067185），具体目录需与已有 upload/image、meal.meal/add 及新增 meal.photo/info 所属模块分别核对。不得以本文件替代接口评审通过记录。\n\n## 1. 接口概述\n\n沿用小程序“餐品详情 → 拍照添加”的公共 `upload/image`，页面、按钮、选择相机/相册操作不变。仅饮食调用增加可选 `photo_context`。服务端用可信登录确定上传人和企业，把原图、餐次上下文和待处理事件持久化，再由可靠 outbox 投递给 store；识别失败不影响已经保存的原图可见。\n\n旧调用完全兼容：不传 photo_context 时，走现有通用上传逻辑，返回原 fileInfo；不把通用相册、头像等上传自动认作饮食。新增分支不改变其他业务已有 flag/checkLogin 参数含义。传 photo_context 时，无论旧 flag/checkLogin 值为何均要求可信登录；客户端不能通过 checkLogin=0 绕过。\n\n## 2. 接口信息\n\n| 项目 | 契约 |\n|---|---|\n| 方法/相对路由 | POST /upload/image（沿用现有客户端相对API路由，域名和部署前缀由环境配置） |\n| Content-Type | multipart/form-data；photo_context 是 UTF-8 JSON 字符串，不能用字段名 form[photo_context] 替代 |\n| 作用范围 | 新增饮食场景分支；本文件包含两个既有接口增量与一个建议新增回读接口 |\n| 成功条件 | 文件对象写入完成，上传文件记录、饮食上传记录、outbox 在本地数据库事务中可靠提交；不等待 AI 完成 |\n| 代码依据 | ai_api release e48002242f71189cb68286bcf01008fff559d0ff；Upload控制器/服务/模型，cores/BaseController.php，config/status.php |\n| 当前真实差距 | 现网 Upload 在 flag 为空时关闭登录检查；普通保存没有原图ID与上传事件关联；草案设计尚未改动代码 |\n\n可靠投递：对象存储与数据库不伪装为一个分布式事务。先校验身份、组织映射、上下文及幂等，再上传到服务端生成的受控对象键；数据库原子写 upload_file + photo_record + outbox。提交失败不能返回“已登记成功”，保留可恢复幂等记录与可清理孤立对象。若对象成功但数据库失败，重试仍用同一request_id与内容摘要，避免重复统计。\n\noutbox 在 ai_api 所属数据库内与源记录同事务提交；后台 worker 将事件投递到 store 受控消费者/已有 mysql_zhct 数据连接（具体承载需部署核验）。store 以源事件ID唯一去重，先提交原图上传记录，再异步识别；确认后才标记源outbox已投递。跨库失败采用至少一次重试，不在一次HTTP事务中假装保证跨库原子性。worker宕机用租约超时重新领取，失败事件保留原因与人工重试入口。\n\n## 3. 请求头（Header）\n\n| 名称 | 规则 |\n|---|---|\n| Access-Token | 新饮食分支必需，沿用现有请求拦截器；仅以服务端验证出的用户身份为准 |\n| enterprise-no | 沿用现有租户路由头；不是授权凭证，必须与已认证用户有权访问的企业相符；单企业环境是否必填沿用部署规则 |\n| platform | 沿用现有值，例如 MP-WEIXIN；不是权限凭证 |\n| version/env-version | 旧调用已有头，增量设计不改变其含义；读取Apifox旧定义后保留全部既有参数 |\n\n不得接受客户端 staff_uuid、staff_id、user_id、tenant_id、store_id、department_uuid 指定记录归属。租户必须由受控路由与授权身份共同解析；食物照片地址只能来自本次服务端文件存储结果，不能接受任意 URL。\n\n## 4. 请求参数\n\nPath、Query：本草案不新增；已有 flag/checkLogin 如果当前接口文档存在则保留，导入前必须先备份差异核对。\n\n| Body字段 | 类型 | 规则 |\n|---|---|---|\n| file | binary | 沿用现有单文件上传；服务端校验实际图像格式、解码及大小，不信任扩展名和MIME声明。饮食分支建议上限10MiB，与现有调用方限制一致；具体格式和尺寸沿用已核验存储验证器 |\n| photo_context | string(JSON) | 可选；省略走旧逻辑；存在但为空/非对象/字段错误则拒绝，不能静默降级旧上传 |\n| photo_context.meal_date | string | 必填，有效 YYYY-MM-DD 进餐日期；不拿服务端上传时间覆盖 |\n| photo_context.meal_times | integer | 必填，1早餐及早加餐、2午餐及午加餐、3晚餐及晚加餐、4夜宵；4不挪作加餐 |\n| photo_context.request_id | string | 必填，调用方每次选择新照片生成UUID；网络重试、超时恢复复用；更换文件/日期/餐次生成新ID |\n\nphoto_context additionalProperties=false。meal_kind为新记录类型，取值与store一致（regular/extra/snack/night_snack/unassigned），不得占用旧date_menu.meal_type=3的来源含义。不要求新增进餐时刻、正餐/加餐表单：没有证据时 record.meal_time=null、meal_kind=unassigned，后续后台补正。餐次组与正餐/加餐是不同字段。\n\n```json\n{\"meal_date\":\"2026-09-11\",\"meal_times\":1,\"request_id\":\"550e8400-e29b-41d4-a716-446655440000\"}\n```\n\n幂等唯一键为服务端已认证 tenant + user_id + request_id；内容指纹包含服务端计算的文件SHA-256、meal_date、meal_times。完全相同重复请求返回相同 file_id/record_id/event_id 和首次 uploaded_at，不新建上传或识别任务。相同 request_id 配不同内容报 IDEMPOTENCY_CONFLICT；并发未完成报 REQUEST_IN_PROGRESS，retryable=true，建议间隔后用同一ID重试。不要只比较客户端声明的哈希。\n\n身份映射遵循 ai_api user_id → AI staff → 餐厅staff.ai_user_id；确认企业/餐厅归属，零条或多条匹配均拒绝，不按 find() 第一条冒配。AI outbox只传可信person身份及其源事件，不提前选择某一个部门。store按当前授权数据解析策略：个人明确覆盖优先；否则对每个有效所属部门分别沿祖先路径解析，最终模式一致则采用；模式不一致时org_resolution=conflict、recognition_status=policy_conflict，原图仍保留，后台可设个人明确配置解决。pending仅表示尚未解析，不能把所有多部门都自动判冲突。不得随机选或把“最新关联部门”当主部门。\n\n## 5. 响应格式\n\n沿用 `renderSuccess`：HTTP 200，顶层 `status=200`，不是 code=0。`renderError` 默认业务status=500；身份异常沿用401/403。业务码与HTTP状态分开；网关可能返回真正HTTP 413/429/5xx，调用方不得一概当JSON成功。\n\n- 旧上传成功：原 `data.fileInfo`，不强行新增 photoRecord，也不删改任何已有字段。\n- 饮食上传成功：原 `data.fileInfo` + `data.photoRecord`；recognition_status=queued仅表示等待，绝不等同识别成功。\n- 策略冲突：个人无明确覆盖且多部门最终模式不同；原图已持久化，仍为上传成功，recognition_status=policy_conflict、org_resolution=conflict。初次上传尚未完成store解析时org_resolution=pending，不能由ai_api冒报已经选定组织。\n- 相同请求重放：返回首次记录ID与uploaded_at、replayed=true；dispatch_status/recognition_status可为当前状态。\n- 本地事务失败：status=500、data.error_code=PERSISTENCE_FAILED、retryable=true；同ID重试。\n\n```json\n{\"status\":200,\"message\":\"文件上传成功\",\"data\":{\"fileInfo\":{\"file_id\":123,\"preview_url\":\"https://example.invalid/diet/sample.jpg\",\"external_url\":\"https://example.invalid/diet/sample.jpg\"},\"photoRecord\":{\"record_id\":\"dpr_example_001\",\"event_id\":\"dpe_example_001\",\"request_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"uploaded_at\":\"2026-09-11T07:42:00+08:00\",\"meal_date\":\"2026-09-11\",\"meal_times\":1,\"meal_time\":null,\"meal_kind\":\"unassigned\",\"dispatch_status\":\"pending\",\"recognition_status\":\"queued\",\"org_resolution\":\"resolved\",\"replayed\":false}},\"trace_id\":\"trace_example_upload\"}\n```\n\n## 6. 状态码与调用方处理\n\n| 顶层status / data.error_code | 调用方处理 |\n|---|---|\n| 200 | fileInfo照旧消费；有photoRecord表示原图已可靠登记，store投递与AI状态另行跟踪 |\n| 401 / AUTH_REQUIRED | 登录完成后同一用户可用原ID重试；换账号必须新ID |\n| 403 / TENANT_FORBIDDEN | 不重试到其他租户，不让客户端指定人员解决 |\n| 500 / PHOTO_CONTEXT_INVALID | 修正参数，不自动降级旧上传 |\n| 500 / STAFF_MAPPING_UNRESOLVED | 后台修复映射后重试，照片不归到其他人 |\n| 500 / FILE_INVALID | 重新选择合法图片，使用新ID |\n| 500 / IDEMPOTENCY_CONFLICT | 检查误复用ID；不要在后台自动以新ID重复提交未知成功请求 |\n| 500 / REQUEST_IN_PROGRESS | 按retry_after_seconds用同ID退避重试，避免并发重复上传 |\n| 500 / PERSISTENCE_FAILED | 同ID退避重试；后台恢复outbox或清理孤立对象 |\n\n新增 error_code 是待评审的 data 子字段，未改造全局status表。不要为了使用HTTP409/422而改变全项目响应约定。消息不包含数据库连接串、token、照片原始正文或供应商敏感响应。\n\n## 7. 响应字段与保存/统计一致性\n\nfileInfo 是已有 UploadFile::toArray() 返回结构：已证实 file_id、preview_url、external_url；隐藏字段不重新暴露。OpenAPI保留 additionalProperties=true，不能凭有限源码字段列表删掉未知既有字段。\n\nphotoRecord新增字段：record_id为服务端不透明记录标识；event_id为可靠投递事件标识；request_id供客户端保存与重试；uploaded_at为首次服务端接收完成时间（带时区），不可人工改；meal_date/meal_times保持原输入；meal_time始终允许空；meal_kind=unassigned；dispatch_status标注pending/delivered；recognition_status与store状态对齐，包括pending/queued/running/completed/partial/not_in_library/failed/policy_conflict；org_resolution为resolved/pending/conflict；replayed标记同请求重放。\n\n**去重与统计启用依赖（必须在真实上线前关闭）：**\n\n1. 上传记录是原图审计事实，默认 `projection_state=unprojected`，不会仅因登记就再次累加旧 `date_menu`。store原图展示与饮食合计的投影分开。\n2. 现有 identify.handleSave 只传菜品，无法关联photo_record，不能用人员+日期+相似菜名去猜。新拍照链路需在保持页面不变的前提下透传服务端record_id给保存动作，保存服务校验所有权与内容版本；保存动作的具体增量契约见下方第二个接口章节，须与上传契约一起评审。\n3. 单次上传仅有一个有效营养版本，投影以record_id+target+version幂等；人工改、保存、重识别采用明确版本替换/差额，而非重复增加。先串行完成AI保存和store跨库投影；旧identify点击保存不能再次走无关联的addZhctMeal累加。\n4. 旧调用不带关联ID仍走原行为，但新分支必须始终携带关联；灰度启用条件是上传、保存接续、store投影去重三者齐备。识别先完成还是用户先保存、重复保存、人工修订后的迟到AI响应，都必须用事务/乐观版本保护。\n5. 错误或待识别记录仅列原图，不虚构营养、不默认为0；已有餐单营养和新上传不得简单两表相加。AI失败不删除原图；人工结果优先，重识别产生候选。\n\n## 8. trace_id、验证与待评审项\n\n串联 `trace_id → tenant内request_id → record_id → event_id → recognition_attempt_id → projection_version`；重试可产生新trace_id，但request_id/record_id/event_id不变。日志只写脱敏ID、状态、耗时、错误分类，不记Access-Token、任意请求头、原图或可长期访问的签名URL。\n\n最低契约用例：旧调用响应不变；缺photo_context不触发识别；空/非法上下文拒绝；饮食分支即使flag缺失仍鉴权；越租户拒绝；映射多条不猜；多部门同模式可识别、异模式policy_conflict且保留原图；同请求重放与并发只一事件；同ID换图冲突；对象写入成功而DB失败可恢复；worker至少一次投递去重；AI失败PC原图仍可见；保存前后顺序与重复点击不双计；人工值不被迟到AI覆盖。\n\n待评审/验证：Apifox401需恢复后先备份并核对旧参数再导入；确认多部门逐路径一致解析、个人覆盖优先及后台权限；审核下方保存/投影契约并核验服务端结果回读接续；核验对象存储验证器与幂等保留时长；核验worker部署、告警、死信恢复和照片登记延迟SLO。该上传草案本身不是完整业务DoR通过证明。\n",
        "parameters": [
          {
            "name": "Access-Token",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "photo_context存在时必填且服务端鉴权；旧调用规则不变，不能把可选理解为饮食匿名可用"
          },
          {
            "name": "enterprise-no",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "现有租户路由头，不是授权证明，需验证用户企业权限"
          },
          {
            "name": "platform",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "example": "MP-WEIXIN"
            },
            "description": "沿用现有客户端平台"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "additionalProperties": true,
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "沿用现有图像校验；饮食上限建议10MiB，需验证实际存储验证器"
                  },
                  "photo_context": {
                    "type": "string",
                    "description": "可选UTF-8 JSON字符串；解析后必须满足PhotoContext。存在但为空/非法时拒绝；不是嵌套form字段。",
                    "x-parsed-schema": {
                      "$ref": "#/components/schemas/PhotoContext"
                    },
                    "example": "{\"meal_date\": \"2026-09-11\", \"meal_times\": 1, \"request_id\": \"550e8400-e29b-41d4-a716-446655440000\"}"
                  }
                }
              },
              "encoding": {
                "photo_context": {
                  "contentType": "text/plain; charset=utf-8"
                }
              },
              "examples": {
                "diet_photo": {
                  "value": {
                    "file": "<binary image>",
                    "photo_context": "{\"meal_date\": \"2026-09-11\", \"meal_times\": 1, \"request_id\": \"550e8400-e29b-41d4-a716-446655440000\"}"
                  }
                },
                "legacy": {
                  "value": {
                    "file": "<binary image>"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "沿用JSON envelope；业务成功/失败均可能为HTTP200，请读status而不是HTTP或code",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/UploadSuccess"
                    },
                    {
                      "$ref": "#/components/schemas/UploadError"
                    }
                  ]
                },
                "examples": {
                  "legacy_success": {
                    "summary": "旧调用成功，原fileInfo不改",
                    "value": {
                      "status": 200,
                      "message": "文件上传成功",
                      "data": {
                        "fileInfo": {
                          "file_id": 123,
                          "preview_url": "https://example.invalid/diet/sample.jpg",
                          "external_url": "https://example.invalid/diet/sample.jpg"
                        }
                      },
                      "trace_id": "trace_example_upload"
                    }
                  },
                  "photo_queued": {
                    "summary": "新饮食记录和outbox已提交，识别等待",
                    "value": {
                      "status": 200,
                      "message": "文件上传成功",
                      "data": {
                        "fileInfo": {
                          "file_id": 123,
                          "preview_url": "https://example.invalid/diet/sample.jpg",
                          "external_url": "https://example.invalid/diet/sample.jpg"
                        },
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "event_id": "dpe_example_001",
                          "request_id": "550e8400-e29b-41d4-a716-446655440000",
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_time": null,
                          "dispatch_status": "pending",
                          "recognition_status": "queued",
                          "org_resolution": "resolved",
                          "replayed": false,
                          "meal_kind": "unassigned"
                        }
                      },
                      "trace_id": "trace_example_upload"
                    }
                  },
                  "idempotent_replay": {
                    "value": {
                      "status": 200,
                      "message": "文件上传成功",
                      "data": {
                        "fileInfo": {
                          "file_id": 123,
                          "preview_url": "https://example.invalid/diet/sample.jpg",
                          "external_url": "https://example.invalid/diet/sample.jpg"
                        },
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "event_id": "dpe_example_001",
                          "request_id": "550e8400-e29b-41d4-a716-446655440000",
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_time": null,
                          "dispatch_status": "pending",
                          "recognition_status": "queued",
                          "org_resolution": "resolved",
                          "replayed": true,
                          "meal_kind": "unassigned"
                        }
                      },
                      "trace_id": "trace_example_upload"
                    }
                  },
                  "auth_required": {
                    "value": {
                      "status": 401,
                      "message": "请先登录",
                      "data": {
                        "error_code": "AUTH_REQUIRED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "tenant_forbidden": {
                    "value": {
                      "status": 403,
                      "message": "无权访问该企业",
                      "data": {
                        "error_code": "TENANT_FORBIDDEN",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_context_invalid": {
                    "value": {
                      "status": 500,
                      "message": "饮食上传未完成",
                      "data": {
                        "error_code": "PHOTO_CONTEXT_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "staff_mapping_unresolved": {
                    "value": {
                      "status": 500,
                      "message": "饮食上传未完成",
                      "data": {
                        "error_code": "STAFF_MAPPING_UNRESOLVED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "file_invalid": {
                    "value": {
                      "status": 500,
                      "message": "饮食上传未完成",
                      "data": {
                        "error_code": "FILE_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "idempotency_conflict": {
                    "value": {
                      "status": 500,
                      "message": "请求ID已用于不同内容",
                      "data": {
                        "error_code": "IDEMPOTENCY_CONFLICT",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "request_in_progress": {
                    "value": {
                      "status": 500,
                      "message": "上传正在处理中",
                      "data": {
                        "error_code": "REQUEST_IN_PROGRESS",
                        "retryable": true,
                        "retry_after_seconds": 2
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "persistence_failed": {
                    "value": {
                      "status": 500,
                      "message": "饮食上传未完成",
                      "data": {
                        "error_code": "PERSISTENCE_FAILED",
                        "retryable": true
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "policy_conflict": {
                    "value": {
                      "status": 200,
                      "message": "文件上传成功",
                      "data": {
                        "fileInfo": {
                          "file_id": 123,
                          "preview_url": "https://example.invalid/diet/sample.jpg",
                          "external_url": "https://example.invalid/diet/sample.jpg"
                        },
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "event_id": "dpe_example_001",
                          "request_id": "550e8400-e29b-41d4-a716-446655440000",
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_time": null,
                          "dispatch_status": "pending",
                          "recognition_status": "policy_conflict",
                          "org_resolution": "conflict",
                          "replayed": false,
                          "meal_kind": "unassigned"
                        }
                      },
                      "trace_id": "trace_example_upload"
                    }
                  }
                }
              }
            }
          },
          "default": {
            "description": "网关/网络异常可能非JSON（413/429/5xx等）；不得误判成功。恢复前确认同request_id状态并退避重试。"
          }
        }
      }
    },
    "/meal.meal/add": {
      "post": {
        "operationId": "mealAddPhotoAssociationDraft",
        "summary": "就餐添加（拍照记录关联与幂等保存，待评审）",
        "tags": [
          "就餐管理"
        ],
        "description": "# 第二个接口：拍照识别结果关联保存（待评审）\n\n## 1. 接口概述\n\n为已有 `meal.meal/add` 增加可选的拍照记录关联字段。原来的无关联调用保留已有累计保存行为；新拍照链路有明确record_id后，按“一个上传记录、一个有效版本、一次投影”保存，不再调用旧 addZhctMeal 进行无记录ID累加。页面布局、原按钮及操作保持不变，客户端仅接续标识与版本。\n\n选用最小字段 `photo_record_id + expected_revision + save_request_id`。不让客户端重复提交source_event_id：它已由服务端photo_record绑定，新增客户端副本会引入可伪造的矛盾来源。每个菜项新增 `source_item_id` 对应服务端记录中的已识别候选条目；保留现有dishes_id/weight/name等字段外形。\n\n## 2. 接口信息\n\n| 项目 | 契约 |\n|---|---|\n| Method / Path | POST /meal.meal/add |\n| URL核验 | ai_app/api/meal/index.js 明确 mealAdd='meal.meal/add'；utils/request/core/utils.js 仅 baseUrl+url，不把点转换为斜杠。不得误登记成 /meal/meal/add |\n| Content-Type | application/json；实际请求仍为 {form:{...}} |\n| 现有控制器 | ai_api/app/api/controller/meal/Meal.php:52 接受postForm，成功renderSuccess('添加成功') |\n| 现有校验 | app/api/validate/Meal.php 要求staff_id、dishes；staff_id由服务端补入。新关联分支增加独立严格校验，不全局收紧旧调用 |\n| 可见结果 | canonical记录与PC有效营养投影在store同库事务提交后才返回保存成功；AI侧餐单投影以同事务outbox异步更新 |\n\n新的关联分支先解析受信tenant与登录user，校验源上传登记的owner，再通过既有受控mysql_zhct连接访问canonical记录。store记录尚未投递时返回PHOTO_NOT_READY（可重试），不自动退回旧累加路径。记录修订、菜项、PC聚合投影、AI投影outbox和保存幂等回执在同一store事务提交。不能先落AI meal再写store，否则跨库失败会留下不可判定双计。\n\n部署前需核验现有连接的事务能力与表引擎；跨库执行失败不当作事务回滚保证。AI投影worker带record_id/source revision处理：为上传单独记录投影明细，重算该人员当天餐次合计；仅更新该record贡献，不能重写整餐其他旧记录。收到旧revision跳过，重复revision回相同结果。DB提交后HTTP响应丢失可通过save_request_id重放取得首次receipt。\n\n## 3. 请求头（Header）\n\nAccess-Token必需，延用当前meal保存鉴权。enterprise-no/platform/version/env-version沿用已有路由/客户端规则，不增加可被客户端信任的owner、store_id或staff_uuid。新分支中即使form出现旧透传staff_id也必须忽略其身份声明并只采用已认证映射；与绑定owner不符时拒绝，不把参数拿来切换人员。\n\n## 4. 请求参数\n\n无新增Path/Query。Body中保留现有form容器和未知既有字段（additionalProperties=true），不能用本增量覆盖完整旧Apifox定义。\n\n| 字段（form内） | 旧调用 | 新拍照关联分支 |\n|---|---|---|\n| meal_date | 保留既有行为 | 必填有效日期，必须等于record当前进餐日期；不同报上下文冲突，不偷偷移动记录 |\n| meal_times | 保留既有行为 | 必填1/2/3/4，必须等于record当前餐次；4仍为夜宵 |\n| dishes | 保留现有数组 | 至少1条；全删记录沿用独立删除流程，不用空数组触发默默清餐 |\n| dishes[].dishes_id | 保留既有行为 | 库内菜应为当前候选映射的有效ID；库外未映射可为0，但必须有合法source_item_id，不能伪造不存在的库ID |\n| dishes[].dishes_weight | 保留既有行为 | 必填非负有限数（g），0是明确0，不能当缺失；超限按已评审业务值域校验 |\n| dishes[].dishes_name | 保留既有字段 | 必填且与服务端候选菜名一致；后台更名采用后台编辑契约，客户端不信任任意菜名替换营养 |\n| dishes[].dishes_number | 保留既有字段 | 若提供需验证与现有单位模型一致；不以它绕过重量或配方换算 |\n| dishes[].source_item_id | 不要求 | 必填，属于该record当前可保存候选；一条只出现一次；其他记录条目拒绝 |\n| photo_record_id | 省略 | 必填非空服务端record_id；单个请求仅修改此上传记录 |\n| expected_revision | 省略 | 必填>=0整数，与canonical业务版本做CAS；来自服务器当前记录/识别结果回读，不能客户端臆造 |\n| save_request_id | 省略 | 必填UUID，一次保存意图生成，完全相同网络重试复用 |\n\n三项关联字段只要任意一项出现，就必须全部有效；缺任意项报PHOTO_SAVE_CONTEXT_INVALID，不能降级旧保存。source_item_id若出现也要求完整关联字段。旧字段/未知扩展只保留其既有语义；新增模式不得把未知字段直接mass assignment写入canonical记录。\n\n来源校验：record_id存在且归属当前租户/用户、源上传有已验证file_id、原上传事件来源可信，候选项属于当前record并有可追溯attempt_id/ingredient映射。无任何合法record也不能仅凭dishes_id生成拍照记录。原图、uploaded_at、AI原响应不可由保存请求改变。\n\n幂等键：tenant+owner+photo_record_id+save_request_id；规范化请求指纹包含expected_revision、日期、餐次、有序source_item_id/重量/菜品字段。完全相同成功重放先读回执，不因当前业务revision已变化而判冲突，不重算、不重复投影。相同ID不同有效内容报IDEMPOTENCY_CONFLICT。未见过的保存命令再做expected_revision CAS，冲突报REVISION_CONFLICT；不能自动改成新版本重试覆盖后台修订。\n\n## 5. 响应格式\n\n旧调用原样：HTTP200，status=200，message='添加成功'，data=[]，trace_id。该形状由renderSuccess字符串重载证实，不能强制旧data变成对象。\n\n新关联成功：同顶层封装；data新增photoSave回执，含record_id、save_request_id、saved_revision、current_revision、replayed、projection_status。PC有效记录与合计已经提交；projection_status=ai_pending只指AI餐单异步投影，不能假称双端都已同步。\n\n版本冲突：status=500，data.error_code=REVISION_CONFLICT、current_revision、retryable=false。客户端保留当前编辑并重新读取服务端当前版本，提示记录已变更，不静默覆盖。用户已手工修订后台结果时，新小程序保存报MANUAL_RESULT_PROTECTED，即使expected_revision恰好匹配也不能自行替换人工值。\n\n识别尚未完成/组织未明确：PHOTO_NOT_READY、可重试或待配置；不允许把客户端直连工作流的未验证结果当服务端候选提交。库外菜已有食材映射可保留为web菜项，不强行入全局菜库；映射不完整仍保存对应菜项、显示partial，缺失营养为null。若legacy AI餐单表无法表示这类菜项，AI投影为blocked_mapping，不把不存在ID发送旧createData滤掉，PC记录和营养审阅继续保留。\n\n## 6. 状态码与调用方处理\n\n| status / error_code | 行为 |\n|---|---|\n| 200 / 无 | 已提交当前记录与PC投影，按回执查看AI投影状态 |\n| 401 / AUTH_REQUIRED | 重新登录，再确认同一owner后用原save_request_id重放 |\n| 403 / PHOTO_ACCESS_DENIED | 记录不属于调用人/租户或不可见，统一响应避免泄露别人记录是否存在 |\n| 500 / PHOTO_SAVE_CONTEXT_INVALID | 修正关联参数，不降级成旧保存 |\n| 500 / PHOTO_NOT_READY | 源事件未消费/结果不可保存；按具体reason处理，原图仍保留 |\n| 500 / PHOTO_MEAL_CONTEXT_CONFLICT | 后台已改日期/餐次，回读后人工决定，不能按旧表单挪回 |\n| 500 / SOURCE_ITEM_INVALID | 伪造、跨记录、重复或过期条目，不落库 |\n| 500 / DISH_MAPPING_INVALID | dishes_id与来源映射矛盾；库外应为0并有合法source_item_id |\n| 500 / IDEMPOTENCY_CONFLICT | 同一命令ID配不同内容，不能自动换ID掩盖未知成功请求 |\n| 500 / REVISION_CONFLICT | 返回当前版本；禁止自动CAS重试覆盖 |\n| 500 / MANUAL_RESULT_PROTECTED | 保留后台人工有效值，小程序保存不得覆盖 |\n| 500 / PERSISTENCE_FAILED | 本次未提交，按原命令ID重试；超时未知时优先同ID查询回执 |\n\n全局HTTP与顶层业务status约定不改；新error_code仅放data，不擅加全项目409/422业务码。\n\n## 7. 响应字段与并发投影规则\n\n| photoSave字段 | 说明 |\n|---|---|\n| record_id / save_request_id | 对应本上传与保存命令 |\n| saved_revision | 该命令首次提交的业务版本；重放不改变 |\n| current_revision | 返回时最新版本，可能比saved_revision大；调用方不得把旧回执误当当前有效数据 |\n| replayed | 已完成命令重复请求为true |\n| projection_status | ai_pending / synchronized / blocked_mapping，均为当前可观察投影状态，不等同AI识别状态 |\n| nutrient_status | complete / partial，partial下缺失指标null，不能补0 |\n\n一个record只有一份有效贡献，新保存使用更新/替换，不使用菜品ID为主键去覆盖其他上传。投影唯一键为record_id+target，保存最新applied_revision与各自源条目贡献；applied_revision相同或更新则安全忽略旧事件。canonical修订和projection outbox同事务；投影消费和target applied_revision同事务。全局统计读有效投影，不把原date_menu与新贡献重复加总。\n\n迟到识别保护：attempt启动时带base_revision；完成时若record已人工修订或base_revision不匹配，只写候选、不能修改effective_revision。新的人工采用动作在后台显式完成；小程序保存接口不能自动“采用新AI”来推翻人工数据。\n\n## 8. trace_id、可复现场景与评审依赖\n\n排查链路增加save_request_id、saved_revision、projection_event_id；日志不输出原图、认证头、完整人员数据。\n\n| 用例 | 前置与请求步骤 | 可检查结果 |\n|---|---|---|\n| 保存超时重放 | record R版本2；S1(expected=2)成功但模拟响应丢失；再次提交完全相同S1 | 两次同saved_revision=3；第二次replayed=true；营养只一份；投影outbox仅一条该版本 |\n| 重放后已有人工修改 | S1保存成3；后台人工改成4；再重放S1 | 返回saved_revision=3/current_revision=4；人工值不变；不新增投影 |\n| 同ID不同内容 | S1保存100g；同S1改成120g | IDEMPOTENCY_CONFLICT；重量仍100g，无新修订 |\n| 并发新命令 | S1和S2都expected=2，分别100g/120g | 仅一个提交为3；另一个REVISION_CONFLICT；不能两次累加 |\n| 迟到AI与人工值 | attempt A从2启动；后台修成3；A回调 | A仅候选；有效版本3/人工值不动；小程序保存expected=2冲突 |\n| 已有人工作为当前值 | record人工有效版本3；新命令expected=3 | MANUAL_RESULT_PROTECTED；不允许客户端覆盖 |\n| 库外无库ID | 服务端合法web候选item W无dishes_id但有成分映射；提交id=0/source_item_id=W | 保留原菜、食材和成分，nutrient_status按映射完整度；不被旧createData过滤；不伪造全局库菜 |\n| 部分映射 | W缺镁映射；保存W | 镁null，nutrient_status=partial；若AI旧表无法容纳则blocked_mapping，不能假称synchronized |\n| 伪造候选或人员 | 以R1提交R2的item，或另一租户record | SOURCE_ITEM_INVALID或PHOTO_ACCESS_DENIED；原记录不变 |\n| 跨库故障 | store事务提交3后AI库宕机；恢复worker并重复投递 | PC版本3始终可见；AI最终只一份3贡献；outbox可重试有证据 |\n| 后台先改餐次 | R由早餐改午餐；旧早餐保存 | PHOTO_MEAL_CONTEXT_CONFLICT；不改回早餐 |\n| 旧调用回归 | 不带任何新字段调用原meal.meal/add | 保留原form/dishes字段处理与data=[]成功外形 |\n\n待评审细节：canonical revision/source_item_id/候选结果回读契约已补于下方第三接口；客户端直接RunAgent结果不满足来源契约。上传、回读、关联保存三步草案已闭合，仍需实现与验证，不能仅凭文档声称完整业务已落地。AI旧meal表的投影关联字段/库外菜表示、表结构迁移、消费任务部署需在实现前冻结。没有数据库/供应商/投影实测前不能声称端到端完成。\n",
        "parameters": [
          {
            "name": "Access-Token",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "沿用登录认证；服务端验证record所有权"
          },
          {
            "name": "enterprise-no",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "租户路由需与已认证用户授权一致"
          },
          {
            "name": "platform",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "example": "MP-WEIXIN"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "form"
                ],
                "additionalProperties": true,
                "properties": {
                  "form": {
                    "$ref": "#/components/schemas/MealSaveForm"
                  }
                }
              },
              "examples": {
                "legacy": {
                  "value": {
                    "form": {
                      "meal_date": "2026-09-11",
                      "meal_times": 1,
                      "dishes": [
                        {
                          "dishes_id": 100,
                          "dishes_weight": 100,
                          "dishes_name": "样例菜"
                        }
                      ]
                    }
                  }
                },
                "photo_save": {
                  "value": {
                    "form": {
                      "meal_date": "2026-09-11",
                      "meal_times": 1,
                      "dishes": [
                        {
                          "dishes_id": 100,
                          "dishes_weight": 100,
                          "dishes_name": "样例菜",
                          "source_item_id": "item_example_001"
                        }
                      ],
                      "photo_record_id": "dpr_example_001",
                      "expected_revision": 2,
                      "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf"
                    }
                  }
                },
                "web_without_library_id": {
                  "value": {
                    "form": {
                      "meal_date": "2026-09-11",
                      "meal_times": 1,
                      "dishes": [
                        {
                          "dishes_id": 0,
                          "dishes_weight": 100,
                          "dishes_name": "样例库外菜",
                          "source_item_id": "web_item_001"
                        }
                      ],
                      "photo_record_id": "dpr_example_001",
                      "expected_revision": 2,
                      "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "沿用HTTP200+顶层status业务判断；仅关联分支增加data.photoSave，旧data=[]不变",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/MealSaveSuccess"
                    },
                    {
                      "$ref": "#/components/schemas/MealSaveError"
                    }
                  ]
                },
                "examples": {
                  "legacy_saved": {
                    "value": {
                      "status": 200,
                      "message": "添加成功",
                      "data": [],
                      "trace_id": "trace_legacy"
                    }
                  },
                  "saved_ai_projection_pending": {
                    "value": {
                      "status": 200,
                      "message": "添加成功",
                      "data": {
                        "photoSave": {
                          "record_id": "dpr_example_001",
                          "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf",
                          "saved_revision": 3,
                          "current_revision": 3,
                          "replayed": false,
                          "projection_status": "ai_pending",
                          "nutrient_status": "complete"
                        }
                      },
                      "trace_id": "trace_example_save"
                    }
                  },
                  "save_replay": {
                    "value": {
                      "status": 200,
                      "message": "添加成功",
                      "data": {
                        "photoSave": {
                          "record_id": "dpr_example_001",
                          "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf",
                          "saved_revision": 3,
                          "current_revision": 3,
                          "replayed": true,
                          "projection_status": "ai_pending",
                          "nutrient_status": "complete"
                        }
                      },
                      "trace_id": "trace_example_save"
                    }
                  },
                  "save_replay_after_manual": {
                    "value": {
                      "status": 200,
                      "message": "添加成功",
                      "data": {
                        "photoSave": {
                          "record_id": "dpr_example_001",
                          "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf",
                          "saved_revision": 3,
                          "current_revision": 4,
                          "replayed": true,
                          "projection_status": "ai_pending",
                          "nutrient_status": "complete"
                        }
                      },
                      "trace_id": "trace_example_save"
                    }
                  },
                  "web_partial_mapping": {
                    "value": {
                      "status": 200,
                      "message": "添加成功",
                      "data": {
                        "photoSave": {
                          "record_id": "dpr_example_001",
                          "save_request_id": "e4837612-1a4c-4ed6-9ea0-f9b4482d32cf",
                          "saved_revision": 3,
                          "current_revision": 3,
                          "replayed": false,
                          "projection_status": "blocked_mapping",
                          "nutrient_status": "partial"
                        }
                      },
                      "trace_id": "trace_example_save"
                    }
                  },
                  "auth_required": {
                    "value": {
                      "status": 401,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "AUTH_REQUIRED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_access_denied": {
                    "value": {
                      "status": 403,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "PHOTO_ACCESS_DENIED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_save_context_invalid": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "PHOTO_SAVE_CONTEXT_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_not_ready": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "PHOTO_NOT_READY",
                        "retryable": true
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_meal_context_conflict": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "PHOTO_MEAL_CONTEXT_CONFLICT",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "source_item_invalid": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "SOURCE_ITEM_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "dish_mapping_invalid": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "DISH_MAPPING_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "idempotency_conflict": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "IDEMPOTENCY_CONFLICT",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "revision_conflict": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "REVISION_CONFLICT",
                        "retryable": false,
                        "current_revision": 4
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "manual_result_protected": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "MANUAL_RESULT_PROTECTED",
                        "retryable": false,
                        "current_revision": 4
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "persistence_failed": {
                    "value": {
                      "status": 500,
                      "message": "保存未完成，请按错误分类处理",
                      "data": {
                        "error_code": "PERSISTENCE_FAILED",
                        "retryable": true
                      },
                      "trace_id": "trace_example_error"
                    }
                  }
                }
              }
            }
          },
          "default": {
            "description": "网关/网络异常；响应未知时同save_request_id重放，不自动创建新保存意图"
          }
        }
      }
    },
    "/meal.photo/info": {
      "post": {
        "operationId": "mealPhotoInfoDraft",
        "summary": "本人饮食照片识别回读（建议新增，待评审）",
        "tags": [
          "就餐管理"
        ],
        "description": "# 第三个接口：本人饮食照片识别结果回读（建议新增，待评审）\n\n## 1. 接口概述\n\n建议新增只读 `POST /meal.photo/info`，归属ai_api的 `api/controller/meal/Photo::info`，遵循现有点号子控制器URL规范。小程序继续使用原identify页面：上传后保存photoRecord.record_id，在原加载流程调用本接口，使用服务端返回的当前结果填充原goodList；保存时携带record_id、revision、source_item_id接续 `/meal.meal/add`。\n\n新饮食分支不再以客户端直连RunAgent返回值作为可信结果。旧未带photo_context的调用保持原链路。此改变仅限调用层与字段透传，复用当前加载、食物列表、重量输入、保存、失败提示和返回操作，不新增页面或PC后台操作入口。PC的token、管理员会话及后台权限不得传给手机。\n\n## 2. 接口信息\n\n| 项目 | 草案定义 |\n|---|---|\n| 方法/路径 | POST /meal.photo/info；建议新增，尚未存在/实现 |\n| Content-Type | application/json |\n| 身份 | ai_api Access-Token，限定本人；后台人员查看使用独立PC权限接口 |\n| 数据来源 | 本地受信上传记录/outbox只用于owner验证和投递状态；结果、修订、候选从store canonical读取 |\n| 副作用 | 无；不创建识别、重试任务、投影或修订，不因查询补餐单 |\n| 缓存 | 私有且不共享；建议 Cache-Control:no-store，不把个人结果放公共CDN |\n\n步骤：验证当前tenant+user → 查该owner绑定源记录 → 取得其store canonical标识 → 在受控服务/数据库连接按同一tenant+owner核验读取 → 输出限定字段DTO。源事件尚未投递时返回pending，revision=null/current_result=null；不存在与他人/跨租户记录统一PHOTO_ACCESS_DENIED，避免探测记录。store临时不可用返回可重试错误，不能用本地旧缓存伪装最新版本。\n\n## 3. 请求头（Header）\n\nAccess-Token必填，遵循现有小程序登录。enterprise-no沿用环境租户路由，必须与token授权一致。platform、version、env-version沿用既有请求规范。不接受PC token，不接受任意人员、组织、tenant或外部URL入参来扩大读取范围。\n\n## 4. 请求参数\n\n| 位置/名称 | 类型 | 约束 |\n|---|---|---|\n| Body.photo_record_id | string | 必填，非空服务端返回的不透明ID，最长64字符 |\n\n无Path/Query参数，不使用form容器（这是新查询接口，采用已有查询类参数平铺风格）。additionalProperties=false；不接受staff_uuid、user_id、source_event_id、department_uuid。即使知道他人ID也不能越权。重复查询无副作用，网络重试复用record_id即可。\n\n## 5. 响应格式\n\n沿用ai_api `{status,message,data,trace_id}`，HTTP200内status=200表示查询成功，recognition_status才表示识别状态。data.photoRecord为本次一致读取的DTO。\n\n- 投递中：dispatch_status=pending，recognition_status=pending，revision=null，current_result=null，candidates=[]，can_save=false，poll_after_seconds建议2。\n- 已登记/识别中：delivered，revision可为0，recognition_status=queued/running；没有有效结果则current_result=null、can_save=false。\n- 完成/部分完成：返回store当前revision、current_result.items；完整为completed，部分映射为partial，缺失成分null。可保存条件还必须满足当前来源非后台人工、至少1项可验证条目、日期餐次有效及所有权有效。\n- 失败/未命中/策略冲突：分别failed/not_in_library/policy_conflict；原图登记仍有效。若存在先前有效结果，仍返回该结果，不能用空数组清除；can_save依当前有效结果和manual保护计算，不简单依识别状态判断。\n- 后台人工有效结果：current_result.origin=manual、can_save=false；已有原型没有手机“采用候选”动作，不增加该操作。迟到AI仅出现在candidates，不得覆写current_result或用于手机提交。\n\n## 6. 状态码与调用方处理\n\n| status / data.error_code | 处理 |\n|---|---|\n| 200 | 消费明确状态；pending/queued/running可按poll_after_seconds退避查询，完成/失败/冲突停止持续轮询 |\n| 401 / AUTH_REQUIRED | 重新登录后以当前本人身份查询，不复用其他账号记录 |\n| 403 / PHOTO_ACCESS_DENIED | 不存在、他人、跨租户统一文案；不输出owner/部门信息 |\n| 500 / PHOTO_QUERY_INVALID | 修正参数，不重试原非法请求 |\n| 500 / RECORD_TEMPORARILY_UNAVAILABLE | store或源存储暂不可用，retryable=true；保留屏幕原数据并提示暂不可用，不宣称当前最新 |\n| 500 / RATE_LIMITED | 按retry_after_seconds退避，不能每个组件重复建轮询 |\n\n网关真正HTTP429/5xx和网络超时均非查询成功。页面隐藏/卸载停止轮询；建议2、4、8秒退避上限10秒，单次前台最多60秒，超出使用原有提示结束等待，下次进入继续同ID读取。具体性能配额为待验证设计值。GET/POST查询均不得通过重试自动触发新识别。\n\n## 7. 响应字段与调用适配\n\n| photoRecord字段 | 说明 |\n|---|---|\n| record_id / source_event_id | 服务端记录和源事件；只作关联，不作权限证明 |\n| revision | canonical当前业务版本；投递前为null，不能拿0代替null |\n| meal_date / meal_times / meal_kind / meal_time | 当前餐次上下文；meal_kind沿用五项枚举，进餐时刻未知为null |\n| uploaded_at | 源上传首次完成时间，原始不可改 |\n| dispatch_status / recognition_status | 投递状态与识别状态分开；状态枚举与store一致 |\n| current_result | 当前有效结果，可空；包含origin（ai/user/manual）、nutrient_status、items，不返回供应商raw_result |\n| items[].source_item_id | 当前记录内稳定菜项标识，映射保存请求dishes[].source_item_id |\n| items[].dishes_id | 当前库映射ID；库外无ID为0，不编造 |\n| items[].name / weight_g / image_url | 原goodList的name/weight/img_url来源；图片为受控服务端生成的当前用户可访问短时URL或null，不接受客户端任意URL |\n| items[].nutrients | energy_kcal/protein_g/zinc_mg/magnesium_mg；数值非负或null，携带来源而不是补0 |\n| items[].source / ingredient_mapping_status | library/web；matched/partial/unmatched，供计算完整度 |\n| candidates | 结构化候选列表；attempt_id、started_revision、result；不返回密钥、prompt、raw_response或内部存储地址 |\n| can_save / save_block_reason | 服务端权威可保存判断；理由为null/no_effective_result/manual_result_protected/record_not_ready/context_invalid |\n| poll_after_seconds | 0表示无需自动轮询；>0为下次查询建议 |\n\n适配规则：goodList每行保留source_item_id；name←name，weight←weight_g，id←dishes_id，energy←nutrients.energy_kcal，img_url←image_url。能量未知显示“—”，不能为适配旧显示补0。expected_revision取同一响应revision；保存只提交当前有效条目及用户在原页面调整的重量，不把candidates拼进列表或覆盖人工结果。新分支原重量调整不能用老detailWithWeight对库外id=0查询；需要展示变化时以服务端结果的有效份量比例仅做临时显示估算，保存后由服务端从食材成分重算并回读；起始weight_g为0/缺失或成分不足时不猜，保留未知。\n\n本查询不额外新增返回全量食材编辑UI；后台已承担人工纠错。来源/成分明细保持store权威记录，候选返回最小结构化菜项。当前记录和候选必须来自同一快照，避免revision取新值而items取旧值；若无法保证一致读取则返回暂不可用。\n\n## 8. trace_id、验收与三步闭合\n\n链路为 upload.request_id → record_id/source_event_id → query.trace_id+revision/source_item_id → save_request_id/saved_revision。敏感凭证、原图、人员明细不进共享日志。\n\n| 场景 | 调用步骤 | 断言 |\n|---|---|---|\n| 正常三步 | 上传返回R；info(R)从pending到completed revision2含item I；add关联R/I/expected2/S1 | 保存revision3，PC和导出一份有效贡献；响应丢失重放S1不双计 |\n| source未消费 | upload本地事务成功后暂停worker；info(R) | pending、revision=null、can_save=false；原始上传未丢，不宣称AI完成 |\n| 识别失败 | worker已登记R但AI失败；info(R) | failed且上传时间可读；原图PC可见，空结果can_save=false |\n| 后台人工修改 | info得到revision2；PC改为3；手机提交expected2 | REVISION_CONFLICT；再info得到manual当前值且can_save=false；原UI提示，不新增候选采用按钮 |\n| 迟到候选 | 人工revision3后AI attempt基于2返回；info(R) | current_result仍人工，candidates含新候选；revision和有效值不被覆盖 |\n| 库外 | info返回web item W/dishes_id0/成分partial；原页面改重量后关联保存 | 不调用旧id0库详情，不丢W、不补0；服务端重算后回读 |\n| 越权/探测 | 另一用户或租户查询R、随机不存在R | 一致403 PHOTO_ACCESS_DENIED，不返回姓名、source_event或区别提示 |\n| 查询无副作用 | 对同R调用info10次 | attempt/outbox/revision/合计数量不变；只有读审计/限流计数允许变化 |\n| 一致快照 | 后台并发修订时持续查询 | 每次revision与items内容一致；不得混版本 |\n\n仅完成这三个接口草案及调用适配规则，不扩展其他功能。未实现、未导入Apifox、未跑真实业务链路；仍需接口评审和实际事务/权限/幂等验证。\n",
        "parameters": [
          {
            "name": "Access-Token",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "ai_api本人登录凭证；不可传PC后台token"
          },
          {
            "name": "enterprise-no",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "受控租户路由，校验与本人授权一致"
          },
          {
            "name": "platform",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "example": "MP-WEIXIN"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "photo_record_id"
                ],
                "properties": {
                  "photo_record_id": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 64
                  }
                }
              },
              "example": {
                "photo_record_id": "dpr_example_001"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "HTTP200；status=200仅表示查询成功，recognition_status独立判断；只读无副作用",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/PhotoInfoSuccess"
                    },
                    {
                      "$ref": "#/components/schemas/PhotoInfoError"
                    }
                  ]
                },
                "examples": {
                  "dispatch_pending": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": null,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "pending",
                          "recognition_status": "pending",
                          "current_result": null,
                          "candidates": [],
                          "can_save": false,
                          "save_block_reason": "record_not_ready",
                          "poll_after_seconds": 2
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "ready_to_save": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": 2,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "delivered",
                          "recognition_status": "completed",
                          "current_result": {
                            "origin": "ai",
                            "nutrient_status": "complete",
                            "items": [
                              {
                                "source_item_id": "item_example_001",
                                "dishes_id": 100,
                                "name": "样例菜",
                                "weight_g": 100,
                                "image_url": null,
                                "nutrients": {
                                  "energy_kcal": 80,
                                  "protein_g": 5,
                                  "zinc_mg": 0.5,
                                  "magnesium_mg": 20
                                },
                                "source": "library",
                                "ingredient_mapping_status": "matched"
                              }
                            ]
                          },
                          "candidates": [],
                          "can_save": true,
                          "save_block_reason": null,
                          "poll_after_seconds": 0
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "manual_with_late_candidate": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": 3,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "delivered",
                          "recognition_status": "completed",
                          "current_result": {
                            "origin": "manual",
                            "nutrient_status": "complete",
                            "items": [
                              {
                                "source_item_id": "item_example_001",
                                "dishes_id": 100,
                                "name": "样例菜",
                                "weight_g": 100,
                                "image_url": null,
                                "nutrients": {
                                  "energy_kcal": 80,
                                  "protein_g": 5,
                                  "zinc_mg": 0.5,
                                  "magnesium_mg": 20
                                },
                                "source": "library",
                                "ingredient_mapping_status": "matched"
                              }
                            ]
                          },
                          "candidates": [
                            {
                              "attempt_id": "attempt_late_example",
                              "started_revision": 2,
                              "result": {
                                "origin": "ai",
                                "nutrient_status": "complete",
                                "items": [
                                  {
                                    "source_item_id": "item_example_001",
                                    "dishes_id": 100,
                                    "name": "样例菜",
                                    "weight_g": 100,
                                    "image_url": null,
                                    "nutrients": {
                                      "energy_kcal": 80,
                                      "protein_g": 5,
                                      "zinc_mg": 0.5,
                                      "magnesium_mg": 20
                                    },
                                    "source": "library",
                                    "ingredient_mapping_status": "matched"
                                  }
                                ]
                              }
                            }
                          ],
                          "can_save": false,
                          "save_block_reason": "manual_result_protected",
                          "poll_after_seconds": 0
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "web_partial": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": 2,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "delivered",
                          "recognition_status": "partial",
                          "current_result": {
                            "origin": "ai",
                            "nutrient_status": "partial",
                            "items": [
                              {
                                "source_item_id": "web_item_001",
                                "dishes_id": 0,
                                "name": "样例库外菜",
                                "weight_g": 100,
                                "image_url": null,
                                "nutrients": {
                                  "energy_kcal": 80,
                                  "protein_g": 5,
                                  "zinc_mg": 0.5,
                                  "magnesium_mg": null
                                },
                                "source": "web",
                                "ingredient_mapping_status": "partial"
                              }
                            ]
                          },
                          "candidates": [],
                          "can_save": true,
                          "save_block_reason": null,
                          "poll_after_seconds": 0
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "recognition_failed": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": 0,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "delivered",
                          "recognition_status": "failed",
                          "current_result": null,
                          "candidates": [],
                          "can_save": false,
                          "save_block_reason": "no_effective_result",
                          "poll_after_seconds": 0
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "policy_conflict": {
                    "value": {
                      "status": 200,
                      "message": "查询成功",
                      "data": {
                        "photoRecord": {
                          "record_id": "dpr_example_001",
                          "source_event_id": "dpe_example_001",
                          "revision": 0,
                          "meal_date": "2026-09-11",
                          "meal_times": 1,
                          "meal_kind": "unassigned",
                          "meal_time": null,
                          "uploaded_at": "2026-09-11T07:42:00+08:00",
                          "dispatch_status": "delivered",
                          "recognition_status": "policy_conflict",
                          "current_result": null,
                          "candidates": [],
                          "can_save": false,
                          "save_block_reason": "record_not_ready",
                          "poll_after_seconds": 0
                        }
                      },
                      "trace_id": "trace_example_query"
                    }
                  },
                  "auth_required": {
                    "value": {
                      "status": 401,
                      "message": "查询未完成",
                      "data": {
                        "error_code": "AUTH_REQUIRED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_access_denied": {
                    "value": {
                      "status": 403,
                      "message": "记录不可访问",
                      "data": {
                        "error_code": "PHOTO_ACCESS_DENIED",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "photo_query_invalid": {
                    "value": {
                      "status": 500,
                      "message": "查询未完成",
                      "data": {
                        "error_code": "PHOTO_QUERY_INVALID",
                        "retryable": false
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "record_temporarily_unavailable": {
                    "value": {
                      "status": 500,
                      "message": "查询未完成",
                      "data": {
                        "error_code": "RECORD_TEMPORARILY_UNAVAILABLE",
                        "retryable": true
                      },
                      "trace_id": "trace_example_error"
                    }
                  },
                  "rate_limited": {
                    "value": {
                      "status": 500,
                      "message": "查询未完成",
                      "data": {
                        "error_code": "RATE_LIMITED",
                        "retryable": true,
                        "retry_after_seconds": 10
                      },
                      "trace_id": "trace_example_error"
                    }
                  }
                }
              }
            }
          },
          "default": {
            "description": "网络/网关失败不视为最新数据；按退避规则只读重试，不触发识别"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "PhotoContext": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "meal_date",
          "meal_times",
          "request_id"
        ],
        "properties": {
          "meal_date": {
            "type": "string",
            "format": "date",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "有效自然日；服务端进一步校验日期",
            "example": "2026-09-11"
          },
          "meal_times": {
            "type": "integer",
            "enum": [
              1,
              2,
              3,
              4
            ],
            "description": "1早餐及早加餐、2午餐及午加餐、3晚餐及晚加餐、4夜宵"
          },
          "request_id": {
            "type": "string",
            "format": "uuid",
            "description": "每次选择新照片生成；相同逻辑重试复用"
          }
        }
      },
      "FileInfo": {
        "type": "object",
        "description": "现有UploadFile返回，只声明代码已证实字段，不裁剪未知字段",
        "additionalProperties": true,
        "required": [
          "file_id",
          "preview_url"
        ],
        "properties": {
          "file_id": {
            "type": "integer",
            "minimum": 1
          },
          "preview_url": {
            "type": "string",
            "format": "uri"
          },
          "external_url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "PhotoRecord": {
        "type": "object",
        "required": [
          "record_id",
          "event_id",
          "request_id",
          "uploaded_at",
          "meal_date",
          "meal_times",
          "meal_time",
          "meal_kind",
          "dispatch_status",
          "recognition_status",
          "org_resolution",
          "replayed"
        ],
        "properties": {
          "record_id": {
            "type": "string",
            "description": "服务端记录ID；客户端不可指定归属",
            "example": "dpr_example_001"
          },
          "event_id": {
            "type": "string",
            "example": "dpe_example_001"
          },
          "request_id": {
            "type": "string",
            "format": "uuid"
          },
          "uploaded_at": {
            "type": "string",
            "format": "date-time",
            "description": "首次服务端上传完成时间；不可改写"
          },
          "meal_date": {
            "type": "string",
            "format": "date"
          },
          "meal_times": {
            "type": "integer",
            "enum": [
              1,
              2,
              3,
              4
            ]
          },
          "meal_time": {
            "type": "string",
            "nullable": true,
            "description": "未采集真实进餐时刻，不用上传时间补齐"
          },
          "dispatch_status": {
            "type": "string",
            "enum": [
              "pending",
              "delivered"
            ]
          },
          "recognition_status": {
            "type": "string",
            "enum": [
              "pending",
              "queued",
              "running",
              "completed",
              "partial",
              "not_in_library",
              "failed",
              "policy_conflict"
            ]
          },
          "org_resolution": {
            "type": "string",
            "enum": [
              "pending",
              "resolved",
              "conflict"
            ]
          },
          "replayed": {
            "type": "boolean"
          },
          "meal_kind": {
            "type": "string",
            "enum": [
              "regular",
              "extra",
              "snack",
              "night_snack",
              "unassigned"
            ],
            "description": "新上传默认unassigned；重放可返回后台修订后的当前类型。不得占用旧date_menu.meal_type字段"
          }
        },
        "additionalProperties": false
      },
      "UploadSuccess": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              200
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "required": [
              "fileInfo"
            ],
            "additionalProperties": true,
            "properties": {
              "fileInfo": {
                "$ref": "#/components/schemas/FileInfo"
              },
              "photoRecord": {
                "$ref": "#/components/schemas/PhotoRecord"
              }
            }
          }
        }
      },
      "UploadError": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              401,
              403,
              500
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "oneOf": [
              {
                "type": "array",
                "maxItems": 0,
                "description": "既有异常可能返回空数组，兼容保留"
              },
              {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "error_code": {
                    "type": "string",
                    "enum": [
                      "AUTH_REQUIRED",
                      "TENANT_FORBIDDEN",
                      "PHOTO_CONTEXT_INVALID",
                      "STAFF_MAPPING_UNRESOLVED",
                      "FILE_INVALID",
                      "IDEMPOTENCY_CONFLICT",
                      "REQUEST_IN_PROGRESS",
                      "PERSISTENCE_FAILED"
                    ]
                  },
                  "retryable": {
                    "type": "boolean"
                  },
                  "retry_after_seconds": {
                    "type": "integer",
                    "minimum": 1
                  }
                }
              }
            ]
          }
        }
      },
      "MealDishExisting": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "dishes_id": {
            "type": "integer",
            "description": "现有字段；关联分支库内为映射ID、库外无ID用0"
          },
          "dishes_weight": {
            "type": "number",
            "description": "现有克重字段；关联分支要求非负有限数"
          },
          "dishes_name": {
            "type": "string"
          },
          "dishes_number": {
            "oneOf": [
              {
                "type": "number"
              },
              {
                "type": "string"
              }
            ],
            "description": "保留旧调用，关联分支校验数量/单位一致"
          },
          "source_item_id": {
            "type": "string",
            "minLength": 1,
            "description": "新增，仅关联分支，服务端当前记录候选项ID"
          }
        }
      },
      "PhotoMealDish": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MealDishExisting"
          },
          {
            "type": "object",
            "required": [
              "dishes_id",
              "dishes_weight",
              "dishes_name",
              "source_item_id"
            ],
            "properties": {
              "dishes_id": {
                "type": "integer",
                "minimum": 0
              },
              "dishes_weight": {
                "type": "number",
                "minimum": 0
              },
              "dishes_name": {
                "type": "string",
                "minLength": 1
              }
            }
          }
        ]
      },
      "MealSaveForm": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "meal_date": {
            "type": "string"
          },
          "meal_times": {
            "type": "integer"
          },
          "dishes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MealDishExisting"
            }
          },
          "photo_record_id": {
            "type": "string",
            "minLength": 1
          },
          "expected_revision": {
            "type": "integer",
            "minimum": 0
          },
          "save_request_id": {
            "type": "string",
            "format": "uuid"
          }
        },
        "description": "旧form不裁剪未知字段。任意新关联字段出现时必须完整；source_item_id出现同样必须关联。",
        "oneOf": [
          {
            "not": {
              "anyOf": [
                {
                  "required": [
                    "photo_record_id"
                  ]
                },
                {
                  "required": [
                    "expected_revision"
                  ]
                },
                {
                  "required": [
                    "save_request_id"
                  ]
                }
              ]
            },
            "properties": {
              "dishes": {
                "type": "array",
                "items": {
                  "not": {
                    "required": [
                      "source_item_id"
                    ]
                  }
                }
              }
            }
          },
          {
            "required": [
              "meal_date",
              "meal_times",
              "dishes",
              "photo_record_id",
              "expected_revision",
              "save_request_id"
            ],
            "properties": {
              "meal_date": {
                "type": "string",
                "format": "date"
              },
              "meal_times": {
                "type": "integer",
                "enum": [
                  1,
                  2,
                  3,
                  4
                ]
              },
              "dishes": {
                "type": "array",
                "minItems": 1,
                "items": {
                  "$ref": "#/components/schemas/PhotoMealDish"
                }
              }
            }
          }
        ]
      },
      "PhotoSaveReceipt": {
        "type": "object",
        "additionalProperties": true,
        "required": [
          "record_id",
          "save_request_id",
          "saved_revision",
          "current_revision",
          "replayed",
          "projection_status",
          "nutrient_status"
        ],
        "properties": {
          "record_id": {
            "type": "string"
          },
          "save_request_id": {
            "type": "string",
            "format": "uuid"
          },
          "saved_revision": {
            "type": "integer",
            "minimum": 1
          },
          "current_revision": {
            "type": "integer",
            "minimum": 1
          },
          "replayed": {
            "type": "boolean"
          },
          "projection_status": {
            "type": "string",
            "enum": [
              "ai_pending",
              "synchronized",
              "blocked_mapping"
            ]
          },
          "nutrient_status": {
            "type": "string",
            "enum": [
              "complete",
              "partial"
            ]
          }
        }
      },
      "MealSaveSuccess": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              200
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "oneOf": [
              {
                "type": "array",
                "maxItems": 0,
                "description": "旧renderSuccess字符串重载data=[]"
              },
              {
                "type": "object",
                "additionalProperties": true,
                "required": [
                  "photoSave"
                ],
                "properties": {
                  "photoSave": {
                    "$ref": "#/components/schemas/PhotoSaveReceipt"
                  }
                }
              }
            ]
          }
        }
      },
      "MealSaveError": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              401,
              403,
              500
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "oneOf": [
              {
                "type": "array",
                "maxItems": 0
              },
              {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "error_code": {
                    "type": "string",
                    "enum": [
                      "AUTH_REQUIRED",
                      "PHOTO_ACCESS_DENIED",
                      "PHOTO_SAVE_CONTEXT_INVALID",
                      "PHOTO_NOT_READY",
                      "PHOTO_MEAL_CONTEXT_CONFLICT",
                      "SOURCE_ITEM_INVALID",
                      "DISH_MAPPING_INVALID",
                      "IDEMPOTENCY_CONFLICT",
                      "REVISION_CONFLICT",
                      "MANUAL_RESULT_PROTECTED",
                      "PERSISTENCE_FAILED"
                    ]
                  },
                  "current_revision": {
                    "type": "integer",
                    "minimum": 0
                  },
                  "retryable": {
                    "type": "boolean"
                  }
                }
              }
            ]
          }
        }
      },
      "PhotoNutrients": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "energy_kcal",
          "protein_g",
          "zinc_mg",
          "magnesium_mg"
        ],
        "properties": {
          "energy_kcal": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "protein_g": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "zinc_mg": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "magnesium_mg": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          }
        }
      },
      "PhotoResultItem": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "source_item_id",
          "dishes_id",
          "name",
          "weight_g",
          "image_url",
          "nutrients",
          "source",
          "ingredient_mapping_status"
        ],
        "properties": {
          "source_item_id": {
            "type": "string",
            "minLength": 1
          },
          "dishes_id": {
            "type": "integer",
            "minimum": 0
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "weight_g": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "image_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "服务端受控授权URL或null，不接受任意客户端URL"
          },
          "nutrients": {
            "$ref": "#/components/schemas/PhotoNutrients"
          },
          "source": {
            "type": "string",
            "enum": [
              "library",
              "web"
            ]
          },
          "ingredient_mapping_status": {
            "type": "string",
            "enum": [
              "matched",
              "partial",
              "unmatched"
            ]
          }
        }
      },
      "PhotoEffectiveResult": {
        "type": "object",
        "nullable": true,
        "additionalProperties": false,
        "required": [
          "origin",
          "nutrient_status",
          "items"
        ],
        "properties": {
          "origin": {
            "type": "string",
            "enum": [
              "ai",
              "user",
              "manual"
            ]
          },
          "nutrient_status": {
            "type": "string",
            "enum": [
              "complete",
              "partial"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PhotoResultItem"
            }
          }
        }
      },
      "PhotoInfo": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "record_id",
          "source_event_id",
          "revision",
          "meal_date",
          "meal_times",
          "meal_kind",
          "meal_time",
          "uploaded_at",
          "dispatch_status",
          "recognition_status",
          "current_result",
          "candidates",
          "can_save",
          "save_block_reason",
          "poll_after_seconds"
        ],
        "properties": {
          "record_id": {
            "type": "string"
          },
          "source_event_id": {
            "type": "string"
          },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "nullable": true
          },
          "meal_date": {
            "type": "string",
            "format": "date"
          },
          "meal_times": {
            "type": "integer",
            "enum": [
              1,
              2,
              3,
              4
            ]
          },
          "meal_kind": {
            "type": "string",
            "enum": [
              "regular",
              "extra",
              "snack",
              "night_snack",
              "unassigned"
            ]
          },
          "meal_time": {
            "type": "string",
            "nullable": true
          },
          "uploaded_at": {
            "type": "string",
            "format": "date-time"
          },
          "dispatch_status": {
            "type": "string",
            "enum": [
              "pending",
              "delivered"
            ]
          },
          "recognition_status": {
            "type": "string",
            "enum": [
              "pending",
              "queued",
              "running",
              "completed",
              "partial",
              "not_in_library",
              "failed",
              "policy_conflict"
            ]
          },
          "current_result": {
            "$ref": "#/components/schemas/PhotoEffectiveResult"
          },
          "candidates": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "attempt_id",
                "started_revision",
                "result"
              ],
              "properties": {
                "attempt_id": {
                  "type": "string"
                },
                "started_revision": {
                  "type": "integer",
                  "minimum": 0
                },
                "result": {
                  "$ref": "#/components/schemas/PhotoEffectiveResult"
                }
              }
            }
          },
          "can_save": {
            "type": "boolean"
          },
          "save_block_reason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "no_effective_result",
              "manual_result_protected",
              "record_not_ready",
              "context_invalid",
              null
            ]
          },
          "poll_after_seconds": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60
          }
        }
      },
      "PhotoInfoSuccess": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              200
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "required": [
              "photoRecord"
            ],
            "properties": {
              "photoRecord": {
                "$ref": "#/components/schemas/PhotoInfo"
              }
            }
          }
        }
      },
      "PhotoInfoError": {
        "type": "object",
        "required": [
          "status",
          "message",
          "data",
          "trace_id"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              401,
              403,
              500
            ]
          },
          "message": {
            "type": "string"
          },
          "trace_id": {
            "type": "string"
          },
          "data": {
            "oneOf": [
              {
                "type": "array",
                "maxItems": 0
              },
              {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "error_code": {
                    "type": "string",
                    "enum": [
                      "AUTH_REQUIRED",
                      "PHOTO_ACCESS_DENIED",
                      "PHOTO_QUERY_INVALID",
                      "RECORD_TEMPORARILY_UNAVAILABLE",
                      "RATE_LIMITED"
                    ]
                  },
                  "retryable": {
                    "type": "boolean"
                  },
                  "retry_after_seconds": {
                    "type": "integer",
                    "minimum": 1
                  }
                }
              }
            ]
          }
        }
      }
    }
  }
}
