Files
kiosk/docs/OSCAR_PHOTO_PURCHASE.md

392 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <md5(sn + 设备api_token + timestamp)>
```
设备必须为已注册且已绑定景区的大屏设备,时间戳允许偏差为 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/推送结果及实际下载结果。未完成真实支付、设备打印或相册下载时,应如实标记“未验证”,不能用语法检查、静态计算或示例响应替代端到端验收。