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

包含完整功能模块:
- 设备激活(直销激活码模式 + 代销免激活码模式)
- 微信支付 + 天卡订单
- 天卡池管理 + 异步橘视厅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
+46
View File
@@ -0,0 +1,46 @@
package io.as.modules.openapi.config;
import io.as.common.utils.RedisUtils;
import io.as.modules.openapi.filter.ApiDeviceSignFilter;
import io.as.modules.openapi.filter.PartnerSignFilter;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.openapi.service.OpenApiPartnerService;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* API 模块配置:注册设备 sign 校验过滤器 + 合作伙伴签名校验过滤器
* <p>对齐生产环境 as-partner-backend 的 ApiConfig</p>
*/
@Configuration
public class ApiConfig {
/**
* 设备 sign 校验过滤器:/api 下除 POST /api/jst/init、/api/payment/** 外均需请求头 sign,否则拒绝
*/
@Bean
public FilterRegistrationBean<ApiDeviceSignFilter> apiDeviceSignFilterRegistration(ApiDeviceSignService apiDeviceSignService) {
FilterRegistrationBean<ApiDeviceSignFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new ApiDeviceSignFilter(apiDeviceSignService));
registration.addUrlPatterns("/api/*");
registration.setOrder(Integer.MAX_VALUE - 100);
registration.setName("apiDeviceSignFilter");
return registration;
}
/**
* 合作伙伴签名校验过滤器:/openapi/partner/** 需请求头 appKey/timestamp/nonce/sign
* <p>签名算法:MD5(appKey + timestamp + nonce + appSecret).toUpperCase()</p>
*/
@Bean
public FilterRegistrationBean<PartnerSignFilter> partnerSignFilterRegistration(
OpenApiPartnerService openApiPartnerService, RedisUtils redisUtils) {
FilterRegistrationBean<PartnerSignFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new PartnerSignFilter(openApiPartnerService, redisUtils));
registration.addUrlPatterns("/openapi/partner/*");
registration.setOrder(Integer.MAX_VALUE - 100);
registration.setName("partnerSignFilter");
return registration;
}
}
@@ -0,0 +1,48 @@
package io.as.modules.openapi.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.ThreadPoolExecutor;
/**
* OpenAPI 配置类
* <p>设备 sign 校验过滤器已在 {@link ApiConfig} 中注册</p>
* <p>注:RestTemplate 与 thirdPartyActivateExecutor 已分别在 ApiConfig、AsyncConfig 中注册,
* 此处使用 @ConditionalOnMissingBean 确保不重复注册,若上述 Bean 已存在则自动跳过。</p>
*/
@Configuration
public class OpenApiConfig {
/**
* RestTemplate:连接超时10s,读取超时30s
*/
@Bean
@ConditionalOnMissingBean(RestTemplate.class)
public RestTemplate openApiRestTemplate(RestTemplateBuilder builder) {
return builder.setConnectTimeout(java.time.Duration.ofSeconds(10))
.setReadTimeout(java.time.Duration.ofSeconds(30))
.build();
}
/**
* 第三方激活专用线程池:核心2,最大8,队列100,CallerRunsPolicy
*/
@Bean("thirdPartyActivateExecutor")
@ConditionalOnMissingBean(name = "thirdPartyActivateExecutor")
public TaskExecutor openApiThirdPartyActivateExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("thirdparty-activate-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@@ -0,0 +1,16 @@
package io.as.modules.openapi.constants;
/**
* 开放API常量
* <p>注:旧的 PartnerSign 四头签名机制已废弃,相关常量已移除。设备 sign 鉴权使用单 sign 头,详见 ApiDeviceSignService。</p>
*/
public final class OpenApiConstants {
private OpenApiConstants() {
}
/** 状态:启用 */
public static final String STATUS_ACTIVE = "ACTIVE";
/** 状态:禁用 */
public static final String STATUS_DISABLED = "DISABLED";
}
@@ -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;
}
}
@@ -0,0 +1,253 @@
package io.as.modules.openapi.controller;
import io.as.modules.device.dto.DeviceOrgContext;
import io.as.modules.device.service.DeviceResolveService;
import io.as.modules.openapi.filter.ApiDeviceSignFilter;
import io.as.modules.sys.entity.SysOrgEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 橘视听 App UI 数据接口
* <p>提供顶栏按钮、筛选分类、筛选内容列表、独立配置等 App 展示所需数据</p>
* <p>所有接口均需请求头 sign(由 ApiDeviceSignFilter 校验)</p>
* <p>返回格式:{code:0, msg:"success", data:{...}}</p>
*/
@RestController
@RequestMapping("/api/jst")
public class ApiJstUiController {
private static final Logger logger = LoggerFactory.getLogger(ApiJstUiController.class);
@Autowired
private DeviceResolveService deviceResolveService;
@Value("${renren.daycard.ui.logo-url:https://daycard.oss.cn/logo.png}")
private String defaultLogoUrl;
@Value("${renren.daycard.ui.login-bg-url:https://daycard.oss.cn/login_bg.png}")
private String defaultLoginBgUrl;
@Value("${renren.daycard.ui.search-normal-url:https://daycard.oss.cn/search_normal.png}")
private String searchNormalUrl;
@Value("${renren.daycard.ui.search-focus-url:https://daycard.oss.cn/search_focus.png}")
private String searchFocusUrl;
@Value("${renren.daycard.ui.help-normal-url:https://daycard.oss.cn/help_normal.png}")
private String helpNormalUrl;
@Value("${renren.daycard.ui.help-focus-url:https://daycard.oss.cn/help_focus.png}")
private String helpFocusUrl;
// ==================== 1. 顶栏按钮 ====================
/**
* 顶栏按钮数据:返回 logo + 搜索 + 帮助
* <p>logo 优先取设备所属渠道的 brand_logo_url,无则用全局默认</p>
*/
@PostMapping("/topButtons")
public Map<String, Object> topButtons(@RequestBody(required = false) Map<String, Object> body,
HttpServletRequest request) {
Long deviceId = deviceIdFrom(request);
String logoUrl = resolveLogoUrl(deviceId);
List<Map<String, Object>> buttons = new ArrayList<>();
// type=1 Logo(仅 back_url)
Map<String, Object> logoBtn = new HashMap<>();
logoBtn.put("type", 1);
logoBtn.put("back_url", logoUrl);
buttons.add(logoBtn);
// type=3 搜索(back_url + focus_picurl)
Map<String, Object> searchBtn = new HashMap<>();
searchBtn.put("type", 3);
searchBtn.put("back_url", searchNormalUrl);
searchBtn.put("focus_picurl", searchFocusUrl);
buttons.add(searchBtn);
// type=6 帮助(back_url + focus_picurl)
Map<String, Object> helpBtn = new HashMap<>();
helpBtn.put("type", 6);
helpBtn.put("back_url", helpNormalUrl);
helpBtn.put("focus_picurl", helpFocusUrl);
buttons.add(helpBtn);
return buildJstResponse(buttons);
}
// ==================== 2. 筛选分类 ====================
/**
* 筛选分类:返回栏目/类型/地区/年代四级分类
* <p>当前为固定分类数据,后续可对接橘视厅媒资 API</p>
*/
@PostMapping("/filter/tags")
public Map<String, Object> filterTags(@RequestBody(required = false) Map<String, Object> body) {
List<Map<String, Object>> series = new ArrayList<>();
// type=1 栏目
series.add(buildSeries(1, "全部栏目", new String[][]{
{"all", "全部"}, {"movie", "电影"}, {"tv", "电视剧"}, {"variety", "综艺"}, {"anime", "动漫"}
}));
// type=2 类型
series.add(buildSeries(2, "全部类型", new String[][]{
{"all", "全部"}, {"action", "动作"}, {"comedy", "喜剧"}, {"romance", "爱情"},
{"scifi", "科幻"}, {"suspense", "悬疑"}, {"animation", "动画"}
}));
// type=3 地区
series.add(buildSeries(3, "全部地区", new String[][]{
{"all", "全部"}, {"cn", "内地"}, {"hk", "中国香港"}, {"tw", "中国台湾"},
{"us", "美国"}, {"kr", "韩国"}, {"jp", "日本"}, {"uk", "英国"}
}));
// type=4 年代
series.add(buildSeries(4, "全部年代", new String[][]{
{"all", "全部"}, {"2026", "2026"}, {"2025", "2025"}, {"2024", "2024"},
{"2023", "2023"}, {"2022", "2022"}, {"2021", "2021"}
}));
Map<String, Object> data = new HashMap<>();
data.put("series", series);
return buildJstResponse(data);
}
// ==================== 3. 筛选内容列表 ====================
/**
* 筛选内容列表:根据筛选条件返回内容列表
* <p>TODO: 后续对接橘视厅媒资 API 或自建媒资表,当前返回 mock 数据骨架</p>
*/
@PostMapping("/filter/content")
public Map<String, Object> filterContent(@RequestBody Map<String, Object> body) {
int page = parseInt(body.get("page"), 1);
int pageSize = parseInt(body.get("pageSize"), 20);
// TODO: 对接真实媒资数据源,当前返回 mock 数据
List<Map<String, Object>> list = new ArrayList<>();
String[][] mockContents = {
{"MOV001", "测试影片一", "https://daycard.oss.cn/poster/1.jpg", "8", "true"},
{"MOV002", "测试影片二", "https://daycard.oss.cn/poster/2.jpg", "1", "false"},
{"MOV003", "测试影片三", "https://daycard.oss.cn/poster/3.jpg", "8", "true"},
{"MOV004", "测试影片四", "https://daycard.oss.cn/poster/4.jpg", "1", "false"},
{"MOV005", "测试影片五", "https://daycard.oss.cn/poster/5.jpg", "8", "true"}
};
for (String[] item : mockContents) {
Map<String, Object> content = new HashMap<>();
content.put("contentCode", item[0]);
content.put("title", item[1]);
content.put("posterUrl", item[2]);
content.put("parentContentType", item[3]);
content.put("isFree", Boolean.parseBoolean(item[4]));
list.add(content);
}
Map<String, Object> data = new HashMap<>();
data.put("total", 5);
data.put("currentPage", page);
data.put("totalPage", 1);
data.put("list", list);
return buildJstResponse(data);
}
// ==================== 4. 独立配置接口 ====================
/**
* 独立配置:返回 logoUrl + loginBg
* <p>logoUrl 优先取设备所属渠道的 brand_logo_url</p>
*/
@GetMapping("/config")
public Map<String, Object> config(@RequestParam(required = false) String deviceId, HttpServletRequest request) {
Long devId = deviceIdFrom(request);
if (devId == null && deviceId != null) {
try {
devId = Long.parseLong(deviceId);
} catch (NumberFormatException ignored) {
}
}
String logoUrl = resolveLogoUrl(devId);
Map<String, Object> data = new HashMap<>();
data.put("logoUrl", logoUrl);
data.put("loginBg", defaultLoginBgUrl);
return buildJstResponse(data);
}
// ==================== 内部方法 ====================
/**
* 从设备所属渠道解析 logoUrl,无则用全局默认
*/
private String resolveLogoUrl(Long deviceId) {
if (deviceId == null) {
return defaultLogoUrl;
}
try {
DeviceOrgContext ctx = deviceResolveService.resolveDevice(deviceId);
SysOrgEntity channelOrg = ctx.getChannelOrg();
if (channelOrg != null && channelOrg.getBrandLogoUrl() != null
&& !channelOrg.getBrandLogoUrl().trim().isEmpty()) {
return channelOrg.getBrandLogoUrl().trim();
}
} catch (Exception e) {
logger.warn("解析设备渠道logo失败,使用默认logo: deviceId={}", deviceId, e);
}
return defaultLogoUrl;
}
/**
* 构建筛选分类维度
*/
private Map<String, Object> buildSeries(int type, String name, String[][] items) {
Map<String, Object> series = new HashMap<>();
series.put("type", type);
series.put("name", name);
List<Map<String, String>> itemList = new ArrayList<>();
for (String[] item : items) {
Map<String, String> m = new HashMap<>();
m.put("id", item[0]);
m.put("name", item[1]);
itemList.add(m);
}
series.put("items", itemList);
return series;
}
/**
* 构建统一响应
*/
private static Map<String, Object> buildJstResponse(Object data) {
Map<String, Object> result = new HashMap<>();
result.put("code", 0);
result.put("msg", "success");
result.put("data", data);
return result;
}
private static Long deviceIdFrom(HttpServletRequest request) {
return (Long) request.getAttribute(ApiDeviceSignFilter.REQUEST_ATTR_DEVICE_ID);
}
private static int parseInt(Object val, int defaultValue) {
if (val == null) {
return defaultValue;
}
try {
return Integer.parseInt(String.valueOf(val));
} catch (NumberFormatException e) {
return defaultValue;
}
}
}
@@ -0,0 +1,219 @@
package io.as.modules.openapi.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.media.client.JetsenMediaClient;
import io.as.modules.openapi.entity.ContentPackageEntity;
import io.as.modules.openapi.service.ContentPackageService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 内容包管理
*/
@Api(tags = "内容包管理")
@RestController
@RequestMapping("/manage/content-package")
public class ContentPackageController {
@Autowired
private ContentPackageService contentPackageService;
@Autowired
private JetsenMediaClient jetsenMediaClient;
/**
* 分页查询内容包列表
*/
@ApiOperation("分页查询内容包列表")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = contentPackageService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 内容包详情
*/
@ApiOperation("内容包详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
ContentPackageEntity contentPackage = contentPackageService.getById(id);
Map<String, Object> data = new HashMap<>();
data.put("contentPackage", contentPackage);
data.put("contentList", parseContentList(contentPackage));
return R.dataSuccess(data);
}
/**
* 按合作伙伴ID查询内容包授权配置
*/
@ApiOperation("按合作伙伴ID查询内容包授权配置")
@GetMapping("/info-by-partner/{partnerId}")
public R infoByPartner(@PathVariable("partnerId") String partnerId) {
ContentPackageEntity contentPackage = contentPackageService.getByPartnerId(partnerId);
Map<String, Object> data = new HashMap<>();
data.put("contentPackage", contentPackage);
data.put("contentList", parseContentList(contentPackage));
return R.dataSuccess(data);
}
/**
* 新增内容包
*/
@ApiOperation("新增内容包")
@AuditAction(value = "PACKAGE_SAVE", description = "新增内容包")
@PostMapping("/save")
public R save(@RequestBody ContentPackageEntity contentPackage) {
Date now = new Date();
contentPackage.setDeleteFlag("UNDEL");
contentPackage.setCreatedAt(now);
contentPackage.setUpdatedAt(now);
contentPackageService.saveOrUpdatePackage(contentPackage);
Map<String, Object> data = new HashMap<>();
data.put("contentPackage", contentPackage);
return R.dataSuccess(data);
}
/**
* 修改内容包
*/
@ApiOperation("修改内容包")
@AuditAction(value = "PACKAGE_UPDATE", description = "修改内容包")
@PostMapping("/update")
public R update(@RequestBody ContentPackageEntity contentPackage) {
if (contentPackage.getId() == null) {
throw new ASException("id不能为空");
}
contentPackage.setUpdatedAt(new Date());
contentPackageService.updateById(contentPackage);
return R.ok();
}
/**
* 删除内容包(软删除)
*/
@ApiOperation("删除内容包")
@AuditAction(value = "PACKAGE_DELETE", description = "删除内容包")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
for (Long id : ids) {
ContentPackageEntity contentPackage = new ContentPackageEntity();
contentPackage.setId(id);
contentPackage.setDeleteFlag("DEL");
contentPackage.setUpdatedAt(new Date());
contentPackageService.updateById(contentPackage);
}
return R.ok();
}
// ==================== 内容选择辅助接口 ====================
/** 解析 content_codes JSON 字符串为数组,方便前端展示 */
private List<Object> parseContentList(ContentPackageEntity pkg) {
if (pkg == null || pkg.getContentCodes() == null || pkg.getContentCodes().isEmpty()) {
return new ArrayList<>();
}
try {
return JSON.parseArray(pkg.getContentCodes());
} catch (Exception e) {
return new ArrayList<>();
}
}
/**
* 拉取栏目内容预览:从橘视厅实时拉取指定栏目的内容,返回精简字段供管理员勾选
*/
@ApiOperation("拉取栏目内容预览(供选择)")
@GetMapping("/fetch-contents")
public R fetchContents(@RequestParam String catalogid,
@RequestParam(defaultValue = "1") int pageindex,
@RequestParam(defaultValue = "50") int pagesize) {
JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize);
List<Map<String, Object>> list = new ArrayList<>();
Object result = resp.get("result");
if (result instanceof JSONArray) {
for (Object o : (JSONArray) result) {
if (o instanceof JSONObject) {
JSONObject item = (JSONObject) o;
Map<String, Object> simplified = new LinkedHashMap<>();
simplified.put("contentcode", item.getString("contentcode"));
simplified.put("name", item.getString("name"));
simplified.put("posterimg", item.getString("posterimg"));
simplified.put("categoryname", item.getString("categoryname"));
simplified.put("score", item.getString("score"));
simplified.put("isFree", item.getString("isFree"));
simplified.put("episodecount", item.get("episodecount"));
simplified.put("duration", item.get("duration"));
simplified.put("releaseyear", item.getString("releaseyear"));
simplified.put("originalcountry", item.getString("originalcountry"));
list.add(simplified);
}
}
}
Map<String, Object> data = new HashMap<>();
data.put("list", list);
data.put("totalCount", resp.getIntValue("sizetotal"));
data.put("totalPage", resp.getIntValue("pagetotal"));
data.put("currPage", pageindex);
data.put("pageSize", pagesize);
return R.dataSuccess(data);
}
/**
* 选择内容保存内容包:管理员勾选内容后一键保存,自动序列化 content_codes
* <p>请求体:{partnerId, name, catalogIds(可选), selectedContents:[{contentcode,name,posterimg,...}]}</p>
*/
@ApiOperation("选择内容保存内容包")
@AuditAction(value = "PACKAGE_SAVE_FROM_SELECTION", description = "选择内容保存内容包")
@PostMapping("/save-from-selection")
public R saveFromSelection(@RequestBody Map<String, Object> body) {
String partnerId = (String) body.get("partnerId");
if (partnerId == null || partnerId.trim().isEmpty()) {
throw new ASException("partnerId不能为空");
}
String name = body.get("name") != null ? String.valueOf(body.get("name")) : null;
String catalogIds = body.get("catalogIds") != null ? String.valueOf(body.get("catalogIds")) : null;
// 勾选的内容对象列表 -> 序列化为JSON字符串存入 content_codes
Object selectedContents = body.get("selectedContents");
String contentCodes = null;
if (selectedContents != null) {
contentCodes = JSON.toJSONString(selectedContents);
}
ContentPackageEntity entity = null;
if (body.get("id") != null) {
entity = contentPackageService.getById(Long.valueOf(String.valueOf(body.get("id"))));
} else {
entity = contentPackageService.getByPartnerId(partnerId);
}
boolean isNew = entity == null;
if (isNew) {
entity = new ContentPackageEntity();
entity.setPartnerId(partnerId);
entity.setDeleteFlag("UNDEL");
entity.setCreatedAt(new Date());
}
if (name != null) entity.setName(name);
entity.setCatalogIds(catalogIds);
entity.setContentCodes(contentCodes);
entity.setStatus("ACTIVE");
entity.setUpdatedAt(new Date());
contentPackageService.saveOrUpdatePackage(entity);
Map<String, Object> data = new HashMap<>();
data.put("contentPackage", entity);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,36 @@
package io.as.modules.openapi.controller;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.openapi.service.OpenApiCallLogService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 开放API调用日志管理(管理后台,JWT鉴权)
*/
@Api(tags = "开放API调用日志")
@RestController
@RequestMapping("/manage/openapi/call-log")
public class OpenApiCallLogController {
@Autowired
private OpenApiCallLogService openApiCallLogService;
@ApiOperation("分页查询调用日志")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = openApiCallLogService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,286 @@
package io.as.modules.openapi.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import io.as.common.utils.IPUtils;
import io.as.common.utils.R;
import io.as.modules.media.client.JetsenMediaClient;
import io.as.modules.openapi.entity.ContentPackageEntity;
import io.as.modules.openapi.filter.PartnerSignFilter;
import io.as.modules.openapi.service.ContentPackageService;
import io.as.modules.openapi.service.OpenApiCallLogService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
/**
* 开放API - 媒资接口(第三方合作伙伴调用,appKey/appSecret 签名鉴权)
* <p>三级授权过滤:content_codes(单部内容) > catalog_ids(栏目) > 全量</p>
* <p>鉴权由 PartnerSignFilter 完成,本 Controller 从 request 属性取 partnerId。</p>
*/
@Api(tags = "开放API-媒资接口")
@RestController
@RequestMapping("/openapi/partner/media")
public class OpenApiMediaController {
private static final Logger logger = LoggerFactory.getLogger(OpenApiMediaController.class);
@Autowired
private JetsenMediaClient jetsenMediaClient;
@Autowired
private ContentPackageService contentPackageService;
@Autowired
private OpenApiCallLogService openApiCallLogService;
// ==================== 授权解析 ====================
private String partnerIdFrom(HttpServletRequest req) {
return (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID);
}
/** 解析授权内容code集合,非空表示CONTENT级别 */
private Set<String> parseContentCodes(ContentPackageEntity pkg) {
if (pkg == null || StringUtils.isBlank(pkg.getContentCodes())) {
return null;
}
try {
JSONArray arr = JSON.parseArray(pkg.getContentCodes());
Set<String> codes = new HashSet<>();
for (Object o : arr) {
if (o instanceof JSONObject) {
String code = ((JSONObject) o).getString("contentcode");
if (code != null) {
codes.add(code);
}
}
}
return codes.isEmpty() ? null : codes;
} catch (Exception e) {
return null;
}
}
/** 解析授权栏目id集合,非空表示CATALOG级别 */
private Set<String> parseCatalogIds(ContentPackageEntity pkg) {
if (pkg == null || StringUtils.isBlank(pkg.getCatalogIds())) {
return null;
}
Set<String> ids = new HashSet<>();
for (String s : pkg.getCatalogIds().split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
ids.add(t);
}
}
return ids.isEmpty() ? null : ids;
}
/** 判定授权级别 */
private String scopeOf(ContentPackageEntity pkg) {
if (parseContentCodes(pkg) != null) {
return "CONTENT";
}
if (parseCatalogIds(pkg) != null) {
return "CATALOG";
}
return "FULL";
}
// ==================== 接口 ====================
/** 查看我的内容包授权配置 */
@ApiOperation("我的内容包配置")
@GetMapping("/package")
public R packageInfo(HttpServletRequest req) {
long start = System.currentTimeMillis();
String partnerId = partnerIdFrom(req);
ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId);
Map<String, Object> data = new HashMap<>();
if (pkg == null) {
data.put("scope", "FULL");
data.put("catalogIds", null);
data.put("contentCount", 0);
} else {
Set<String> codes = parseContentCodes(pkg);
data.put("scope", scopeOf(pkg));
data.put("catalogIds", pkg.getCatalogIds());
data.put("contentCount", codes != null ? codes.size() : 0);
}
logCall(req, "/package", "GET", 0, start);
return R.dataSuccess(data);
}
/** 栏目树(按授权过滤) */
@ApiOperation("栏目树")
@GetMapping("/catalog-tree")
public R catalogTree(HttpServletRequest req) {
long start = System.currentTimeMillis();
String partnerId = partnerIdFrom(req);
ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId);
Set<String> contentCodes = parseContentCodes(pkg);
Set<String> catalogIds = parseCatalogIds(pkg);
JSONArray tree;
if (contentCodes != null) {
// CONTENT级别:不按栏目,返回空树
tree = new JSONArray();
} else {
tree = jetsenMediaClient.getCatalogTree();
if (catalogIds != null) {
// CATALOG级别:只保留授权的二级栏目
JSONArray filtered = new JSONArray();
for (Object o : tree) {
JSONObject node = (JSONObject) o;
JSONArray children = node.getJSONArray("children");
JSONArray kept = new JSONArray();
if (children != null) {
for (Object c : children) {
JSONObject child = (JSONObject) c;
if (catalogIds.contains(String.valueOf(child.get("id")))) {
kept.add(child);
}
}
}
node.put("children", kept);
if (!kept.isEmpty()) {
filtered.add(node);
}
}
tree = filtered;
}
// FULL: 返回完整树
}
logCall(req, "/catalog-tree", "GET", 0, start);
Map<String, Object> data = new HashMap<>();
data.put("list", tree);
return R.dataSuccess(data);
}
/** 内容列表(按授权过滤) */
@ApiOperation("内容列表")
@GetMapping("/list")
public R list(@RequestParam(required = false) String catalogid,
@RequestParam(defaultValue = "1") int pageindex,
@RequestParam(defaultValue = "20") int pagesize,
HttpServletRequest req) {
long start = System.currentTimeMillis();
String partnerId = partnerIdFrom(req);
ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId);
Set<String> contentCodes = parseContentCodes(pkg);
Set<String> catalogIds = parseCatalogIds(pkg);
Map<String, Object> data = new HashMap<>();
if (contentCodes != null) {
// CONTENT级别:返回本地存储的授权内容,本地分页
JSONArray all = JSON.parseArray(pkg.getContentCodes());
int total = all.size();
int from = Math.max(0, (pageindex - 1) * pagesize);
int to = Math.min(from + pagesize, total);
data.put("list", from < total ? all.subList(from, to) : new ArrayList<>());
data.put("totalCount", total);
data.put("totalPage", (total + pagesize - 1) / pagesize);
} else if (catalogIds != null) {
// CATALOG级别:校验catalogid在授权范围内
if (StringUtils.isBlank(catalogid) || !catalogIds.contains(catalogid)) {
logCall(req, "/list", "GET", 400, start);
return R.error("catalogid不在授权范围内,可用栏目: " + pkg.getCatalogIds());
}
JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize);
data.put("list", extractList(resp));
data.put("totalCount", resp.getIntValue("sizetotal"));
data.put("totalPage", resp.getIntValue("pagetotal"));
} else {
// FULL级别:直接查
if (StringUtils.isBlank(catalogid)) {
logCall(req, "/list", "GET", 400, start);
return R.error("catalogid不能为空");
}
JSONObject resp = jetsenMediaClient.getContentList(catalogid, pageindex, pagesize);
data.put("list", extractList(resp));
data.put("totalCount", resp.getIntValue("sizetotal"));
data.put("totalPage", resp.getIntValue("pagetotal"));
}
data.put("currPage", pageindex);
data.put("pageSize", pagesize);
logCall(req, "/list", "GET", 0, start);
return R.dataSuccess(data);
}
/** 搜索(按授权过滤) */
@ApiOperation("搜索")
@GetMapping("/search")
public R search(@RequestParam String keyword,
@RequestParam(defaultValue = "1") int pageindex,
@RequestParam(defaultValue = "20") int pagesize,
HttpServletRequest req) {
long start = System.currentTimeMillis();
String partnerId = partnerIdFrom(req);
ContentPackageEntity pkg = contentPackageService.getByPartnerId(partnerId);
Set<String> contentCodes = parseContentCodes(pkg);
JSONObject resp = jetsenMediaClient.search(keyword, pageindex, pagesize);
List<JSONObject> list = extractList(resp);
Map<String, Object> data = new HashMap<>();
if (contentCodes != null) {
// CONTENT级别:按contentcode过滤搜索结果
list = new ArrayList<>(list);
list.removeIf(item -> !contentCodes.contains(item.getString("contentcode")));
data.put("list", list);
data.put("totalCount", list.size());
data.put("totalPage", 1);
} else {
// CATALOG/FULL:直接返回搜索结果(CATALOG级别在list接口已做栏目校验)
data.put("list", list);
data.put("totalCount", resp.getIntValue("sizetotal"));
data.put("totalPage", resp.getIntValue("pagetotal"));
}
data.put("currPage", pageindex);
data.put("pageSize", pagesize);
logCall(req, "/search", "GET", 0, start);
return R.dataSuccess(data);
}
// ==================== 内部方法 ====================
@SuppressWarnings("unchecked")
private List<JSONObject> extractList(JSONObject resp) {
List<JSONObject> list = new ArrayList<>();
if (resp == null) {
return list;
}
Object result = resp.get("result");
if (result instanceof JSONArray) {
for (Object o : (JSONArray) result) {
if (o instanceof JSONObject) {
list.add((JSONObject) o);
}
}
}
return list;
}
private void logCall(HttpServletRequest req, String api, String method, int resultCode, long startMs) {
try {
String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID);
String appKey = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_APP_KEY);
String params = req.getQueryString();
String ip = IPUtils.getIpAddr(req);
int costMs = (int) (System.currentTimeMillis() - startMs);
openApiCallLogService.saveLog(partnerId, appKey, "/openapi/partner/media" + api, method, params, resultCode, ip, costMs);
} catch (Exception e) {
logger.warn("调用日志记录失败: {}", e.getMessage());
}
}
}
@@ -0,0 +1,100 @@
package io.as.modules.openapi.controller;
import io.as.common.utils.IPUtils;
import io.as.common.utils.R;
import io.as.modules.openapi.filter.PartnerSignFilter;
import io.as.modules.openapi.service.OpenApiCallLogService;
import io.as.modules.openapi.service.PartnerCardPoolService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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.HashMap;
import java.util.Map;
/**
* 开放API - 合作伙伴天卡接口(代销模式,appKey/appSecret 签名鉴权)
* <p>第三方EPG调消耗接口:从partner池子分配天卡绑定设备,APK后续用同一设备标识调init/checkBenefits播放</p>
*/
@Api(tags = "开放API-天卡(代销)")
@RestController
@RequestMapping("/openapi/partner/card")
public class OpenApiPartnerCardController {
private static final Logger logger = LoggerFactory.getLogger(OpenApiPartnerCardController.class);
@Autowired
private PartnerCardPoolService partnerCardPoolService;
@Autowired
private OpenApiCallLogService openApiCallLogService;
/**
* 消耗天卡(代销模式)
* <p>第三方EPG客人付费后调用,从partner池子分配1张天卡绑定到设备,异步激活。</p>
* <p>APK后续用同一设备标识调 /api/jst/init(免activationCode)+ checkBenefits 拿天卡播放。</p>
*
* @param body {mac, deviceUid, deviceSn, channelOrgId}
*/
@ApiOperation("消耗天卡(代销)")
@PostMapping("/consume")
public R consume(@RequestBody Map<String, Object> body, HttpServletRequest req) {
long start = System.currentTimeMillis();
String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID);
String mac = body.get("mac") != null ? String.valueOf(body.get("mac")) : null;
String wifiMac = body.get("wifiMac") != null ? String.valueOf(body.get("wifiMac")) : null;
String deviceUid = body.get("deviceUid") != null ? String.valueOf(body.get("deviceUid")) : null;
String deviceSn = body.get("deviceSn") != null ? String.valueOf(body.get("deviceSn")) : null;
Long channelOrgId = body.get("channelOrgId") != null ? Long.valueOf(String.valueOf(body.get("channelOrgId"))) : null;
// 构建审计参数(记录设备标识,用于溯源)
String auditParams = "mac=" + mac + ",wifiMac=" + wifiMac + ",deviceSn=" + deviceSn + ",deviceUid=" + deviceUid;
int resultCode = 0;
try {
Map<String, Object> result = partnerCardPoolService.consume(partnerId, mac, wifiMac, deviceUid, deviceSn, channelOrgId);
return R.dataSuccess(result);
} catch (Exception e) {
resultCode = e instanceof io.as.common.exception.ASException ? ((io.as.common.exception.ASException) e).getCode() : 500;
throw e;
} finally {
logCall(req, "/card/consume", "POST", auditParams, resultCode, start);
}
}
/**
* 查询我的天卡库存
*/
@ApiOperation("我的天卡库存")
@GetMapping("/stock")
public R stock(HttpServletRequest req) {
long start = System.currentTimeMillis();
int resultCode = 0;
try {
String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID);
int available = partnerCardPoolService.getAvailableCount(partnerId);
Map<String, Object> data = new HashMap<>();
data.put("available", available);
return R.dataSuccess(data);
} catch (Exception e) {
resultCode = 500;
throw e;
} finally {
logCall(req, "/card/stock", "GET", null, resultCode, start);
}
}
private void logCall(HttpServletRequest req, String api, String method, String params, int resultCode, long startMs) {
try {
String partnerId = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_PARTNER_ID);
String appKey = (String) req.getAttribute(PartnerSignFilter.REQUEST_ATTR_APP_KEY);
String ip = IPUtils.getIpAddr(req);
int costMs = (int) (System.currentTimeMillis() - startMs);
openApiCallLogService.saveLog(partnerId, appKey, "/openapi/partner" + api, method, params, resultCode, ip, costMs);
} catch (Exception e) {
logger.warn("调用日志记录失败: {}", e.getMessage());
}
}
}
@@ -0,0 +1,112 @@
package io.as.modules.openapi.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.openapi.constants.OpenApiConstants;
import io.as.modules.openapi.entity.OpenApiPartnerEntity;
import io.as.modules.openapi.service.OpenApiPartnerService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 合作伙伴管理
*/
@Api(tags = "合作伙伴管理")
@RestController
@RequestMapping("/manage/partner")
public class OpenApiPartnerController {
@Autowired
private OpenApiPartnerService openApiPartnerService;
/**
* 分页查询合作伙伴列表
*/
@ApiOperation("分页查询合作伙伴列表")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = openApiPartnerService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 合作伙伴详情
*/
@ApiOperation("合作伙伴详情")
@GetMapping("/info/{id}")
public R info(@PathVariable("id") Long id) {
OpenApiPartnerEntity partner = openApiPartnerService.getById(id);
Map<String, Object> data = new HashMap<>();
data.put("partner", partner);
return R.dataSuccess(data);
}
/**
* 新增合作伙伴:自动生成 partnerId、appKey、appSecret
*/
@ApiOperation("新增合作伙伴")
@AuditAction(value = "PARTNER_SAVE", description = "新增合作伙伴")
@PostMapping("/save")
public R save(@RequestBody OpenApiPartnerEntity partner) {
String uuid = UUID.randomUUID().toString().replace("-", "");
partner.setPartnerId(uuid.substring(0, 16));
partner.setAppKey(UUID.randomUUID().toString().replace("-", "").substring(0, 16));
partner.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
if (partner.getStatus() == null) {
partner.setStatus(OpenApiConstants.STATUS_ACTIVE);
}
if (partner.getRateLimit() == null) {
partner.setRateLimit(100);
}
partner.setDeleteFlag("UNDEL");
partner.setCreatedAt(new Date());
partner.setUpdatedAt(new Date());
openApiPartnerService.save(partner);
Map<String, Object> data = new HashMap<>();
data.put("partner", partner);
return R.dataSuccess(data);
}
/**
* 修改合作伙伴
*/
@ApiOperation("修改合作伙伴")
@AuditAction(value = "PARTNER_UPDATE", description = "修改合作伙伴")
@PostMapping("/update")
public R update(@RequestBody OpenApiPartnerEntity partner) {
if (partner.getId() == null) {
throw new ASException("id不能为空");
}
partner.setUpdatedAt(new Date());
openApiPartnerService.updateById(partner);
return R.ok();
}
/**
* 删除合作伙伴(软删除)
*/
@ApiOperation("删除合作伙伴")
@AuditAction(value = "PARTNER_DELETE", description = "删除合作伙伴")
@PostMapping("/delete")
public R delete(@RequestBody Long[] ids) {
for (Long id : ids) {
OpenApiPartnerEntity partner = new OpenApiPartnerEntity();
partner.setId(id);
partner.setDeleteFlag("DEL");
partner.setUpdatedAt(new Date());
openApiPartnerService.updateById(partner);
}
return R.ok();
}
}
@@ -0,0 +1,90 @@
package io.as.modules.openapi.controller;
import io.as.common.annotation.AuditAction;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.R;
import io.as.modules.openapi.service.PartnerCardPoolService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 合作伙伴天卡池(代销)
*/
@Api(tags = "合作伙伴天卡池(代销)")
@RestController
@RequestMapping("/manage/partner-card")
public class PartnerCardPurchaseController {
@Autowired
private PartnerCardPoolService partnerCardPoolService;
/**
* 采购天卡到合作伙伴池子
*/
@ApiOperation("采购天卡")
@AuditAction(value = "PARTNER_CARD_PURCHASE", description = "采购天卡到合作伙伴池子")
@PostMapping("/purchase")
public R purchase(@RequestParam("partnerId") String partnerId,
@RequestParam("quantity") int quantity) {
if (partnerId == null || partnerId.trim().isEmpty()) {
throw new ASException("partnerId不能为空");
}
if (quantity <= 0) {
throw new ASException("quantity必须大于0");
}
int purchased = partnerCardPoolService.purchase(partnerId, quantity);
Map<String, Object> data = new HashMap<>();
data.put("purchased", purchased);
return R.dataSuccess(data);
}
/**
* 库存查询
*/
@ApiOperation("库存查询")
@GetMapping("/stock")
public R stock(@RequestParam("partnerId") String partnerId) {
int count = partnerCardPoolService.getAvailableCount(partnerId);
Map<String, Object> data = new HashMap<>();
data.put("available", count);
return R.dataSuccess(data);
}
/**
* 分页查询池子记录
*/
@ApiOperation("分页查询池子记录")
@GetMapping("/list")
public R list(@RequestParam Map<String, Object> params) {
PageUtils page = partnerCardPoolService.queryPage(params);
Map<String, Object> data = new HashMap<>();
data.put("page", page);
return R.dataSuccess(data);
}
/**
* 回退天卡:把partner池子里未消耗(AVAILABLE)的天卡回退到平台天卡池
*/
@ApiOperation("回退天卡")
@AuditAction(value = "PARTNER_CARD_REVERT", description = "回退天卡到平台池子")
@PostMapping("/revert")
public R revert(@RequestParam("partnerId") String partnerId,
@RequestParam("quantity") int quantity) {
if (partnerId == null || partnerId.trim().isEmpty()) {
throw new ASException("partnerId不能为空");
}
if (quantity <= 0) {
throw new ASException("回退数量必须大于0");
}
int reverted = partnerCardPoolService.revert(partnerId, quantity);
Map<String, Object> data = new HashMap<>();
data.put("reverted", reverted);
return R.dataSuccess(data);
}
}
@@ -0,0 +1,12 @@
package io.as.modules.openapi.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.openapi.entity.ContentPackageEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 开放API内容包授权
*/
@Mapper
public interface ContentPackageDao extends BaseMapper<ContentPackageEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.openapi.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.openapi.entity.OpenApiCallLogEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 开放API调用日志
*/
@Mapper
public interface OpenApiCallLogDao extends BaseMapper<OpenApiCallLogEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.openapi.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.openapi.entity.OpenApiPartnerEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 开放API合作伙伴
*/
@Mapper
public interface OpenApiPartnerDao extends BaseMapper<OpenApiPartnerEntity> {
}
@@ -0,0 +1,12 @@
package io.as.modules.openapi.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.as.modules.openapi.entity.PartnerCardPoolEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 合作伙伴天卡池
*/
@Mapper
public interface PartnerCardPoolDao extends BaseMapper<PartnerCardPoolEntity> {
}
@@ -0,0 +1,37 @@
package io.as.modules.openapi.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 开放API内容包授权表
*/
@Data
@TableName("open_api_content_package")
public class ContentPackageEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 合作伙伴ID */
private String partnerId;
/** 内容包名称 */
private String name;
/** 目录ID集合 */
private String catalogIds;
/** 内容编码集合 */
private String contentCodes;
/** 状态:ACTIVE / DISABLED */
private String status;
/** 删除标记 */
private String deleteFlag;
/** 创建时间 */
private Date createdAt;
/** 更新时间 */
private Date updatedAt;
}
@@ -0,0 +1,39 @@
package io.as.modules.openapi.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 开放API调用日志表
*/
@Data
@TableName("open_api_call_log")
public class OpenApiCallLogEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 合作伙伴ID */
private String partnerId;
/** 应用Key */
private String appKey;
/** 调用API */
private String api;
/** HTTP方法 */
private String httpMethod;
/** 请求参数 */
private String params;
/** 结果码 */
private Integer resultCode;
/** 调用方IP */
private String ip;
/** 耗时(毫秒) */
private Integer costMs;
/** 创建时间 */
private Date createdAt;
}
@@ -0,0 +1,41 @@
package io.as.modules.openapi.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 开放API合作伙伴表
*/
@Data
@TableName("open_api_partner")
public class OpenApiPartnerEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 合作伙伴ID */
private String partnerId;
/** 应用Key */
private String appKey;
/** 应用密钥 */
private String appSecret;
/** 合作伙伴名称 */
private String name;
/** 状态:ACTIVE / DISABLED */
private String status;
/** API权限(JSON) */
private String apiPermissions;
/** 速率限制(默认100) */
private Integer rateLimit;
/** 删除标记 */
private String deleteFlag;
/** 创建时间 */
private Date createdAt;
/** 更新时间 */
private Date updatedAt;
}
@@ -0,0 +1,49 @@
package io.as.modules.openapi.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 合作伙伴天卡池
*/
@Data
@TableName("open_api_partner_card_pool")
public class PartnerCardPoolEntity implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
/** 合作伙伴ID */
private String partnerId;
/** 天卡池ID */
private Long poolId;
/** 卡编码 */
private String cardCode;
/** 第三方卡号 */
private String thirdCardNo;
/** 状态:AVAILABLE / CONSUMED */
private String status;
/** 设备ID */
private Long deviceId;
/** 渠道机构ID */
private Long channelOrgId;
/** 采购批次 */
private String purchaseBatch;
/** 消费时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date consumedAt;
/** 删除标记 */
private String deleteFlag;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createdAt;
/** 更新时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updatedAt;
}
@@ -0,0 +1,100 @@
package io.as.modules.openapi.filter;
import com.google.gson.Gson;
import io.as.common.utils.HttpContextUtils;
import io.as.common.utils.R;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.openapi.sign.DeviceSignResult;
import org.apache.commons.lang.StringUtils;
import org.apache.http.HttpStatus;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 设备 sign 校验:除 POST /api/jst/init 外,所有以 /api 开头的请求必须在请求头携带 sign,否则拒绝。
* <p>校验通过后将 deviceId 和 DeviceSignResult 放入 request 属性,供后续接口使用。</p>
* <p>对齐生产环境 as-partner-backend 的 ApiDeviceSignFilter</p>
*/
public class ApiDeviceSignFilter extends OncePerRequestFilter {
public static final String REQUEST_ATTR_DEVICE_ID = "deviceId";
public static final String REQUEST_ATTR_DEVICE_SIGN_RESULT = "deviceSignResult";
public static final String HEADER_SIGN = "sign";
private final ApiDeviceSignService apiDeviceSignService;
public ApiDeviceSignFilter(ApiDeviceSignService apiDeviceSignService) {
this.apiDeviceSignService = apiDeviceSignService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = contextRelativePath(request);
if (path == null || !path.startsWith("/api")) {
filterChain.doFilter(request, response);
return;
}
// init 接口不校验 sign(init 返回 sign)
if ("POST".equalsIgnoreCase(request.getMethod())
&& "/api/jst/init".equals(removeTrailingSlash(path))) {
filterChain.doFilter(request, response);
return;
}
// 微信支付回调由微信服务器直接调用,无 sign
if (path.startsWith("/api/payment/")) {
filterChain.doFilter(request, response);
return;
}
String sign = request.getHeader(HEADER_SIGN);
if (StringUtils.isBlank(sign)) {
writeUnauthorized(response, "缺少 sign");
return;
}
DeviceSignResult result = apiDeviceSignService.verify(sign);
if (result == null) {
writeUnauthorized(response, "sign 无效或已过期");
return;
}
if (result.getDeviceId() == null) {
writeUnauthorized(response, "设备标识无效");
return;
}
request.setAttribute(REQUEST_ATTR_DEVICE_ID, result.getDeviceId());
request.setAttribute(REQUEST_ATTR_DEVICE_SIGN_RESULT, result);
filterChain.doFilter(request, response);
}
/** 去掉 context-path,与 Filter 匹配的 /api/** 语义一致 */
private static String contextRelativePath(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return null;
}
String ctx = request.getContextPath();
if (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) {
uri = uri.substring(ctx.length());
}
return uri.isEmpty() ? "/" : uri;
}
private static String removeTrailingSlash(String path) {
if (path == null || path.length() <= 1) {
return path;
}
return path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
}
private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
response.getWriter().print(new Gson().toJson(R.error(HttpStatus.SC_UNAUTHORIZED, msg)));
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,132 @@
package io.as.modules.openapi.filter;
import com.google.gson.Gson;
import io.as.common.utils.HttpContextUtils;
import io.as.common.utils.R;
import io.as.common.utils.RedisUtils;
import io.as.modules.openapi.entity.OpenApiPartnerEntity;
import io.as.modules.openapi.service.OpenApiPartnerService;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 合作伙伴 sign 校验过滤器
* <p>拦截 /openapi/partner/** 请求,校验请求头 appKey/timestamp/nonce/sign。</p>
* <p>签名算法:MD5(appKey + timestamp + nonce + appSecret).toUpperCase()</p>
* <p>防重放:timestamp 5分钟内有效 + nonce 5分钟内不可重复(Redis)</p>
* <p>校验通过后将 partnerId 放入 request 属性,供后续 Controller 使用。</p>
*/
public class PartnerSignFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(PartnerSignFilter.class);
public static final String REQUEST_ATTR_PARTNER_ID = "partnerId";
public static final String REQUEST_ATTR_APP_KEY = "appKey";
public static final String HEADER_APP_KEY = "appKey";
public static final String HEADER_TIMESTAMP = "timestamp";
public static final String HEADER_NONCE = "nonce";
public static final String HEADER_SIGN = "sign";
/** 时间戳有效期 5 分钟 */
private static final long TIMESTAMP_VALID_MS = 5 * 60 * 1000L;
/** nonce 缓存 key 前缀,与 daycard/openapi 隔离 */
private static final String NONCE_KEY_PREFIX = "openapi:partner:nonce:";
private final OpenApiPartnerService openApiPartnerService;
private final RedisUtils redisUtils;
private static final Gson GSON = new Gson();
public PartnerSignFilter(OpenApiPartnerService openApiPartnerService, RedisUtils redisUtils) {
this.openApiPartnerService = openApiPartnerService;
this.redisUtils = redisUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String ctx = request.getContextPath();
if (ctx != null && !ctx.isEmpty() && path.startsWith(ctx)) {
path = path.substring(ctx.length());
}
// 只拦截第三方接口路径前缀(排除 /openapi/partner/list|info|save|update|delete 等管理后台端点)
if (path == null || (!path.startsWith("/openapi/partner/media/")
&& !path.startsWith("/openapi/partner/card/")
&& !path.startsWith("/openapi/partner/device/")
&& !path.startsWith("/openapi/partner/order/"))) {
filterChain.doFilter(request, response);
return;
}
String appKey = request.getHeader(HEADER_APP_KEY);
String timestamp = request.getHeader(HEADER_TIMESTAMP);
String nonce = request.getHeader(HEADER_NONCE);
String sign = request.getHeader(HEADER_SIGN);
if (StringUtils.isBlank(appKey) || StringUtils.isBlank(timestamp)
|| StringUtils.isBlank(nonce) || StringUtils.isBlank(sign)) {
writeUnauthorized(response, "缺少鉴权参数(appKey/timestamp/nonce/sign)");
return;
}
// 1. 校验时间戳有效期
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
writeUnauthorized(response, "timestamp格式无效");
return;
}
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_VALID_MS) {
writeUnauthorized(response, "请求已过期");
return;
}
// 2. 查合作伙伴
OpenApiPartnerEntity partner = openApiPartnerService.getByAppKey(appKey);
if (partner == null || !"ACTIVE".equals(partner.getStatus())) {
writeUnauthorized(response, "appKey无效或已禁用");
return;
}
// 3. 校验签名: MD5(appKey + timestamp + nonce + appSecret).toUpperCase()
String expectedSign = DigestUtils.md5Hex(appKey + timestamp + nonce + partner.getAppSecret()).toUpperCase();
if (!expectedSign.equals(sign)) {
writeUnauthorized(response, "签名校验失败");
return;
}
// 4. nonce 防重放(原子操作:setIfAbsent 成功=首次,失败=重复)
String nonceKey = NONCE_KEY_PREFIX + nonce;
boolean isNew = redisUtils.setIfAbsent(nonceKey, "1", TIMESTAMP_VALID_MS / 1000);
if (!isNew) {
writeUnauthorized(response, "请求不可重复提交");
return;
}
// 5. 通过,放入 partnerId
request.setAttribute(REQUEST_ATTR_PARTNER_ID, partner.getPartnerId());
request.setAttribute(REQUEST_ATTR_APP_KEY, appKey);
filterChain.doFilter(request, response);
}
private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
logger.warn("合作伙伴鉴权失败: {}", msg);
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
response.getWriter().print(GSON.toJson(R.error(HttpStatus.SC_UNAUTHORIZED, msg)));
}
}
@@ -0,0 +1,53 @@
package io.as.modules.openapi.form;
import lombok.Data;
import java.io.Serializable;
/**
* 设备初始化请求
* <p>App 端只需传激活码 + 设备标识,后端通过激活码解析门店与渠道</p>
*/
@Data
public class DeviceInitRequest implements Serializable {
private static final long serialVersionUID = 1L;
/** 激活码(必填,管理后台按门店生成) */
private String activationCode;
/** 设备MAC */
private String mac;
/** WiFi MAC */
private String wifiMac;
/** 设备SN */
private String deviceSn;
/** 设备UID */
private String deviceUid;
/** Android ID(Settings.Secure.ANDROID_ID,重装不变) */
private String androidId;
/** 设备厂商 Build.MANUFACTURER */
private String manufacturer;
/** 设备型号 Build.MODEL */
private String model;
/** 设备品牌 Build.BRAND */
private String brand;
/** 版本号 */
private String verCode;
/** 版本名 */
private String verName;
/** 安卓版本 */
private String androidVersion;
/** 设备型号 */
private String deviceModel;
/** 设备名称 */
private String deviceName;
}
@@ -0,0 +1,36 @@
package io.as.modules.openapi.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.openapi.entity.ContentPackageEntity;
import java.util.Map;
/**
* 开放API内容包授权
*/
public interface ContentPackageService extends IService<ContentPackageEntity> {
/**
* 分页查询内容包列表
*
* @param params 查询参数
* @return 分页结果
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据合作伙伴ID获取生效的内容包
*
* @param partnerId 合作伙伴ID
* @return 内容包实体,不存在返回 null
*/
ContentPackageEntity getByPartnerId(String partnerId);
/**
* 保存或更新内容包(按 partnerId 判断)
*
* @param entity 内容包实体
*/
void saveOrUpdatePackage(ContentPackageEntity entity);
}
@@ -0,0 +1,35 @@
package io.as.modules.openapi.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.openapi.entity.OpenApiCallLogEntity;
import java.util.Map;
/**
* 开放API调用日志
*/
public interface OpenApiCallLogService extends IService<OpenApiCallLogEntity> {
/**
* 分页查询调用日志
*
* @param params 查询参数
* @return 分页结果
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 保存调用日志
*
* @param partnerId 合作伙伴ID
* @param appKey 应用Key
* @param api 调用API
* @param method HTTP方法
* @param params 请求参数
* @param resultCode 结果码
* @param ip 调用方IP
* @param costMs 耗时(毫秒)
*/
void saveLog(String partnerId, String appKey, String api, String method, String params, Integer resultCode, String ip, Integer costMs);
}
@@ -0,0 +1,29 @@
package io.as.modules.openapi.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.openapi.entity.OpenApiPartnerEntity;
import java.util.Map;
/**
* 开放API合作伙伴
*/
public interface OpenApiPartnerService extends IService<OpenApiPartnerEntity> {
/**
* 分页查询合作伙伴列表
*
* @param params 查询参数
* @return 分页结果
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 根据 appKey 获取合作伙伴
*
* @param appKey 应用Key
* @return 合作伙伴实体,不存在返回 null
*/
OpenApiPartnerEntity getByAppKey(String appKey);
}
@@ -0,0 +1,88 @@
package io.as.modules.openapi.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.as.common.utils.PageUtils;
import io.as.modules.openapi.entity.PartnerCardPoolEntity;
import java.util.List;
import java.util.Map;
/**
* 合作伙伴天卡池
*/
public interface PartnerCardPoolService extends IService<PartnerCardPoolEntity> {
/**
* 分页查询天卡池列表
*
* @param params 查询参数
* @return 分页结果
*/
PageUtils queryPage(Map<String, Object> params);
/**
* 查询合作伙伴可用天卡数量
*
* @param partnerId 合作伙伴ID
* @return 可用天卡数量
*/
int getAvailableCount(String partnerId);
/**
* 获取合作伙伴可用天卡列表
*
* @param partnerId 合作伙伴ID
* @param limit 取卡数量
* @return 可用天卡列表
*/
List<PartnerCardPoolEntity> getAvailableCards(String partnerId, int limit);
/**
* 根据天卡池ID查询
*
* @param poolId 天卡池ID
* @return 天卡池实体,不存在返回 null
*/
PartnerCardPoolEntity getByPoolId(Long poolId);
/**
* 批量保存采购的天卡
*
* @param partnerId 合作伙伴ID
* @param purchaseBatch 采购批次
* @param cards 天卡列表
*/
void saveBatchCards(String partnerId, String purchaseBatch, List<PartnerCardPoolEntity> cards);
/**
* 采购天卡:从平台天卡池划拨指定数量到合作伙伴池子
*
* @param partnerId 合作伙伴ID
* @param quantity 采购数量
* @return 实际划拨数量
*/
int purchase(String partnerId, int quantity);
/**
* 消耗天卡(代销模式):从合作伙伴池子分配1张天卡,绑定到设备
* <p>APK后续用同一设备标识调 /api/jst/init(免activationCode)+ checkBenefits 拿天卡播放</p>
*
* @param partnerId 合作伙伴ID(由PartnerSignFilter注入)
* @param mac 设备MAC(与wifiMac/deviceUid/deviceSn至少传一个)
* @param wifiMac WiFi MAC
* @param deviceUid 设备唯一标识
* @param deviceSn 设备序列号
* @param channelOrgId 渠道组织ID(第三方直接传递渠道身份)
* @return 天卡信息(deviceId/thirdCardNo/expireAt/status)
*/
Map<String, Object> consume(String partnerId, String mac, String wifiMac, String deviceUid, String deviceSn, Long channelOrgId);
/**
* 回退天卡:把partner池子里未消耗(AVAILABLE)的天卡回退到平台天卡池
*
* @param partnerId 合作伙伴ID
* @param quantity 回退数量
* @return 实际回退数量
*/
int revert(String partnerId, int quantity);
}
@@ -0,0 +1,65 @@
package io.as.modules.openapi.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.openapi.dao.ContentPackageDao;
import io.as.modules.openapi.entity.ContentPackageEntity;
import io.as.modules.openapi.service.ContentPackageService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 开放API内容包授权
*/
@Service("contentPackageService")
public class ContentPackageServiceImpl extends ServiceImpl<ContentPackageDao, ContentPackageEntity> implements ContentPackageService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
String name = (String) params.get("name");
String status = (String) params.get("status");
IPage<ContentPackageEntity> page = this.page(
new Query<ContentPackageEntity>().getPage(params),
new QueryWrapper<ContentPackageEntity>()
.like(StringUtils.isNotBlank(name), "name", name)
.eq(StringUtils.isNotBlank(status), "status", status)
.eq("delete_flag", "UNDEL")
.orderByDesc("created_at")
);
return new PageUtils(page);
}
@Override
public ContentPackageEntity getByPartnerId(String partnerId) {
return this.getOne(
new QueryWrapper<ContentPackageEntity>()
.eq("partner_id", partnerId)
.eq("status", "ACTIVE")
.eq("delete_flag", "UNDEL")
.last("LIMIT 1")
);
}
@Override
public void saveOrUpdatePackage(ContentPackageEntity entity) {
ContentPackageEntity exist = this.getOne(
new QueryWrapper<ContentPackageEntity>()
.eq("partner_id", entity.getPartnerId())
.eq("delete_flag", "UNDEL")
.last("LIMIT 1")
);
if (exist != null) {
entity.setId(exist.getId());
this.updateById(entity);
} else {
this.save(entity);
}
}
}
@@ -0,0 +1,53 @@
package io.as.modules.openapi.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.openapi.dao.OpenApiCallLogDao;
import io.as.modules.openapi.entity.OpenApiCallLogEntity;
import io.as.modules.openapi.service.OpenApiCallLogService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Map;
/**
* 开放API调用日志
*/
@Service("openApiCallLogService")
public class OpenApiCallLogServiceImpl extends ServiceImpl<OpenApiCallLogDao, OpenApiCallLogEntity> implements OpenApiCallLogService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
String partnerId = (String) params.get("partnerId");
String api = (String) params.get("api");
IPage<OpenApiCallLogEntity> page = this.page(
new Query<OpenApiCallLogEntity>().getPage(params),
new QueryWrapper<OpenApiCallLogEntity>()
.eq(StringUtils.isNotBlank(partnerId), "partner_id", partnerId)
.like(StringUtils.isNotBlank(api), "api", api)
.orderByDesc("created_at")
);
return new PageUtils(page);
}
@Override
public void saveLog(String partnerId, String appKey, String api, String method, String params, Integer resultCode, String ip, Integer costMs) {
OpenApiCallLogEntity entity = new OpenApiCallLogEntity();
entity.setPartnerId(partnerId);
entity.setAppKey(appKey);
entity.setApi(api);
entity.setHttpMethod(method);
entity.setParams(params);
entity.setResultCode(resultCode);
entity.setIp(ip);
entity.setCostMs(costMs);
entity.setCreatedAt(new Date());
this.save(entity);
}
}
@@ -0,0 +1,49 @@
package io.as.modules.openapi.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.modules.openapi.dao.OpenApiPartnerDao;
import io.as.modules.openapi.entity.OpenApiPartnerEntity;
import io.as.modules.openapi.service.OpenApiPartnerService;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 开放API合作伙伴
*/
@Service("openApiPartnerService")
public class OpenApiPartnerServiceImpl extends ServiceImpl<OpenApiPartnerDao, OpenApiPartnerEntity> implements OpenApiPartnerService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
String name = (String) params.get("name");
String partnerId = (String) params.get("partnerId");
String status = (String) params.get("status");
IPage<OpenApiPartnerEntity> page = this.page(
new Query<OpenApiPartnerEntity>().getPage(params),
new QueryWrapper<OpenApiPartnerEntity>()
.like(StringUtils.isNotBlank(name), "name", name)
.like(StringUtils.isNotBlank(partnerId), "partner_id", partnerId)
.eq(StringUtils.isNotBlank(status), "status", status)
.eq("delete_flag", "UNDEL")
.orderByDesc("created_at")
);
return new PageUtils(page);
}
@Override
public OpenApiPartnerEntity getByAppKey(String appKey) {
return this.getOne(
new QueryWrapper<OpenApiPartnerEntity>()
.eq("app_key", appKey)
.eq("delete_flag", "UNDEL")
);
}
}
@@ -0,0 +1,442 @@
package io.as.modules.openapi.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.as.common.exception.ASException;
import io.as.common.utils.PageUtils;
import io.as.common.utils.Query;
import io.as.common.utils.RedisUtils;
import io.as.modules.daycard.constants.DayCardConstants;
import io.as.modules.daycard.dao.DayCardPoolDao;
import io.as.modules.daycard.entity.DayCardPoolEntity;
import io.as.modules.device.dao.DeviceDao;
import io.as.modules.device.entity.DeviceEntity;
import io.as.modules.device.service.DeviceService;
import io.as.modules.jst.service.JstActivateCardResult;
import io.as.modules.jst.service.JushitingApiService;
import io.as.modules.openapi.dao.PartnerCardPoolDao;
import io.as.modules.openapi.entity.PartnerCardPoolEntity;
import io.as.modules.openapi.service.PartnerCardPoolService;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* 合作伙伴天卡池
*/
@Service("partnerCardPoolService")
public class PartnerCardPoolServiceImpl extends ServiceImpl<PartnerCardPoolDao, PartnerCardPoolEntity> implements PartnerCardPoolService {
private static final Logger logger = LoggerFactory.getLogger(PartnerCardPoolServiceImpl.class);
@Autowired
private DayCardPoolDao dayCardPoolDao;
@Autowired
private DeviceDao deviceDao;
@Autowired
private DeviceService deviceService;
@Autowired
private JushitingApiService jushitingApiService;
@Autowired
@Qualifier("thirdPartyActivateExecutor")
private TaskExecutor activateExecutor;
@Autowired
private RedisUtils redisUtils;
@Override
public PageUtils queryPage(Map<String, Object> params) {
String partnerId = (String) params.get("partnerId");
String status = (String) params.get("status");
String purchaseBatch = (String) params.get("purchaseBatch");
IPage<PartnerCardPoolEntity> page = this.page(
new Query<PartnerCardPoolEntity>().getPage(params),
new QueryWrapper<PartnerCardPoolEntity>()
.eq(StringUtils.isNotBlank(partnerId), "partner_id", partnerId)
.eq(StringUtils.isNotBlank(status), "status", status)
.eq(StringUtils.isNotBlank(purchaseBatch), "purchase_batch", purchaseBatch)
.eq("delete_flag", "UNDEL")
.orderByDesc("created_at")
);
return new PageUtils(page);
}
@Override
public int getAvailableCount(String partnerId) {
return Math.toIntExact(this.count(
new QueryWrapper<PartnerCardPoolEntity>()
.eq("partner_id", partnerId)
.eq("status", "AVAILABLE")
.eq("delete_flag", "UNDEL")
));
}
@Override
public List<PartnerCardPoolEntity> getAvailableCards(String partnerId, int limit) {
return this.list(
new QueryWrapper<PartnerCardPoolEntity>()
.eq("partner_id", partnerId)
.eq("status", "AVAILABLE")
.eq("delete_flag", "UNDEL")
.orderByAsc("id")
.last("LIMIT " + limit)
);
}
@Override
public PartnerCardPoolEntity getByPoolId(Long poolId) {
return this.getOne(
new QueryWrapper<PartnerCardPoolEntity>()
.eq("pool_id", poolId)
.last("LIMIT 1")
);
}
@Override
public void saveBatchCards(String partnerId, String purchaseBatch, List<PartnerCardPoolEntity> cards) {
this.saveBatch(cards);
}
// ==================== 采购 ====================
@Override
public int purchase(String partnerId, int quantity) {
if (quantity <= 0) {
throw new ASException("采购数量必须大于0", 400);
}
// 1. 从平台天卡池取 quantity 张 UNUSED 的卡(SKIP LOCKED 防阻塞)
List<DayCardPoolEntity> cards = dayCardPoolDao.selectList(
new QueryWrapper<DayCardPoolEntity>()
.eq("status", DayCardConstants.POOL_STATUS_UNUSED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.last("LIMIT " + quantity + " FOR UPDATE SKIP LOCKED")
);
if (cards.isEmpty()) {
throw new ASException("平台天卡池库存不足", 400);
}
String purchaseBatch = "PUR" + System.currentTimeMillis();
Date now = new Date();
for (DayCardPoolEntity card : cards) {
// 2. 平台卡状态 UNUSED -> RESERVED(划拨给partner)
int updated = dayCardPoolDao.update(null, new UpdateWrapper<DayCardPoolEntity>()
.eq("id", card.getId())
.eq("status", DayCardConstants.POOL_STATUS_UNUSED)
.set("status", DayCardConstants.POOL_STATUS_RESERVED)
.set("updated_at", now)
);
if (updated == 0) {
continue; // 被其他线程抢走,跳过
}
// 3. 记录到 partner 池子
PartnerCardPoolEntity pcp = new PartnerCardPoolEntity();
pcp.setPartnerId(partnerId);
pcp.setPoolId(card.getId());
pcp.setCardCode(card.getCardCode());
pcp.setThirdCardNo(card.getThirdCardNo());
pcp.setStatus("AVAILABLE");
pcp.setPurchaseBatch(purchaseBatch);
pcp.setDeleteFlag("UNDEL");
pcp.setCreatedAt(now);
pcp.setUpdatedAt(now);
this.save(pcp);
}
logger.info("合作伙伴采购天卡: partnerId={}, quantity={}, batch={}", partnerId, cards.size(), purchaseBatch);
return cards.size();
}
// ==================== 消耗(代销模式) ====================
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> consume(String partnerId, String mac, String wifiMac, String deviceUid, String deviceSn, Long channelOrgId) {
if (StringUtils.isBlank(mac) && StringUtils.isBlank(wifiMac)
&& StringUtils.isBlank(deviceUid) && StringUtils.isBlank(deviceSn)) {
throw new ASException("mac/wifiMac/deviceUid/deviceSn 至少传一个", 400);
}
// 1. Redis防重锁:按设备标识加锁,防止并发重复消耗
String lockKey = DayCardConstants.REDIS_PARTNER_CONSUME_LOCK_PREFIX
+ (StringUtils.isNotBlank(mac) ? mac
: StringUtils.isNotBlank(wifiMac) ? wifiMac
: StringUtils.isNotBlank(deviceUid) ? deviceUid : deviceSn);
boolean locked = redisUtils.setIfAbsent(lockKey, "1", 30);
if (!locked) {
throw new ASException("操作太频繁,请稍后再试", 429);
}
try {
// 2. 查找/创建设备
DeviceEntity device = findOrCreateDevice(mac, wifiMac, deviceUid, deviceSn);
boolean isNew = device.getId() == null;
Date now = new Date();
if (isNew) {
if (StringUtils.isNotBlank(mac)) device.setMac(mac);
if (StringUtils.isNotBlank(wifiMac)) device.setWifiMac(wifiMac);
if (StringUtils.isNotBlank(deviceUid)) device.setDeviceUid(deviceUid);
if (StringUtils.isNotBlank(deviceSn)) device.setDeviceSn(deviceSn);
device.setLastHeartbeat(now);
device.setCreatedAt(now);
device.setDeleteFlag("UNDEL");
device.setStatus("ACTIVE");
device.setSource("PARTNER");
device.setPartnerId(partnerId);
deviceService.saveDevice(device);
}
// 3. 幂等检查:设备已有有效天卡则直接返回(防重复消耗)
DayCardPoolEntity existing = getDeviceActiveCard(device.getId());
if (existing != null) {
Map<String, Object> idempotent = new HashMap<>();
idempotent.put("deviceId", device.getId());
idempotent.put("thirdCardNo", existing.getThirdCardNo());
idempotent.put("expireAt", existing.getExpireAt());
idempotent.put("status", "ACTIVE");
logger.info("代销天卡消耗幂等返回: deviceId={}, poolId={}", device.getId(), existing.getId());
return idempotent;
}
// 4. 从 partner 池子取一张可用天卡(FOR UPDATE SKIP LOCKED 防并发超卖)
PartnerCardPoolEntity pcp = this.getOne(
new QueryWrapper<PartnerCardPoolEntity>()
.eq("partner_id", partnerId)
.eq("status", "AVAILABLE")
.eq("delete_flag", "UNDEL")
.orderByAsc("id")
.last("LIMIT 1 FOR UPDATE SKIP LOCKED")
);
if (pcp == null) {
throw new ASException("天卡池库存不足", 400);
}
// 5. 绑定天卡到设备:day_card_pool RESERVED -> USED + deviceId + expireAt(默认24h)
Date defaultExpire = new Date(now.getTime() + 24 * 60 * 60 * 1000L);
int updated = dayCardPoolDao.update(null, new UpdateWrapper<DayCardPoolEntity>()
.eq("id", pcp.getPoolId())
.eq("status", DayCardConstants.POOL_STATUS_RESERVED)
.set("status", DayCardConstants.POOL_STATUS_USED)
.set("device_id", device.getId())
.set("activate_status", DayCardConstants.ACTIVATE_STATUS_PENDING)
.set("used_at", now)
.set("expire_at", defaultExpire)
.set("updated_at", now)
);
if (updated == 0) {
throw new ASException("天卡分配失败,请重试", 500);
}
// 6. 更新 partner 池子:CONSUMED + deviceId + channelOrgId
pcp.setStatus("CONSUMED");
pcp.setDeviceId(device.getId());
pcp.setChannelOrgId(channelOrgId);
pcp.setConsumedAt(now);
pcp.setUpdatedAt(now);
this.updateById(pcp);
// 7. 异步激活天卡(调橘视厅)
final Long poolId = pcp.getPoolId();
final Long deviceId = device.getId();
activateExecutor.execute(() -> activateCardAsync(poolId, deviceId));
// 8. 返回天卡信息(异步激活中,客户端通过checkBenefits轮询确认)
// thirdCardNo不暴露给第三方,App通过init+checkBenefits自行获取播放凭证
Map<String, Object> result = new HashMap<>();
result.put("deviceId", device.getId());
result.put("expireAt", defaultExpire);
result.put("status", "ACTIVATING");
logger.info("代销天卡消耗: partnerId={}, deviceId={}, poolId={}, thirdCardNo={}",
partnerId, device.getId(), poolId, pcp.getThirdCardNo());
return result;
} finally {
redisUtils.delete(lockKey);
}
}
// ==================== 回退 ====================
@Override
public int revert(String partnerId, int quantity) {
if (quantity <= 0) {
throw new ASException("回退数量必须大于0", 400);
}
List<PartnerCardPoolEntity> cards = getAvailableCards(partnerId, quantity);
if (cards.isEmpty()) {
return 0;
}
Date now = new Date();
for (PartnerCardPoolEntity card : cards) {
// 平台天卡池 RESERVED -> UNUSED(恢复可分配)
dayCardPoolDao.update(null, new UpdateWrapper<DayCardPoolEntity>()
.eq("id", card.getPoolId())
.eq("status", DayCardConstants.POOL_STATUS_RESERVED)
.set("status", DayCardConstants.POOL_STATUS_UNUSED)
.set("updated_at", now)
);
// partner池子标记DISABLED
card.setStatus("DISABLED");
card.setUpdatedAt(now);
this.updateById(card);
}
logger.info("合作伙伴回退天卡: partnerId={}, quantity={}", partnerId, cards.size());
return cards.size();
}
/** 按设备标识查找已有设备,找不到返回新实体(标识优先级:mac > wifiMac > deviceUid > deviceSn) */
private DeviceEntity findOrCreateDevice(String mac, String wifiMac, String deviceUid, String deviceSn) {
DeviceEntity existing = null;
if (StringUtils.isNotBlank(mac)) {
existing = deviceDao.selectOne(new QueryWrapper<DeviceEntity>().eq("mac", mac).eq("delete_flag", "UNDEL").last("LIMIT 1"));
}
if (existing == null && StringUtils.isNotBlank(wifiMac)) {
existing = deviceDao.selectOne(new QueryWrapper<DeviceEntity>().eq("wifi_mac", wifiMac).eq("delete_flag", "UNDEL").last("LIMIT 1"));
}
if (existing == null && StringUtils.isNotBlank(deviceUid)) {
existing = deviceDao.selectOne(new QueryWrapper<DeviceEntity>().eq("device_uid", deviceUid).eq("delete_flag", "UNDEL").last("LIMIT 1"));
}
if (existing == null && StringUtils.isNotBlank(deviceSn)) {
existing = deviceDao.selectOne(new QueryWrapper<DeviceEntity>().eq("device_sn", deviceSn).eq("delete_flag", "UNDEL").last("LIMIT 1"));
}
return existing != null ? existing : new DeviceEntity();
}
/** 查询设备当前有效的天卡(status=USED 且未过期) */
private DayCardPoolEntity getDeviceActiveCard(Long deviceId) {
return dayCardPoolDao.selectOne(new QueryWrapper<DayCardPoolEntity>()
.eq("device_id", deviceId)
.eq("status", DayCardConstants.POOL_STATUS_USED)
.eq("delete_flag", DayCardConstants.DELETE_FLAG_UNDEL)
.isNotNull("expire_at")
.gt("expire_at", new Date())
.orderByDesc("used_at")
.last("LIMIT 1"));
}
/** 异步激活天卡:调橘视厅 getToken + activateCard,更新真实过期时间 */
private void activateCardAsync(Long poolId, Long deviceId) {
// 激活锁:防止同一张卡重复激活(参考生产项目 api:thirdparty:activate:lock:{cardId})
String lockKey = DayCardConstants.REDIS_THIRD_PARTY_ACTIVATE_LOCK_PREFIX + poolId;
boolean locked = redisUtils.setIfAbsent(lockKey, "1", 3600);
if (!locked) {
logger.info("天卡激活任务已存在,跳过: poolId={}", poolId);
return;
}
try {
DayCardPoolEntity pool = dayCardPoolDao.selectById(poolId);
if (pool == null) {
logger.warn("异步激活失败:天卡不存在, poolId={}", poolId);
return;
}
DeviceEntity device = deviceDao.selectById(deviceId);
if (device == null) {
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "设备不存在: " + deviceId, null);
return;
}
String thirdCardRef = pool.getThirdCardNo();
if (StringUtils.isBlank(thirdCardRef)) {
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "第三方卡号为空", null);
return;
}
// 1. 免密登录获取Token(优先从缓存读取,参考生产项目)
String token;
String memberCode;
String tokenCacheKey = DayCardConstants.REDIS_THIRD_PARTY_TOKEN_PREFIX + thirdCardRef;
@SuppressWarnings("unchecked")
Map<String, String> cached = redisUtils.get(tokenCacheKey, Map.class);
if (cached != null && StringUtils.isNotBlank(cached.get("token"))
&& StringUtils.isNotBlank(cached.get("memberCode"))) {
token = cached.get("token");
memberCode = cached.get("memberCode");
} else {
Map<String, String> tokenResult = jushitingApiService.getToken(device, thirdCardRef);
if (tokenResult == null || StringUtils.isBlank(tokenResult.get("token"))) {
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, "获取Token失败", null);
return;
}
token = tokenResult.get("token");
memberCode = tokenResult.get("memberCode");
// 缓存token和memberCode,5分钟有效
Map<String, String> cacheData = new HashMap<>();
cacheData.put("token", token);
cacheData.put("memberCode", memberCode);
redisUtils.set(tokenCacheKey, cacheData, 5 * 60);
}
// 2. 卡密激活
JstActivateCardResult activateResult = jushitingApiService.activateCard(
deviceId, pool.getCardCode(), memberCode, token);
if (!activateResult.isSuccess()) {
String errMsg = activateResult.getErrorMessage() != null ? activateResult.getErrorMessage() : "卡密激活返回失败";
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, errMsg, null);
return;
}
// 3. 确定过期时间
Date expireAt = null;
if (StringUtils.isNotBlank(activateResult.getExpireTime())) {
expireAt = parseExpireTime(activateResult.getExpireTime());
}
if (expireAt == null) {
String vipExpire = jushitingApiService.queryVipExpireTime(deviceId, thirdCardRef);
if (StringUtils.isNotBlank(vipExpire)) {
expireAt = parseExpireTime(vipExpire);
}
}
if (expireAt == null) {
expireAt = new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000L);
}
// 4. 更新过期时间 + 激活成功
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_SUCCESS, null, expireAt);
logger.info("代销天卡激活完成: poolId={}, deviceId={}, expireAt={}", poolId, deviceId, expireAt);
} catch (Exception e) {
logger.error("代销天卡激活异常: poolId={}", poolId, e);
updateActivateStatus(poolId, DayCardConstants.ACTIVATE_STATUS_FAILED, e.getMessage(), null);
} finally {
try {
redisUtils.delete(lockKey);
} catch (Exception e) {
logger.warn("释放激活锁失败: key={}", lockKey, e);
}
}
}
private void updateActivateStatus(Long poolId, String status, String error, Date expireAt) {
UpdateWrapper<DayCardPoolEntity> uw = new UpdateWrapper<>();
uw.eq("id", poolId)
.set("activate_status", status)
.set("activate_error", error)
.set("updated_at", new Date());
if (expireAt != null) {
uw.set("expire_at", expireAt).set("activated_at", new Date());
}
dayCardPoolDao.update(null, uw);
}
private static Date parseExpireTime(String time) {
if (time == null || time.isEmpty()) {
return null;
}
java.util.TimeZone tz = java.util.TimeZone.getTimeZone("Asia/Shanghai");
try {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(tz);
return sdf.parse(time);
} catch (Exception e) {
try {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
sdf.setTimeZone(tz);
return sdf.parse(time);
} catch (Exception ignored) {
return null;
}
}
}
}
@@ -0,0 +1,41 @@
package io.as.modules.openapi.sign;
/**
* 设备 sign 服务:init 成功后返回的加密签名(设备ID+毫秒时间戳),供后续接口校验
*/
public interface ApiDeviceSignService {
/**
* 生成 sign:加密(设备ID + 毫秒时间戳),并保存 deviceId 与 orgCode 的关系
*
* @param deviceId 设备ID
* @param timestampMs 毫秒时间戳
* @param orgCode 组织编码(渠道激活前缀)
* @return 签名字符串,用于响应与请求头
*/
String generate(Long deviceId, long timestampMs, String orgCode);
/**
* 校验 sign,解析出设备ID与时间戳
*
* @param sign 请求头中的 sign
* @return 解析结果,无效或过期返回 null
*/
DeviceSignResult verify(String sign);
/**
* 刷新 sign 过期时间:心跳等场景下,将 Redis 中该设备 sign 缓存的 TTL 重新延长
*
* @param deviceId 设备ID
*/
void refreshExpire(Long deviceId);
/**
* 作废设备当前 sign(设备解绑/逆向 init 时调用)
* <p>用哨兵缓存覆盖 Redis 中该设备的最新 sign,校验时旧 sign 因不匹配而被拒绝,
* 直到重新 init 调 generate 覆盖或 TTL 过期。App 端旧 sign 失效后无法进入首页,需重新输入激活码。</p>
*
* @param deviceId 设备ID
*/
void invalidate(Long deviceId);
}
@@ -0,0 +1,22 @@
package io.as.modules.openapi.sign;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 设备 sign 在 Redis 中的缓存结构:保存 deviceId、orgCode、sign 以及时间戳
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceSignCache implements Serializable {
private static final long serialVersionUID = 1L;
private Long deviceId;
private String orgCode;
private String sign;
private long timestamp;
}
@@ -0,0 +1,18 @@
package io.as.modules.openapi.sign;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 设备 sign 校验结果:解析出的设备ID、时间戳及 orgCode 前缀
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceSignResult {
private Long deviceId;
private long timestamp;
/** 生成 sign 时携带的 orgCode(渠道前缀) */
private String orgCode;
}
@@ -0,0 +1,138 @@
package io.as.modules.openapi.sign.impl;
import io.as.common.utils.RedisUtils;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.openapi.sign.DeviceSignCache;
import io.as.modules.openapi.sign.DeviceSignResult;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 设备 sign:HMAC-SHA256(deviceId:timestamp:orgCode, secret),Base64URL 输出;校验时验证签名并检查时间戳在有效期内
* <p>对齐生产环境 as-partner-backend 的 ApiDeviceSignServiceImpl</p>
*/
@Service
public class ApiDeviceSignServiceImpl implements ApiDeviceSignService {
private static final String HMAC_SHA256 = "HmacSHA256";
/** sign 有效时长(毫秒):24 小时 */
private static final long SIGN_VALID_MS = 24 * 60 * 60 * 1000;
/** Redis key 前缀:openapi: 隔离于生产 api:jst: */
private static final String REDIS_KEY_LATEST_SIGN_PREFIX = "openapi:device-sign:";
@Value("${renren.api.device-sign-secret:renren-device-sign-secret-change-in-prod}")
private String secret;
@Autowired
private RedisUtils redisUtils;
@Override
public String generate(Long deviceId, long timestampMs, String orgCode) {
String orgCodePart = orgCode == null ? "" : orgCode.trim();
String payload = deviceId + ":" + timestampMs + ":" + orgCodePart;
String hmac = hmacSha256Hex(payload, secret);
String toEncode = payload + ":" + hmac;
String sign = Base64.getUrlEncoder().withoutPadding().encodeToString(toEncode.getBytes(StandardCharsets.UTF_8));
if (deviceId != null) {
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = new DeviceSignCache(deviceId, orgCodePart, sign, timestampMs);
redisUtils.set(key, cache, SIGN_VALID_MS / 1000);
}
return sign;
}
@Override
public DeviceSignResult verify(String sign) {
if (StringUtils.isBlank(sign)) {
return null;
}
try {
String decoded = new String(Base64.getUrlDecoder().decode(sign.trim()), StandardCharsets.UTF_8);
int lastColon = decoded.lastIndexOf(':');
if (lastColon <= 0) {
return null;
}
String payload = decoded.substring(0, lastColon);
String hmacReceived = decoded.substring(lastColon + 1);
String hmacExpected = hmacSha256Hex(payload, secret);
if (!hmacExpected.equalsIgnoreCase(hmacReceived)) {
return null;
}
// payload: deviceId:timestamp:orgCode
String[] parts = payload.split(":", 3);
if (parts.length < 2) {
return null;
}
Long deviceId = Long.parseLong(parts[0].trim());
long timestamp = Long.parseLong(parts[1].trim());
String orgCode = parts.length >= 3 ? parts[2].trim() : null;
long now = System.currentTimeMillis();
if (timestamp > now || (now - timestamp) > SIGN_VALID_MS) {
return null;
}
if (deviceId == null) {
return null;
}
// 结合 Redis 校验:只允许当前设备最新 sign 生效(若 Redis 有记录且不同,则判为旧 sign)
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class);
if (cache != null && !sign.equals(cache.getSign())) {
return null;
}
// Redis 没记录时,用签名中自带的 orgCode;有记录时以缓存中的为准
if (cache != null && cache.getOrgCode() != null) {
orgCode = cache.getOrgCode();
}
return new DeviceSignResult(deviceId, timestamp, orgCode);
} catch (Exception e) {
return null;
}
}
@Override
public void refreshExpire(Long deviceId) {
if (deviceId == null) {
return;
}
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class);
if (cache != null) {
redisUtils.set(key, cache, SIGN_VALID_MS / 1000);
}
}
@Override
public void invalidate(Long deviceId) {
if (deviceId == null) {
return;
}
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
// 哨兵缓存:verify 时 cache != null 且 sign != "REVOKED" 即拒绝旧 sign,
// 直到重新 init 调 generate 覆盖此哨兵或 TTL 过期。
DeviceSignCache revoked = new DeviceSignCache(deviceId, null, "REVOKED", System.currentTimeMillis());
redisUtils.set(key, revoked, SIGN_VALID_MS / 1000);
}
private static String hmacSha256Hex(String data, String secretKey) {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
byte[] hmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hmac.length * 2);
for (byte b : hmac) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("HMAC error", e);
}
}
}