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

包含完整功能模块:
- 设备激活(直销激活码模式 + 代销免激活码模式)
- 微信支付 + 天卡订单
- 天卡池管理 + 异步橘视厅SDK激活
- 第三方合作伙伴接口(代销模式)
  - 签名鉴权(appKey/appSecret + nonce防重放)
  - 天卡消耗(事务 + 幂等 + 并发控制)
  - 媒资接口(三级授权过滤)
  - 审计日志
- 定价策略(3层:门店 > 渠道 > 平台)
- 分账(微信分账 + 分润规则)
- Docker部署(含Redis容器)
This commit is contained in:
zhaogd
2026-07-26 12:34:13 +08:00
commit 30198c7191
349 changed files with 28837 additions and 0 deletions
@@ -0,0 +1,41 @@
package io.as.modules.openapi.sign;
/**
* 设备 sign 服务:init 成功后返回的加密签名(设备ID+毫秒时间戳),供后续接口校验
*/
public interface ApiDeviceSignService {
/**
* 生成 sign:加密(设备ID + 毫秒时间戳),并保存 deviceId 与 orgCode 的关系
*
* @param deviceId 设备ID
* @param timestampMs 毫秒时间戳
* @param orgCode 组织编码(渠道激活前缀)
* @return 签名字符串,用于响应与请求头
*/
String generate(Long deviceId, long timestampMs, String orgCode);
/**
* 校验 sign,解析出设备ID与时间戳
*
* @param sign 请求头中的 sign
* @return 解析结果,无效或过期返回 null
*/
DeviceSignResult verify(String sign);
/**
* 刷新 sign 过期时间:心跳等场景下,将 Redis 中该设备 sign 缓存的 TTL 重新延长
*
* @param deviceId 设备ID
*/
void refreshExpire(Long deviceId);
/**
* 作废设备当前 sign(设备解绑/逆向 init 时调用)
* <p>用哨兵缓存覆盖 Redis 中该设备的最新 sign,校验时旧 sign 因不匹配而被拒绝,
* 直到重新 init 调 generate 覆盖或 TTL 过期。App 端旧 sign 失效后无法进入首页,需重新输入激活码。</p>
*
* @param deviceId 设备ID
*/
void invalidate(Long deviceId);
}
@@ -0,0 +1,22 @@
package io.as.modules.openapi.sign;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 设备 sign 在 Redis 中的缓存结构:保存 deviceId、orgCode、sign 以及时间戳
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceSignCache implements Serializable {
private static final long serialVersionUID = 1L;
private Long deviceId;
private String orgCode;
private String sign;
private long timestamp;
}
@@ -0,0 +1,18 @@
package io.as.modules.openapi.sign;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 设备 sign 校验结果:解析出的设备ID、时间戳及 orgCode 前缀
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceSignResult {
private Long deviceId;
private long timestamp;
/** 生成 sign 时携带的 orgCode(渠道前缀) */
private String orgCode;
}
@@ -0,0 +1,138 @@
package io.as.modules.openapi.sign.impl;
import io.as.common.utils.RedisUtils;
import io.as.modules.openapi.sign.ApiDeviceSignService;
import io.as.modules.openapi.sign.DeviceSignCache;
import io.as.modules.openapi.sign.DeviceSignResult;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 设备 sign:HMAC-SHA256(deviceId:timestamp:orgCode, secret),Base64URL 输出;校验时验证签名并检查时间戳在有效期内
* <p>对齐生产环境 as-partner-backend 的 ApiDeviceSignServiceImpl</p>
*/
@Service
public class ApiDeviceSignServiceImpl implements ApiDeviceSignService {
private static final String HMAC_SHA256 = "HmacSHA256";
/** sign 有效时长(毫秒):24 小时 */
private static final long SIGN_VALID_MS = 24 * 60 * 60 * 1000;
/** Redis key 前缀:openapi: 隔离于生产 api:jst: */
private static final String REDIS_KEY_LATEST_SIGN_PREFIX = "openapi:device-sign:";
@Value("${renren.api.device-sign-secret:renren-device-sign-secret-change-in-prod}")
private String secret;
@Autowired
private RedisUtils redisUtils;
@Override
public String generate(Long deviceId, long timestampMs, String orgCode) {
String orgCodePart = orgCode == null ? "" : orgCode.trim();
String payload = deviceId + ":" + timestampMs + ":" + orgCodePart;
String hmac = hmacSha256Hex(payload, secret);
String toEncode = payload + ":" + hmac;
String sign = Base64.getUrlEncoder().withoutPadding().encodeToString(toEncode.getBytes(StandardCharsets.UTF_8));
if (deviceId != null) {
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = new DeviceSignCache(deviceId, orgCodePart, sign, timestampMs);
redisUtils.set(key, cache, SIGN_VALID_MS / 1000);
}
return sign;
}
@Override
public DeviceSignResult verify(String sign) {
if (StringUtils.isBlank(sign)) {
return null;
}
try {
String decoded = new String(Base64.getUrlDecoder().decode(sign.trim()), StandardCharsets.UTF_8);
int lastColon = decoded.lastIndexOf(':');
if (lastColon <= 0) {
return null;
}
String payload = decoded.substring(0, lastColon);
String hmacReceived = decoded.substring(lastColon + 1);
String hmacExpected = hmacSha256Hex(payload, secret);
if (!hmacExpected.equalsIgnoreCase(hmacReceived)) {
return null;
}
// payload: deviceId:timestamp:orgCode
String[] parts = payload.split(":", 3);
if (parts.length < 2) {
return null;
}
Long deviceId = Long.parseLong(parts[0].trim());
long timestamp = Long.parseLong(parts[1].trim());
String orgCode = parts.length >= 3 ? parts[2].trim() : null;
long now = System.currentTimeMillis();
if (timestamp > now || (now - timestamp) > SIGN_VALID_MS) {
return null;
}
if (deviceId == null) {
return null;
}
// 结合 Redis 校验:只允许当前设备最新 sign 生效(若 Redis 有记录且不同,则判为旧 sign)
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class);
if (cache != null && !sign.equals(cache.getSign())) {
return null;
}
// Redis 没记录时,用签名中自带的 orgCode;有记录时以缓存中的为准
if (cache != null && cache.getOrgCode() != null) {
orgCode = cache.getOrgCode();
}
return new DeviceSignResult(deviceId, timestamp, orgCode);
} catch (Exception e) {
return null;
}
}
@Override
public void refreshExpire(Long deviceId) {
if (deviceId == null) {
return;
}
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
DeviceSignCache cache = redisUtils.get(key, DeviceSignCache.class);
if (cache != null) {
redisUtils.set(key, cache, SIGN_VALID_MS / 1000);
}
}
@Override
public void invalidate(Long deviceId) {
if (deviceId == null) {
return;
}
String key = REDIS_KEY_LATEST_SIGN_PREFIX + deviceId;
// 哨兵缓存:verify 时 cache != null 且 sign != "REVOKED" 即拒绝旧 sign,
// 直到重新 init 调 generate 覆盖此哨兵或 TTL 过期。
DeviceSignCache revoked = new DeviceSignCache(deviceId, null, "REVOKED", System.currentTimeMillis());
redisUtils.set(key, revoked, SIGN_VALID_MS / 1000);
}
private static String hmacSha256Hex(String data, String secretKey) {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
byte[] hmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hmac.length * 2);
for (byte b : hmac) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("HMAC error", e);
}
}
}