# Oscar 照片打印版 / 电子版购买对接 更新日期:2026-09-14。本文说明本次接口契约和人工联调标准;真实微信支付、设备打印、FaceBox 回调及相册下载仍需在部署后联调,示例不是线上验收记录。 ## 1. 适用范围和业务规则 本次在大屏人脸照片流程 `type=2` 增加 `purchase_mode=electronic`。打印版 `print` 保持原有打印、套餐计价和相册权益;`type=1` 小程序上传的接口响应和业务流程保持旧版。`type=3` 小程序人脸流程本次不新增电子版购买。 | 参数 / 配置 | 约定 | | --- | --- | | `purchase_mode` | 仅接受 `print`、`electronic`,区分大小写;省略时默认 `print` | | 显式空值或非法模式 | `null`、空字符串、其他字符串均报错,不能自动改成 `print` | | `electronic` | 仅 `type=2`,只购买照片,不打印,不支持混入视频 | | 电子版基础价 | `price_photo_digital`,未配置可为 `null`;后台新提交价格必须为 `0.01–99999.99` 元 | | 电子版阶梯 | 独立开关和独立档位,只根据去重照片张数选取已达到的最大门槛,命中单价用于本单全部照片 | | 免费与首张配置 | 电子版不使用 `free_digital_enabled`、`free_num`、`one_order_amount`,这些字段继续服务原打印逻辑 | | 免费电子版 | 本次不支持;基础价缺失或旧数据非正数视为电子版不可购买,新档位也不能配置零价 | | 用户身份 | 大屏仅代表设备;用户登录小程序扫码后创建实体订单,并以扫码用户为订单归属 | | 金额 | 对外为元、两位小数字符串;数据库和预选缓存金额使用整数分;不可用价格用 JSON `null` | 普通用户的付款金额采用获取支付链接时锁定的金额。现有内部员工/景区员工 **1 分钱优惠保留**,这是设备报价与实际付款金额一致性的明确例外;员工身份由服务端判断,不能由 App 上传金额或身份覆盖。 ## 2. 鉴权与公共响应 设备接口前缀:`/api/oscar/order`。每次请求带设备鉴权头: ```text Content-Type: application/json sn: <设备SN> timestamp: <当前秒级时间戳> token: ``` 设备必须为已注册且已绑定景区的大屏设备,时间戳允许偏差为 1 小时。`api_token` 为设备已配置密钥,不作为请求体字段发送。照片素材、订单号和购买模式不能替代设备鉴权。 HTTP 成功响应的最外层 `code` 为 `100000`: ```json { "code": 100000, "msg": "success", "data": {}, "time": "2026-09-14 12:00:00" } ``` 业务错误可能仍使用 HTTP 200,必须判断最外层 `code` 并显示 `msg`。不要把业务消息 `type=5` 或 WebSocket 的外层 `code=5` 当作 HTTP 成功码。下文未特别说明的响应示例仅展示 HTTP `data` 部分。 ## 3. App 调用顺序 1. 选照片后调用 `verify-result`,同时展示打印版报价和可用的电子版报价;没有选择照片时仅展示单价,总额为零。 2. 用户选择购买模式,调用 `get-pay-url`。使用该响应的金额展示支付二维码,保存其 `order_number` 与 `purchase_mode`。 3. 用户登录小程序扫码支付。App 监听 WebSocket 购买完成消息,并可调用 `pay-success-message` 补查。 4. 仅确认该订单 `order_status=30` 或收到匹配订单的购买完成推送后进入完成流程。`electronic` 展示相册二维码;`print` 继续原打印流程及相册领取流程。 5. 电子版不发起打印、不扣纸、不调用 `print-notify` 或 `print-complete`。订单购买完成已取得电子版相册权益,文件转存允许稍后完成。 App 应按订单号防止 WebSocket 与 HTTP 重复结果触发重复打印、重复跳页。切换购买模式或照片集合后应重新获取支付链接,不能继续复用前一张二维码。 电子版独立完成页仅展示下载二维码和 90 秒返回首页倒计时。倒计时只控制 App 返回首页,不代表已购照片下载权限到期。 ## 4. 报价:POST `/api/oscar/order/verify-result` 请求: ```json { "type": 2, "image_id": [101, 102, 103, 104, 105], "video_id": [] } ``` 照片和视频 ID 均为整数数组;照片按去重数量参与计价。电子版只计照片,App 电子版路径保持 `video_id=[]`。此接口同时报价,不需要 `purchase_mode`。 示例:打印套餐单价 12 元、电子版基础价 5 元、电子版满 2 张单价 4 元、满 5 张单价 3.50 元,打印旧赠送和首张优惠关闭: ```json { "price_image": "12.00", "price_video": "0.00", "amount": "60.00", "price_electronic": "3.50", "amount_electronic": "17.50" } ``` | 字段 | 含义 | | --- | --- | | `price_image` | 原打印流程照片单价;完整打印总额仍以 `amount` 为准,可能涉及原赠送/首张规则 | | `price_video` | 原视频单价 | | `amount` | 原打印流程总额 | | `price_electronic` | 当前照片张数对应的电子版单价 | | `amount_electronic` | 电子版照片单价 × 去重照片张数 | 电子版未配置时,新增的两个字段都为 `null`,打印报价正常返回。App 显示电子版不可购买;不能把 `null` 转成零元、不能自动改成打印版购买。 ```json { "price_image": "12.00", "price_video": "0.00", "amount": "60.00", "price_electronic": null, "amount_electronic": null } ``` 空选 `image_id=[]`、`video_id=[]` 时,总额为 `"0.00"`,单价仍返回基础单价;如果电子版未配置,电子版两个字段继续为 `null`。空选可报价,但不能生成购买订单。 `type=1` 继续只返回 `price_image`、`price_video`、`amount` 三个旧字段,不新增电子版字段。 ## 5. 获取支付链接:POST `/api/oscar/order/get-pay-url` 请求: ```json { "type": 2, "image_id": [101, 102, 103, 104, 105, 105], "video_id": [], "purchase_mode": "electronic" } ``` 响应: ```json { "url": "https://<业务域名>/scan/pay?order_number=<订单号>", "order_number": "<订单号>", "purchase_mode": "electronic", "amount": "17.50", "order_status": 10 } ``` 该接口生成预选缓存和订单号,**尚未创建实体订单,也不表示付款成功**。缓存有效期 24 小时,冻结设备 SN、`type`、去重照片/视频集合、购买模式、项目 ID、景区 ID、报价和整数分总额。 扫码后小程序沿用 `POST /api/mini/capture/scan-pay`,提交 `order_number`,由登录身份建单支付。扫码前后台改价不会重算本次已锁定的普通用户金额;项目已下线或设备绑定景区与锁定信息不一致时,拒绝建单,提示重新选择照片。缓存过期且尚未建单时同样重新选择。 同一订单重复扫码支付复用已有订单和金额,不允许改成其他购买者。完成后的重复扫码不能重复收款。 约束: - `image_id` 必须至少包含一张有效照片;重复 ID 去重。 - 电子版价格不可用、总额非正数或超出可存储范围时,不返回支付链接。 - 电子版带视频报错;`type=1/3` 请求电子版报错。 - 省略 `purchase_mode` 等同 `print`;显式非法值报错。 - `type=1` 响应仍只有旧字段 `url`、`order_number`,保持原扫码付款流程。 ## 6. HTTP 补查:POST `/api/oscar/order/pay-success-message` 请求: ```json {"order_number": "<订单号>"} ``` 尚未扫码但有效的本设备 `type=2` 预选缓存也可以查询,返回待付款 `10`: ```json { "order_status": 10, "order_status_name": "待付款", "sn": "<设备SN>", "type": 5, "data": { "order_number": "<订单号>", "capture_type": 2, "image_id": [101, 102, 103, 104, 105], "purchase_mode": "electronic" } } ``` 购买完成时结构相同,`order_status=30`、`order_status_name="已完成"`。`type=5` 是业务消息类别,待付款响应也带该值;**HTTP 补查必须检查 `order_status`,不能只看 `type=5` 就打印或开放下载**。 | 订单状态 | App 处理 | | --- | --- | | `10` 待付款 | 保持支付页面;可能只有缓存,也可能已建单等待支付 | | `30` 已完成 | 按响应的 `purchase_mode` 分流到打印或相册领取 | | `40` 已取消 / `50` 已退款 | 退出支付或完成等待,显示对应状态 | | 其他状态 | 不当作本次照片购买成功;按服务端提示处理 | 本次直接从待付款到完成,不新增状态。`60` 不是本次订单状态,不能用作免费领取、电子版完成或等待转存状态。未找到实体订单且缓存也失效时返回错误;其他设备的订单不可查询,也不能用旧缓存遮盖实体订单状态。 ## 7. WebSocket 购买完成通知 沿用既有 Oscar WebSocket 连接和鉴权,外层消息码为 `code=5`,其中业务消息为 `type=5`。电子版完成消息示例: ```json { "code": 5, "data": { "sn": "<设备SN>", "type": 5, "data": { "order_number": "<订单号>", "capture_type": 2, "image_id": [101, 102, 103, 104, 105], "purchase_mode": "electronic" } } } ``` 外层沿用现有网关协议,示例只列相关字段。服务端在购买完成事务提交后发送消息;HTTP 和 WebSocket 使用同一个 `type=5` 业务数据构造方法,所以模式和照片集合一致。HTTP 的 `order_status` 属于补查外壳,不要求 WebSocket 业务载荷增加该字段。 `capture_type=1` 仍使用旧的 `uuid`、`file_map` 载荷;`capture_type=2` 使用 `image_id` 与新增 `purchase_mode`。收到其他订单、其他抓拍流程消息时,不应驱动当前页面。推送丢失或连接恢复后,通过 HTTP 补查恢复状态。 ## 8. 相册二维码与文件准备 接口:`POST /api/oscar/order/save-album-url`。 ```json {"order_number": "<订单号>"} ``` 响应: ```json {"url": "https://<业务域名>/scan/share?order_number=<订单号>"} ``` 仅本设备已完成的照片订单可获取链接。电子版在购买完成时立即取得相册展示权益并发起 FaceBox 转存,**无需等待 `print-complete`**;打印版保持已有相册权益和扫码展示规则,不因本次新增模式减少原权益。 付款完成与文件转存完成是两个时刻。FaceBox 尚未回传原图时,相册可处于“上传中/准备中”;App 不应再次要求付费,不应把暂时没有素材当作未购买。 允许重复调用本接口:若此前转存请求没有成功受理,可重试;已受理的任务不会重复发起。成功取得二维码仅说明订单可领取,不保证所有原图已经准备完毕。已受理后长期未回调的任务仍需检查 FaceBox 状态,不能把反复取二维码等同于强制重建已受理任务。 FaceBox 成功回调以订单号和所购照片集合入库,重复回调不应产生重复素材。购买用户的订单与素材归属保持一致。 打印版继续调用原接口: - `POST /api/oscar/order/print-notify`:单张打印状态,字段为 `order_number`、`capture_type`、`image_id`、`print_status`,可附 `remaining_paper_num`。 - `POST /api/oscar/order/print-complete`:打印流程结束上报,字段为 `order_number`、`capture_type`。 服务端校验设备归属和购买模式。电子版调用以上两个接口会报错,不能写打印记录或更新剩余纸张数。打印完成上报不承担购买授权或相册转存的触发职责。 ## 9. 后台电子版配置 沿用 `POST /backend/project/add`、`POST /backend/project/edit`、`POST /backend/project/detail`。项目类型仍为 `22`,下列字段放在原有 `extra` 内;保存项目时同时提供既有接口要求的项目和打印配置。 ```json { "extra": { "price_photo_digital": "5.00", "multi_photo_digital_discount_enabled": 1, "multi_photo_digital_prices": [ {"min_photo_num": 2, "price_photo_digital": "4.00"}, {"min_photo_num": 5, "price_photo_digital": "3.50"} ] } } ``` | 字段 | 保存约束 | | --- | --- | | `price_photo_digital` | 可为 `null`,非空值为 `0.01–99999.99` 元,最多两位小数 | | `multi_photo_digital_discount_enabled` | `0` 关闭、`1` 开启;默认关闭 | | `multi_photo_digital_prices` | 独立电子版档位数组,不混用打印或套餐档位 | | `min_photo_num` | 整数,至少 2,同一策略内不得重复 | | 档位 `price_photo_digital` | `0.01–99999.99` 元,最多两位小数 | 开启电子版优惠必须配置正数基础价和至少一个档位。未达到门槛使用基础价;达到多个门槛时使用最大门槛的单价。保存前按门槛排序,金额转为整数分存储。 编辑时未提交的基础价、旧赠送字段、首张金额或策略配置保留原值;关闭策略并提交空数组不会清除已存档位。已存电子优惠仍开启时,不能只把基础价清空;应同时关闭电子优惠。旧数据里的基础价 `0` 读取时按电子版未配置处理,新保存时显式提交 `0` 会被拒绝。 电子档位复用 `project_type_face_print_multi_price`:`price_type=1` 打印,`2` 套餐,`3` 电子版。项目保存后刷新该景区价格缓存;旧缓存没有电子档位数组时重新加载。平台获取配置时也会核对共享数据库中的基础配置版本,因此景区端改价、店铺新版本审核上线后,即使三端缓存前缀不同也会重新加载当前价格和项目。 ### 后台价格试算 接口:`POST /backend/project/face-print-price-preview`,使用当前未保存配置。请求示例: ```json { "extra": { "free_digital_enabled": 0, "free_num": null, "one_order_amount": null, "price_photo_print": "8.00", "price_photo_combo": "12.00", "price_photo_digital": "5.00", "multi_photo_print_discount_enabled": 0, "multi_photo_print_prices": [], "multi_photo_combo_discount_enabled": 0, "multi_photo_combo_prices": [], "multi_photo_digital_discount_enabled": 1, "multi_photo_digital_prices": [ {"min_photo_num": 2, "price_photo_digital": "4.00"}, {"min_photo_num": 5, "price_photo_digital": "3.50"} ] }, "page": 1, "page_size": 2 } ``` 响应 `data`: ```json { "list": [ { "photo_num": 1, "price_photo_print": "8.00", "price_photo_combo": "12.00", "amount_upload_print": "8.00", "amount_face_print": "12.00", "price_photo_digital": "5.00", "amount_electronic": "5.00" }, { "photo_num": 2, "price_photo_print": "8.00", "price_photo_combo": "12.00", "amount_upload_print": "16.00", "amount_face_print": "24.00", "price_photo_digital": "4.00", "amount_electronic": "8.00" } ], "total": 6, "page": 1, "page_size": 2 } ``` 后台预览的电子单价字段叫 `price_photo_digital`,App 报价接口叫 `price_electronic`,二者不要混用。两处电子总额都叫 `amount_electronic`。未配置电子价时,预览电子单价和总额均为 `null`。 ## 10. 旧版兼容与异常处理 | 场景 | 约定 | | --- | --- | | 旧 App 不传模式 | 按 `print` 处理,保持原打印权益 | | 上线前生成、没有 `purchase_mode` 的预选缓存 | 按旧 `print` 流程建单;未锁定价格的旧缓存仍采用旧计价路径 | | 已存在的旧订单 | 数据库新增字段默认 `print` | | 明确标记 `electronic` 的新缓存损坏或过期 | 返回错误并重新选择,不降级为打印版或重新套用旧打印计价 | | 同一笔支付的重复通知 | 幂等处理、不重复完成,素材回调不重复入库 | | 付款完成但 WebSocket 通知失败 | 通过 HTTP 补查恢复;通知失败不能让已支付订单重新付款 | | 转存请求失败 | 已购买权益保留,后续获取相册链接可重试尚未受理的上传 | | 非本设备订单或抓拍类型不匹配 | 拒绝查询、取相册或打印上报 | 对于服务端报错,App 显示服务端提示并恢复 loading。缓存失效、项目失效或模式价格不可用时,返回选片/报价步骤重新生成二维码,不复用旧金额。 ## 11. SQL 与上线顺序 SQL 文件:[`20260914_face_print_electronic.sql`](../database/sql/20260914_face_print_electronic.sql)。此文件只供用户/数据库维护人员执行,开发助手未执行 SQL,也不执行 `php artisan migrate`。 平台、店铺、景区共用数据库。上线顺序: 1. 对目标数据库只读检查表和列定义,确认已存在打印/套餐阶梯表;缺少既有阶梯表时先核对既有 `20260818_face_print_multi_price.sql` 的部署情况。 2. 用户按新 SQL 中说明执行一次性新增列:`order.purchase_mode` 默认 `print`,`project_type_face_print.multi_photo_digital_discount_enabled` 默认 `0`;更新既有 `price_type` 注释支持 `3`。列已存在时跳过相应 `ADD COLUMN`,不要直接重跑整份脚本。 3. 发布平台后端与后台配置页面,同时发布景区、店铺 API 的配置保留改动。景区编辑保留未提交配置;店铺编辑或启用项目创建新版本时复制原阶梯配置。按现有发布流程重启长驻服务并处理框架缓存。先有数据库列,再发布会写这些列的代码。 4. 在后台配置正数电子版价格和需要的阶梯,保存后核对详情回显与试算。 小程序继续使用原扫码支付和相册下载接口;本次付费电子版已沿用这些能力,发布新版 App 前仍须核对小程序能显示实际支付金额及照片准备中状态。 5. 用支持 `purchase_mode` 的 App 做下表人工联调,通过后再开放电子版入口;旧 App 可继续走默认打印。 6. 回退应用版本时保留新增列和已有购买数据,先核对待支付/已购买电子订单的处理能力,避免旧代码把电子版订单当作打印订单。 ## 12. 人工联调验收矩阵 以下均为待执行的联调项,不代表已经通过。 | 场景 | 操作 | 预期 | | --- | --- | --- | | 基础打印 | 不传模式获取二维码,普通用户付款 | 模式 `print`;金额和打印、相册权益与原流程一致 | | 基础电子版 | 正数基础价,关闭电子阶梯,选 1 张付款 | 电子单价和总额为基础价;完成后不打印,能领取原图 | | 电子阶梯边界 | 依次选门槛前、门槛上、门槛后张数 | 最大已达到门槛单价用于全部去重照片 | | 独立计价 | 调整旧赠送张数和首张金额 | 打印保留旧规则;相同电子配置的电子版金额不变 | | 重复照片 | 同一 ID 提交多次 | 报价、锁定金额、HTTP 与推送中的照片集合均按去重计算 | | 空选 | 空数组报价,再请求支付链接 | 返回基础单价与零总额;禁止空选下单 | | 未配置电子价 | 基础价设 `null`;另验证旧数据库值为 `0` 的读取 | 打印可用;电子两字段 `null`,电子支付链接报错 | | 非法价格/档位 | 提交零价、负价、3 位小数、重复门槛、门槛 1 | 后台拒绝;未保存坏配置 | | 开关与保留 | 关闭电子优惠并传空数组;编辑旧字段时省略电子配置 | 原电子档位保留;未提交字段不被清空 | | 跨端保留 | 景区 API 编辑时省略电子价;店铺 API 编辑或启用并审核新版本 | 原电子基础价、开关和各类阶梯保留;平台新报价读取当前项目配置 | | 非法模式 | 提交空、`null`、拼写错误模式 | 报错,不自动打印 | | 电子混入视频 | `type=2`、电子模式携带视频 | 获取支付链接失败,不能发生打印或扣纸 | | 锁价 | 取二维码后后台改价,普通用户扫码 | 本单仍用已返回金额;重新取码采用新价 | | 员工优惠 | 使用符合原有规则的员工账号扫码 | 允许实际付款 0.01 元,记录为已知金额例外 | | 未扫码补查 | 仅生成预选缓存后调用支付结果接口 | 返回 `10`,`type=5` 不能被误判为购买成功 | | 过期/失效 | 缓存过期、项目下线、设备改绑景区后扫码 | 拒绝或提示重新选择,不重新解释为其他模式 | | 双人/重复扫码 | 两个账号扫描同一订单、同人重复支付 | 已建单归属不可被抢占;复用业务订单与原金额,已完成订单不再发起支付 | | WebSocket 丢失 | 付款时断开推送,再走 HTTP 补查 | 返回 `30`、正确模式和同一照片集合,只处理一次 | | 上传暂不可用 | 付款时使 FaceBox 上传请求失败,再恢复并重复取相册链接 | 购买仍完成;可重试转存,准备完成后可下载 | | 重复素材回调 | 重放相同成功回调,夹带未购买照片 ID | 不重复素材,未购买照片不进入本单相册 | | 电子禁打印 | 对电子订单调用两个打印回调接口 | 拒绝,打印记录和纸张数量不变化 | | 跨设备订单 | 用另一设备鉴权查询、取相册或上报打印 | 拒绝,不泄露或修改其他设备订单 | | 旧版上传 | `type=1` 完整上传、报价、扫码、打印 | 旧响应字段与旧业务流程保持一致 | | 小程序人脸购买 | `type=3` 购买照片、视频及纯视频 | 保持原计价与相册展示,不支持新增电子模式 | | 旧缓存 | 使用上线前无模式的有效缓存扫码 | 继续原 `print` 流程;新电子缓存绝不走该降级路径 | 验收记录应单独注明环境、设备、普通/员工账号、订单号、实付金额、HTTP/推送结果及实际下载结果。未完成真实支付、设备打印或相册下载时,应如实标记“未验证”,不能用语法检查、静态计算或示例响应替代端到端验收。