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