feat: support electronic photo purchases and download completion

This commit is contained in:
2026-09-15 10:51:38 +08:00
parent 712f6154bc
commit b1cdc1fe06
33 changed files with 1488 additions and 218 deletions
+391
View File
@@ -0,0 +1,391 @@
# 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/推送结果及实际下载结果。未完成真实支付、设备打印或相册下载时,应如实标记“未验证”,不能用语法检查、静态计算或示例响应替代端到端验收。