初始化天卡开放平台后端项目

包含完整功能模块:
- 设备激活(直销激活码模式 + 代销免激活码模式)
- 微信支付 + 天卡订单
- 天卡池管理 + 异步橘视厅SDK激活
- 第三方合作伙伴接口(代销模式)
  - 签名鉴权(appKey/appSecret + nonce防重放)
  - 天卡消耗(事务 + 幂等 + 并发控制)
  - 媒资接口(三级授权过滤)
  - 审计日志
- 定价策略(3层:门店 > 渠道 > 平台)
- 分账(微信分账 + 分润规则)
- Docker部署(含Redis容器)
This commit is contained in:
zhaogd
2026-07-26 12:34:13 +08:00
commit 30198c7191
349 changed files with 28837 additions and 0 deletions
@@ -0,0 +1,418 @@
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);
}
/**
* 设备心跳:更新设备心跳时间,续期 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;
}
}