初始化天卡开放平台后端项目
包含完整功能模块: - 设备激活(直销激活码模式 + 代销免激活码模式) - 微信支付 + 天卡订单 - 天卡池管理 + 异步橘视厅SDK激活 - 第三方合作伙伴接口(代销模式) - 签名鉴权(appKey/appSecret + nonce防重放) - 天卡消耗(事务 + 幂等 + 并发控制) - 媒资接口(三级授权过滤) - 审计日志 - 定价策略(3层:门店 > 渠道 > 平台) - 分账(微信分账 + 分润规则) - Docker部署(含Redis容器)
This commit is contained in:
@@ -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 @@
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
+138
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user