Files
as-daycard-openapi/src/main/java/io/as/modules/openapi/controller/ApiJstController.java
T
zhaogd 8385196b38 feat: 移除mock支付、卡不可复用、新增设备端取消订单接口
1. 移除所有mock支付逻辑,订单强制走微信支付
2. 卡状态流转改为单向:UNUSED->RESERVED->USED(终态不可复用)
3. 解绑接口改为仅清除device_id,卡状态保持USED
4. 新增设备端取消订单接口 POST /api/jst/daycard/cancel/{orderNo}
5. 修复微信回调验签header大小写问题
6. 临时注释分账标记(商户号未开通分账权限)
7. 删除多余的App UI辅助接口(ApiJstUiController)
8. 清理application.yml中无用UI配置
2026-08-06 13:43:34 +08:00

441 lines
19 KiB
Java
Raw 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.
package io.as.modules.openapi.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.entity.DayCardActivationCodeEntity;
import io.as.modules.daycard.entity.DayCardOrderEntity;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.daycard.entity.DayCardPriceConfigEntity;
import io.as.modules.daycard.service.DayCardActivationCodeService;
import io.as.modules.daycard.service.DayCardOrderService;
import io.as.modules.device.entity.DeviceEntity;
import io.as.modules.device.service.DeviceService;
import io.as.modules.openapi.filter.ApiDeviceSignFilter;
import io.as.modules.openapi.form.DeviceInitRequest;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.openapi.sign.DeviceSignResult;
import io.as.modules.sys.entity.SysOrgEntity;
import io.as.modules.sys.service.SysOrgService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* 橘视听 API 设备接口(对齐生产环境 as-partner-backend 的 ApiJstController)
* <p>完整流程:</p>
* <ol>
* <li><b>init</b>:设备初始化(注册/更新),返回 sign</li>
* <li><b>checkBenefits</b>:查询天卡权益,返回 thirdCardNo 与 status(ACTIVE/UNBOUND)</li>
* <li>无权益 → <b>daycard/create</b> 创建天卡订单 → 支付 → 轮询 checkBenefits 拿 thirdCardNo</li>
* </ol>
* <p><b>鉴权</b>:除 POST /api/jst/init 外,均须在请求头携带 sign。缺少或无效 sign 由过滤器直接返回 HTTP 401。</p>
*/
@Api(tags = "橘视听-API-设备接口",
description = "除 POST /api/jst/init 外均需请求头 sign;缺/无效 sign→401。业务结果以 JSON 的 code 为准。")
@RestController
@RequestMapping("/api/jst")
public class ApiJstController {
private static final Logger logger = LoggerFactory.getLogger(ApiJstController.class);
@Autowired
private ApiDeviceSignService apiDeviceSignService;
@Autowired
private DeviceService deviceService;
@Autowired
private SysOrgService sysOrgService;
@Autowired
private DayCardOrderService dayCardOrderService;
@Autowired
private DayCardActivationCodeService dayCardActivationCodeService;
/**
* 设备初始化:校验设备是否在系统,不在则添加、在则更新,返回 sign
*/
@ApiOperation(value = "设备初始化",
notes = "本接口无需 sign。body 必传 activationCode(管理后台按门店生成的激活码);设备可传 mac、wifiMac、deviceSn、deviceUid 等。成功返回 data.device、sign。")
@AuditAction(value = "DEVICE_INIT", description = "设备初始化")
@PostMapping("/init")
public Map<String, Object> init(
@ApiParam(value = "设备初始化请求体", required = true)
@RequestBody DeviceInitRequest request) {
try {
return doInit(request);
} catch (ASException e) {
return rrExceptionResponse(e);
}
}
/**
* 设备权益状态:查询天卡权益,返回 thirdCardNo + status
* <p>status: ACTIVE(橘视厅激活成功且未过期,返回 thirdCardNo)/ ACTIVATING(激活中)/ ACTIVATION_FAILED(激活失败)/ UNBOUND(无天卡)</p>
*/
@ApiOperation(value = "设备权益状态",
notes = "请求头必传 sign。查询设备天卡权益,返回 data.status(ACTIVE/ACTIVATING/ACTIVATION_FAILED/UNBOUND)。仅 ACTIVE 时返回 data.thirdCardNo(播放凭证)和 data.expireAt。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名(用于标识设备)", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/checkBenefits")
public Map<String, Object> checkBenefits(HttpServletRequest request) {
Long deviceId = deviceIdFrom(request);
DayCardPoolEntity pool = dayCardOrderService.getDeviceBenefits(deviceId);
Map<String, Object> data = new HashMap<>();
if (pool == null) {
data.put("status", "UNBOUND");
} else if (DayCardConstants.ACTIVATE_STATUS_SUCCESS.equals(pool.getActivateStatus())) {
data.put("status", "ACTIVE");
data.put("thirdCardNo", pool.getThirdCardNo());
data.put("cardNo", pool.getCardCode());
data.put("expireAt", pool.getExpireAt());
} else if (DayCardConstants.ACTIVATE_STATUS_FAILED.equals(pool.getActivateStatus())) {
data.put("status", "ACTIVATION_FAILED");
data.put("activateError", pool.getActivateError());
} else {
// PENDING 或 null,激活中
data.put("status", "ACTIVATING");
}
return buildSuccessResponse(data);
}
/**
* 设备天卡价格:供前端二维码展示
* <p>按 3 层维度匹配当前设备生效价:门店配置价 > 渠道配置价 > 平台默认价</p>
*/
@ApiOperation(value = "设备天卡价格",
notes = "请求头必传 sign。返回 data.price(元)、data.priceFen(分)、data.name。未配置定价时 data.status=UNCONFIGURED。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/price")
public Map<String, Object> price(HttpServletRequest request) {
Long deviceId = deviceIdFrom(request);
DayCardPriceConfigEntity config = dayCardOrderService.getDevicePrice(deviceId);
Map<String, Object> data = new HashMap<>();
if (config == null) {
data.put("status", "UNCONFIGURED");
} else {
data.put("status", "OK");
data.put("price", config.getPrice());
data.put("priceFen", config.getPrice().multiply(new java.math.BigDecimal("100")).intValue());
data.put("name", config.getName());
}
return buildSuccessResponse(data);
}
/**
* 创建天卡订单(无权益时调用)
*/
@ApiOperation(value = "创建天卡订单",
notes = "请求头必传 sign。从 sign 解析 deviceId,无需传设备标识。返回 data.orderNo、data.status、data.amount(分)、data.codeUrl、data.expireAt。")
@AuditAction(value = "DAYCARD_ORDER_CREATE", description = "创建天卡订单(设备端)")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/daycard/create")
public Map<String, Object> createDayCard(HttpServletRequest request) {
try {
Long deviceId = deviceIdFrom(request);
DayCardOrderEntity order = dayCardOrderService.createOrder(deviceId);
Map<String, Object> data = new HashMap<>();
data.put("orderNo", order.getOrderNo());
data.put("status", order.getStatus());
data.put("amount", order.getAmount().multiply(new java.math.BigDecimal("100")).intValue());
data.put("cardNo", order.getCardNo());
data.put("codeUrl", order.getCodeUrl());
data.put("expireAt", order.getExpireTime());
data.put("deviceId", order.getDeviceId());
return buildSuccessResponse(data);
} catch (ASException e) {
return rrExceptionResponse(e);
}
}
/**
* 查询支付状态
*/
@ApiOperation(value = "查询支付状态",
notes = "请求头必传 sign。路径参数为订单号,返回 data.status(CREATED/PAYING/PAID/CANCELLED)、data.codeUrl 等。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string")
})
@GetMapping("/daycard/status/{orderNo}")
public Map<String, Object> daycardStatus(@PathVariable("orderNo") String orderNo) {
DayCardOrderEntity order = dayCardOrderService.getByOrderNo(orderNo);
if (order == null) {
Map<String, Object> err = new HashMap<>();
err.put("code", 404);
err.put("msg", "订单不存在");
err.put("data", new HashMap<>());
return err;
}
Map<String, Object> data = new HashMap<>();
data.put("orderNo", order.getOrderNo());
data.put("status", order.getStatus());
data.put("paidAt", order.getPayTime());
data.put("codeUrl", order.getCodeUrl());
data.put("amount", order.getAmount() != null ? order.getAmount().multiply(new java.math.BigDecimal("100")).intValue() : 0);
data.put("payChannel", order.getPayChannel());
return buildSuccessResponse(data);
}
/**
* 取消天卡订单(用户放弃支付时调用)
*/
@ApiOperation(value = "取消天卡订单",
notes = "请求头必传 sign。用户取消支付时调用,取消订单并释放卡,允许设备重新下单。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/daycard/cancel/{orderNo}")
public Map<String, Object> cancelDayCard(@PathVariable("orderNo") String orderNo, HttpServletRequest request) {
try {
Long deviceId = deviceIdFrom(request);
dayCardOrderService.cancelOrder(orderNo, deviceId);
Map<String, Object> data = new HashMap<>();
data.put("orderNo", orderNo);
data.put("status", "CANCELLED");
return buildSuccessResponse(data);
} catch (ASException e) {
return rrExceptionResponse(e);
}
}
/**
* 设备心跳:更新设备心跳时间,续期 sign
*/
@ApiOperation(value = "设备心跳",
notes = "请求头必传 sign。更新设备 last_heartbeat 并续期 sign 缓存 TTL。建议按业务间隔调用以防 sign 过期。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/heartBeat")
public Map<String, Object> heartBeat(HttpServletRequest request) {
Long deviceId = deviceIdFrom(request);
DeviceEntity device = deviceService.getById(deviceId);
if (device != null) {
device.setLastHeartbeat(new Date());
deviceService.updateDevice(device);
}
apiDeviceSignService.refreshExpire(deviceId);
return buildSuccessResponse(new HashMap<>());
}
/**
* 刷新 sign:换新 sign,旧 sign 立即失效
*/
@ApiOperation(value = "刷新 sign",
notes = "请求头必传当前有效 sign。成功后在 data 中返回新 sign;旧 sign 立即失效,客户端必须替换本地存储。")
@ApiImplicitParams({
@ApiImplicitParam(name = "sign", value = "设备签名(旧的即将过期 sign)", required = true, paramType = "header", dataType = "string")
})
@PostMapping("/refreshSign")
public Map<String, Object> refreshSign(HttpServletRequest request) {
Long deviceId = deviceIdFrom(request);
String orgCode = channelOrgCodeFrom(request);
String newSign = apiDeviceSignService.generate(deviceId, System.currentTimeMillis(), orgCode);
Map<String, Object> data = new HashMap<>();
data.put("sign", newSign);
return buildSuccessResponse(data);
}
// ==================== 内部方法 ====================
private Map<String, Object> doInit(DeviceInitRequest request) {
if (request == null) {
throw new ASException("请求体不能为空", 400);
}
String activationCode = trim(request.getActivationCode());
if (StringUtils.isBlank(activationCode)) {
// 代销模式:免activationCode,按设备标识查找已注册设备(由partner消耗天卡接口创建)
return doInitWithoutActivationCode(request);
}
// 通过激活码解析门店与渠道
DayCardActivationCodeEntity codeEntity = dayCardActivationCodeService.getByCode(activationCode);
if (codeEntity == null) {
throw new ASException("激活码无效或已禁用", 400);
}
Long storeOrgId = codeEntity.getStoreOrgId();
Long channelOrgId = codeEntity.getChannelOrgId();
if (storeOrgId == null) {
throw new ASException("激活码未绑定门店", 400);
}
// 校验门店存在
SysOrgEntity storeOrg = sysOrgService.getById(storeOrgId);
if (storeOrg == null) {
throw new ASException("门店不存在", 400);
}
// 校验设备数上限
if (codeEntity.getMaxDeviceCount() != null && codeEntity.getMaxDeviceCount() > 0) {
int activated = codeEntity.getActivatedCount() != null ? codeEntity.getActivatedCount() : 0;
if (activated >= codeEntity.getMaxDeviceCount()) {
throw new ASException("该激活码已达设备上限(" + codeEntity.getMaxDeviceCount() + "台)", 400);
}
}
String androidId = trim(request.getAndroidId());
String mac = trim(request.getMac());
String wifiMac = trim(request.getWifiMac());
String deviceSn = trim(request.getDeviceSn());
String deviceUid = trim(request.getDeviceUid());
// 查找或创建设备(按优先级降权匹配:androidId > mac > deviceSn > wifiMac > deviceUid)
DeviceEntity device = findOrCreateDevice(androidId, mac, wifiMac, deviceSn, deviceUid);
boolean isNewDevice = device.getId() == null;
Date now = new Date();
device.setStoreOrgId(storeOrgId);
// 仅用非空标识更新,避免覆盖已有标识(按文档"更新缺失的标识字段")
if (StringUtils.isNotBlank(androidId)) device.setAndroidId(androidId);
if (StringUtils.isNotBlank(mac)) device.setMac(mac);
if (StringUtils.isNotBlank(wifiMac)) device.setWifiMac(wifiMac);
if (StringUtils.isNotBlank(deviceSn)) device.setDeviceSn(deviceSn);
if (StringUtils.isNotBlank(deviceUid)) device.setDeviceUid(deviceUid);
if (StringUtils.isNotBlank(request.getManufacturer())) device.setManufacturer(trim(request.getManufacturer()));
if (StringUtils.isNotBlank(request.getModel())) device.setModel(trim(request.getModel()));
if (StringUtils.isNotBlank(request.getBrand())) device.setBrand(trim(request.getBrand()));
if (StringUtils.isNotBlank(request.getVerCode())) device.setVerCode(trim(request.getVerCode()));
if (StringUtils.isNotBlank(request.getVerName())) device.setVerName(trim(request.getVerName()));
if (StringUtils.isNotBlank(request.getAndroidVersion())) device.setAndroidVersion(trim(request.getAndroidVersion()));
if (StringUtils.isNotBlank(request.getDeviceModel())) device.setDeviceModel(trim(request.getDeviceModel()));
if (StringUtils.isNotBlank(request.getDeviceName())) device.setDeviceName(trim(request.getDeviceName()));
device.setLastHeartbeat(now);
if (isNewDevice) {
device.setCreatedAt(now);
device.setDeleteFlag("UNDEL");
device.setStatus("ACTIVE");
deviceService.saveDevice(device);
// 新设备激活计数 +1
dayCardActivationCodeService.incrementActivatedCount(codeEntity.getId());
} else {
device.setUpdatedAt(now);
deviceService.updateDevice(device);
}
// 生成 sign(orgCode 用渠道组织ID的字符串形式,sign 校验时不依赖此值)
String orgCode = channelOrgId != null ? String.valueOf(channelOrgId) : "";
String sign = apiDeviceSignService.generate(device.getId(), System.currentTimeMillis(), orgCode);
Map<String, Object> data = new HashMap<>();
Map<String, Object> deviceInfo = new HashMap<>();
deviceInfo.put("deviceId", device.getId());
deviceInfo.put("roomNo", device.getRoomNo());
data.put("device", deviceInfo);
data.put("sign", sign);
return buildSuccessResponse(data);
}
/**
* 代销模式免activationCode初始化:按设备标识查找已注册设备,返回sign
* <p>设备由 /openapi/partner/card/consume 接口创建,APK用同一设备标识调本方法拿sign</p>
*/
private Map<String, Object> doInitWithoutActivationCode(DeviceInitRequest request) {
String mac = trim(request.getMac());
String wifiMac = trim(request.getWifiMac());
String deviceSn = trim(request.getDeviceSn());
String deviceUid = trim(request.getDeviceUid());
if (StringUtils.isBlank(mac) && StringUtils.isBlank(wifiMac)
&& StringUtils.isBlank(deviceSn) && StringUtils.isBlank(deviceUid)) {
throw new ASException("代销模式免激活码,需至少传一个设备标识(mac/wifiMac/deviceSn/deviceUid)", 400);
}
// 按优先级查找已有设备:mac > wifiMac > deviceSn > deviceUid(与生产环境一致)
DeviceEntity device = findByIdentifier(mac, DeviceEntity::getMac);
if (device == null) device = findByIdentifier(wifiMac, DeviceEntity::getWifiMac);
if (device == null) device = findByIdentifier(deviceSn, DeviceEntity::getDeviceSn);
if (device == null) device = findByIdentifier(deviceUid, DeviceEntity::getDeviceUid);
if (device == null) {
throw new ASException("设备未注册,请先通过合作伙伴天卡消耗接口激活设备", 400);
}
// 更新心跳
device.setLastHeartbeat(new Date());
deviceService.updateDevice(device);
// 生成sign(orgCode留空,代销模式不依赖渠道组织)
String sign = apiDeviceSignService.generate(device.getId(), System.currentTimeMillis(), "");
Map<String, Object> data = new HashMap<>();
Map<String, Object> deviceInfo = new HashMap<>();
deviceInfo.put("deviceId", device.getId());
deviceInfo.put("roomNo", device.getRoomNo());
data.put("device", deviceInfo);
Map<String, Object> result = buildSuccessResponse(data);
result.put("sign", sign);
return result;
}
/**
* 查找或创建设备:按 mac 优先查找,无则新建
* <p>注:此处为简化实现,完整的多策略识别算法在 DeviceResolveService 中</p>
*/
private DeviceEntity findOrCreateDevice(String androidId, String mac, String wifiMac, String deviceSn, String deviceUid) {
if (StringUtils.isBlank(androidId) && StringUtils.isBlank(mac)
&& StringUtils.isBlank(wifiMac) && StringUtils.isBlank(deviceSn)
&& StringUtils.isBlank(deviceUid)) {
throw new ASException("androidId、mac、wifiMac、deviceSn、deviceUid 至少传一个", 400);
}
// 按优先级降权匹配:androidId > mac > deviceSn > wifiMac > deviceUid
DeviceEntity existing = findByIdentifier(androidId, DeviceEntity::getAndroidId);
if (existing == null) existing = findByIdentifier(mac, DeviceEntity::getMac);
if (existing == null) existing = findByIdentifier(deviceSn, DeviceEntity::getDeviceSn);
if (existing == null) existing = findByIdentifier(wifiMac, DeviceEntity::getWifiMac);
if (existing == null) existing = findByIdentifier(deviceUid, DeviceEntity::getDeviceUid);
return existing != null ? existing : new DeviceEntity();
}
private DeviceEntity findByIdentifier(String value, com.baomidou.mybatisplus.core.toolkit.support.SFunction<DeviceEntity, ?> column) {
if (StringUtils.isBlank(value)) {
return null;
}
return deviceService.lambdaQuery()
.eq(column, value)
.eq(DeviceEntity::getDeleteFlag, "UNDEL")
.last("LIMIT 1").one();
}
private static Long deviceIdFrom(HttpServletRequest request) {
return (Long) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_ID);
}
private static String channelOrgCodeFrom(HttpServletRequest request) {
DeviceSignResult signResult =
(DeviceSignResult) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_SIGN_RESULT);
return signResult != null ? signResult.getOrgCode() : null;
}
private static String trim(String s) {
return s == null ? null : s.trim();
}
private static Map<String, Object> buildSuccessResponse(Map<String, Object> data) {
Map<String, Object> result = new HashMap<>();
result.put("code", 0);
result.put("msg", "success");
result.put("data", data != null ? data : new HashMap<>());
return result;
}
private static Map<String, Object> rrExceptionResponse(ASException e) {
Map<String, Object> err = new HashMap<>();
err.put("code", e.getCode());
err.put("msg", e.getMessage());
err.put("data", new HashMap<>());
return err;
}
}