--- schema: "hl-changelog/v2" ticket: "5599" title: "核单餐食餐厅下拉值独立存储" consumer: "admin" author: "yst" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "implemented" frontend_owner: "mmg" frontend_ref: "hl-admin@01bd3875046cd32e9497e2f00a9eb0f782a197dd" verified_at: "" target_release: "" updated_at: "2026-08-10" base: "dev-v3" status_note: "PR #5600 已合并 dev-v3;2026-08-10 复核确认测试服已部署并验证(见文末验证证据章节)。restaurantId 关联资源服务餐厅下拉接口 PR #5583" --- # 核单餐食餐厅下拉值独立存储(不覆盖餐食名称) - **变更类型**:修改接口 - **端类型**:管理后台 - **服务**:hl-order-service-v3 - **日期**:2026-08-06 ## 1. 接口背景 核单「餐食」Tab 原来只有手动录入的 `mealName`(餐食名称)一个文本字段承载餐食/餐厅信息。业务上需要把「从餐厅资源库选择的餐厅」作为独立结构化数据存下来,便于后续按餐厅维度统计与对账,且**不能覆盖**运营手动填写的餐食名称。 本次变更:餐食费用的「新增 / 修改 / 回显」三个接口同时新增 `restaurantId` + `restaurantName` 两个字段,与 `mealName` 完全独立、互不联动。 ## 2. 变更清单 | # | 接口 | 变更 | 说明 | |---|------|------|------| | 1 | POST /v3/admin/order/{orderId}/settlement/meals | 入参 +2 字段 | 新增 restaurantId / restaurantName(成对、非必填) | | 2 | PUT /v3/admin/order/{orderId}/settlement/meals/{settlementId} | 入参 +2 字段 | 新增 restaurantId / restaurantName(成对、非必填) | | 3 | GET /v3/admin/order/{orderId}/settlement/meals | 出参 +2 字段 | 回显新增 restaurantId / restaurantName | 无删除字段、无改名字段、无枚举值变化。 ## 3. 接口详情 ### 3.1 新增餐食费用 - 方法/路径:`POST /v3/admin/order/{orderId}/settlement/meals` - 接口名:新增餐食费用 - 认证:管理后台 JWT(网关统一鉴权) - 幂等性:非幂等(重复提交会产生多条餐食记录) - 限流:走网关默认限流,无接口级特殊限流 ### 3.2 修改餐食费用 - 方法/路径:`PUT /v3/admin/order/{orderId}/settlement/meals/{settlementId}` - 接口名:修改餐食费用 - 认证:管理后台 JWT(网关统一鉴权) - 幂等性:幂等(相同 body 重复 PUT 结果一致) - 限流:走网关默认限流 ### 3.3 查询餐食费用(回显) - 方法/路径:`GET /v3/admin/order/{orderId}/settlement/meals` - 接口名:查询餐食费用 - 认证:管理后台 JWT(网关统一鉴权) - 幂等性:只读接口 - 限流:走网关默认限流 ## 4. 接口入参 ### 4.1 路径参数 | 参数 | 位置 | 类型 | 必填 | 说明 | |---|---|---|---|---| | orderId | path | Long | 是 | 订单ID(三个接口均有) | | settlementId | path | Long | 是 | 餐食费用记录ID(仅 PUT 修改接口) | 无 Query 参数。 ### 4.2 请求体字段(POST / PUT 共用同一请求结构) | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | mealType | String | 是 | 餐型:BREAKFAST / LUNCH / DINNER | | mealDate | Date(yyyy-MM-dd) | 否 | 发生日期 | | mealName | String | 是 | 餐食名称,手动录入,最长 200 字符;**不受餐厅选择影响、不被覆盖** | | quantity | Integer | 是 | 数量,1-10000 | | unitPrice | Number | 是 | 单价,≥ 0,最多 8 位整数 + 2 位小数;unitPrice × quantity ≤ 99999999.99 | | paymentMethod | String | 是 | 付款类型:CASH_PAID / COMPANY_PAID / SIGNED | | sourceType | String | 是 | 来源类型:MEAL_ASSIGNMENT / MANUAL / SYSTEM | | settlementConfirmStatus | String | 是 | 确认状态:UNCONFIRMED / CONFIRMED | | voucherUrls | String[] | 否 | 凭证 URL 列表,最多 9 个,仅支持 http/https,单个最长 1024 字符 | | restaurantId | Long | 否 | **本次新增**。餐厅资源ID,与 restaurantName 成对出现;不选餐厅则两者都不传 | | restaurantName | String | 否 | **本次新增**。餐厅名称快照,与 restaurantId 成对出现,最长 200 字符 | | remark | String | 否 | 备注,最长 512 字符 | **成对校验(关键业务规则)**:`restaurantId` 与 `restaurantName` 必须**同时传或同时不传**(restaurantName 为空白字符串视为未传)。只传其一 → 400,message 为「restaurantId 与 restaurantName 必须成对出现」或「餐食费用字段超出允许范围」(错误码 584094,MEAL_EXPENSE_REQUEST_INVALID)。两字段与手动录入的 `mealName` **完全独立、不联动**——选了餐厅也不会改动/覆盖 mealName。 ## 5. 出参字段 - POST / PUT 响应:`Result`(单条记录) - GET 响应:`Result>`(列表,元素结构相同) SettlementMealRespVO 字段表: | 字段 | 类型 | 说明 | |---|---|---| | id | String | 餐食费用ID,雪花ID序列化为字符串 | | mealType | String | 餐型 | | mealDate | Date(yyyy-MM-dd) | 发生日期 | | mealName | String | 餐食名称 | | restaurantId | String \| null | **本次新增**。餐厅资源ID,雪花ID序列化为字符串(防 JS 精度丢失);未选餐厅为 null | | restaurantName | String \| null | **本次新增**。餐厅名称快照(选中时记录,餐厅改名/删除不影响已存核单记录);未选餐厅为 null | | quantity | Integer | 数量 | | unitPrice | String | 单价(BigDecimal 序列化为字符串) | | actualAmount | String | 实际金额 = unitPrice × quantity(BigDecimal 序列化为字符串) | | paymentMethod | String | 付款类型 | | sourceType | String | 来源类型 | | sourceTypeName | String | 来源类型名称 | | voucherUrls | String[] | 凭证 URL 列表 | | settlementConfirmStatus | String | 确认状态 | | settlementConfirmStatusName | String | 确认状态名称 | | remark | String | 备注 | 外层为统一响应结构:`{ "code": 200, "message": "...", "data": ..., "traceId": ..., "success": ... }`;业务失败时 code 为错误码、data 为 null。 ## 6. 枚举 / 数据字典 本次**无新增、无变更**枚举。以下现有枚举取值保持不变: | 字段 | 取值 | 说明 | |---|---|---| | mealType | BREAKFAST / LUNCH / DINNER | 早餐 / 午餐 / 晚餐 | | paymentMethod | CASH_PAID / COMPANY_PAID / SIGNED | 现金垫付 / 公司支付 / 签单 | | sourceType | MEAL_ASSIGNMENT / MANUAL / SYSTEM | 餐食安排 / 手动录入 / 系统生成 | | settlementConfirmStatus | UNCONFIRMED / CONFIRMED | 未确认 / 已确认 | `restaurantId` 的取值来源:资源服务「餐厅资源下拉选项」接口 `GET /admin/resource-options/restaurants`(PR #5583 已上线)返回的 `resourceId`。餐厅选项是业务数据,不是枚举/字典。 ## 7. 错误码 | HTTP | 错误码 | message | 触发条件 | |---|---|---|---| | 400 | 584094 | 餐食费用字段超出允许范围 | restaurantId 与 restaurantName 只传其一(不成对),service 层抛 MEAL_EXPENSE_REQUEST_INVALID | | 400 | (参数校验失败) | restaurantId 与 restaurantName 必须成对出现 | 同上场景,bean validation 层先行拦截时的提示文案 | | 400 | (参数校验失败) | mealType 必须是 BREAKFAST / LUNCH / DINNER 之一 等 | 既有字段校验失败(本次不变) | | 401 | - | 未登录 / token 失效 | 未携带或携带无效的管理后台 JWT | ## 8. 示例 ### 8.1 典型成功:选择餐厅保存 + 回显 请求: ```http POST /v3/admin/order/1951234567890123456/settlement/meals Authorization: Bearer {adminToken} Content-Type: application/json ``` ```json { "mealType": "LUNCH", "mealDate": "2026-08-10", "mealName": "团队桌餐(10人标)", "quantity": 10, "unitPrice": 68.00, "paymentMethod": "COMPANY_PAID", "sourceType": "MANUAL", "settlementConfirmStatus": "UNCONFIRMED", "voucherUrls": ["https://oss.example.com/voucher/a1.jpg"], "restaurantId": 1889900112233445566, "restaurantName": "海拉尔XX手把肉餐厅", "remark": "导游现场确认" } ``` 响应(200): ```json { "code": 200, "message": "成功", "data": { "id": "1952345678901234567", "mealType": "LUNCH", "mealDate": "2026-08-10", "mealName": "团队桌餐(10人标)", "restaurantId": "1889900112233445566", "restaurantName": "海拉尔XX手把肉餐厅", "quantity": 10, "unitPrice": "68.00", "actualAmount": "680.00", "paymentMethod": "COMPANY_PAID", "sourceType": "MANUAL", "sourceTypeName": "手动录入", "voucherUrls": ["https://oss.example.com/voucher/a1.jpg"], "settlementConfirmStatus": "UNCONFIRMED", "settlementConfirmStatusName": "未确认", "remark": "导游现场确认" } } ``` 回显:`GET /v3/admin/order/1951234567890123456/settlement/meals` 返回 `data` 为列表,元素结构同上(含 restaurantId / restaurantName)。 ### 8.2 边界:不选餐厅,纯手动录入(两字段都不传) 请求: ```http POST /v3/admin/order/1951234567890123456/settlement/meals Authorization: Bearer {adminToken} Content-Type: application/json ``` ```json { "mealType": "BREAKFAST", "mealDate": "2026-08-11", "mealName": "酒店自助早餐", "quantity": 10, "unitPrice": 0, "paymentMethod": "SIGNED", "sourceType": "MANUAL", "settlementConfirmStatus": "CONFIRMED" } ``` 响应(200):`data` 中 `restaurantId: null`、`restaurantName: null`,其余字段正常填充。存量历史餐食记录回显同样两字段为 null。 ### 8.3 业务失败:只传 restaurantId、缺 restaurantName(不成对) 请求: ```json { "mealType": "DINNER", "mealName": "涮羊肉", "quantity": 10, "unitPrice": 88.00, "paymentMethod": "CASH_PAID", "sourceType": "MANUAL", "settlementConfirmStatus": "UNCONFIRMED", "restaurantId": 1889900112233445566 } ``` 响应(400): ```json { "code": 584094, "message": "餐食费用字段超出允许范围", "data": null } ``` (若 bean validation 层先行拦截,message 为「restaurantId 与 restaurantName 必须成对出现」。反向场景——只传 restaurantName 不传 restaurantId——同样 400。) ## 9. 业务边界 **适用**: - 核单「餐食」Tab 新增 / 编辑费用时,从餐厅资源下拉中选择餐厅,结构化保存 restaurantId + restaurantName - 运营纯手动录入餐食(不关联餐厅):两字段都不传即可,行为与本次变更前完全一致 **不适用**: - restaurantId 只接受「餐厅资源下拉选项」接口返回的 resourceId,不接受其他类型资源ID - 已提交核单(终态)订单不可再改餐食费用(核单提交守卫为既有逻辑,本次不变) **特殊边界**: - **快照语义**:restaurantName 是选中那一刻的名称快照。之后餐厅在资源库改名 / 删除,**不影响**已存核单记录的 restaurantName - **与 mealName 完全独立**:选了餐厅也不会改动 / 覆盖 mealName;mealName 仍是必填手动录入字段 - 存量数据:历史餐食费用记录两字段均为 null,回显正常、无需迁移 ## 10. 修改前后对比 ### 字段级对比 | 接口 | 维度 | 修改前 | 修改后 | |---|---|---|---| | POST / PUT 保存 | 入参 | 无餐厅字段 | +restaurantId(Long,可选,成对)+restaurantName(String,最长 200,可选,成对) | | GET 回显 | 出参 | 无餐厅字段 | +restaurantId(String,可 null)+restaurantName(String,可 null) | | mealName | 语义 | 餐食/餐厅信息只能挤在这一个文本字段里 | 仍为必填手动录入;餐厅信息改由独立字段承载,不再占用 mealName | ### 行为级对比 | 场景 | 修改前 | 修改后 | |---|---|---| | 保存餐食费用 | 想记餐厅只能写进 mealName 文本 | 可结构化传 restaurantId + restaurantName,mealName 不被覆盖 | | 回显 | 无法区分「餐食名称」和「餐厅」 | 两类信息独立字段回显,前端可分开展示 | | 入参校验 | 无成对概念 | restaurantId / restaurantName 不成对 → 400 | ## 11. 影响评估 / 回滚 - **破坏性兼容**:无。两个字段均为可选新增;存量请求不传两字段时行为完全不变;存量数据两列为 NULL - **前端同步上线**:不强制同步。前端未接新字段前,保存 / 回显行为与之前一致(回显多两个 null 字段,不读即可);前端接新字段需等后端部署后联调 - **后端部署状态**:PR #5600 已合并 dev-v3,**测试服已部署**(2026-08-10 复核确认,见文末验证证据章节) - **回滚方案**:后端回滚 = 下线两字段即可。回滚时前端需同步停止传这两字段;存量数据两列本就为 NULL 或历史快照值,无数据迁移、无回滚 SQL ## 12. 注意事项 1. `restaurantId` 出参是 **String**(雪花 ID 序列化),前端不要用 Number 解析,防 JS 精度丢失 2. `unitPrice` / `actualAmount` 出参同样是 String(BigDecimal 序列化),金额展示直接渲染字符串即可 3. 编辑时「清掉已选餐厅」= 两字段都不传(或都传 null),保持成对空态 4. restaurantName 是快照不是实时关联,餐厅后续改名 / 删除不影响历史核单记录显示,属预期 5. 餐厅下拉数据源接口 `GET /admin/resource-options/restaurants` 属资源服务(PR #5583),不在本变更范围内 6. 本变更只涉及接口契约;后端代码已合并 dev-v3,测试服部署时间以后端通知为准 ## 13. 关联 / 联系人 - Issue:https://git.1814.love:8443/wx/HL/issues/5599 - PR:https://git.1814.love:8443/wx/HL/pulls/5600 - Commit:https://git.1814.love:8443/wx/HL/commit/9884585a292924c1a0a7a5fec6b7dc8ab87aa59d - 关联依赖:餐厅资源下拉接口 PR #5583(资源服务,已上线) - 后端负责人:yst(腰苏图) ## 验证证据(2026-08-10 复核回填,wx) > 本条 changelog 2026-08-06 推送时后端尚未部署测试服(原 status_note 自述),违反「测试服部署+实测后才通知前端」流程。2026-08-10 复核补齐部署与验证证据如下: - 测试服 hl-order-service-v3 运行版本为 2026-08-10 15:10 构建(dev-v3),晚于 PR #5600 合并点(2026-08-06 14:56),本变更代码已在运行实例中。 - 测试服 DB `hl_order_service_v3.order_settlement_meal` 已存在 `restaurant_id` / `restaurant_name` 两列(Flyway 已执行)。 - `SettlementMealRespVO` 含 `restaurantId` / `restaurantName` 字段(dev-v3 源码核对)。 - `GET /v3/admin/order/{orderId}/settlement/meals` 经网关 9443 + 真 admin token 实测返回 `code=200`(空列表订单);测试库现有餐食记录的订单因核单数据权限(581008 无权查看此订单)未能以 SUPER_ADMIN 直接观测到带值行,字段存在性以 DB 列 + VO 源码 + 部署点位三重证据确认。