修复订阅备机同步的一致性与重试问题
This commit is contained in:
@@ -2,9 +2,11 @@ package com.lion.lionwebsite.Message;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
@ToString(exclude = {"payloadBase64", "signature"})
|
||||||
public class SubscriptionSnapshotMessage extends AbstractMessage {
|
public class SubscriptionSnapshotMessage extends AbstractMessage {
|
||||||
{
|
{
|
||||||
messageType = SUBSCRIPTION_SNAPSHOT_MESSAGE;
|
messageType = SUBSCRIPTION_SNAPSHOT_MESSAGE;
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import io.netty.channel.nio.NioEventLoopGroup;
|
|||||||
import io.netty.channel.socket.nio.NioSocketChannel;
|
import io.netty.channel.socket.nio.NioSocketChannel;
|
||||||
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
|
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
|
||||||
import io.netty.handler.logging.LoggingHandler;
|
import io.netty.handler.logging.LoggingHandler;
|
||||||
|
import io.netty.handler.logging.ByteBufFormat;
|
||||||
import io.netty.util.concurrent.DefaultPromise;
|
import io.netty.util.concurrent.DefaultPromise;
|
||||||
import io.netty.util.concurrent.Promise;
|
import io.netty.util.concurrent.Promise;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
@@ -80,6 +82,8 @@ public class RemoteService {
|
|||||||
|
|
||||||
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
|
final AtomicBoolean subscriptionSyncQueued = new AtomicBoolean();
|
||||||
|
|
||||||
|
final AtomicBoolean subscriptionSyncRunning = new AtomicBoolean();
|
||||||
|
|
||||||
@Value("${subscription.standby.sync-enabled:false}")
|
@Value("${subscription.standby.sync-enabled:false}")
|
||||||
boolean subscriptionSyncEnabled;
|
boolean subscriptionSyncEnabled;
|
||||||
|
|
||||||
@@ -104,7 +108,8 @@ public class RemoteService {
|
|||||||
protected void initChannel(NioSocketChannel channel) {
|
protected void initChannel(NioSocketChannel channel) {
|
||||||
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
channel.pipeline().addLast(new LengthFieldBasedFrameDecoder(100000000, 1, 4));
|
||||||
channel.pipeline().addLast(new MessageCodec());
|
channel.pipeline().addLast(new MessageCodec());
|
||||||
channel.pipeline().addLast(new LoggingHandler());
|
// 只记录事件和字节数,避免把订阅正文、签名等消息内容写入日志。
|
||||||
|
channel.pipeline().addLast(new LoggingHandler(io.netty.handler.logging.LogLevel.DEBUG, ByteBufFormat.SIMPLE));
|
||||||
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
channel.pipeline().addLast(new MyChannelInboundHandlerAdapter());
|
||||||
}
|
}
|
||||||
}).connect(new InetSocketAddress(ip, port + i)).sync();
|
}).connect(new InetSocketAddress(ip, port + i)).sync();
|
||||||
@@ -176,35 +181,59 @@ public class RemoteService {
|
|||||||
public void requestSubscriptionSync() {
|
public void requestSubscriptionSync() {
|
||||||
if (!subscriptionSyncEnabled)
|
if (!subscriptionSyncEnabled)
|
||||||
return;
|
return;
|
||||||
if (!subscriptionSyncQueued.compareAndSet(false, true))
|
subscriptionSyncQueued.set(true);
|
||||||
|
if (!subscriptionSyncRunning.compareAndSet(false, true))
|
||||||
return;
|
return;
|
||||||
subscriptionSyncExecutor.execute(() -> {
|
subscriptionSyncExecutor.execute(this::drainSubscriptionSyncQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${subscription.standby.retry-interval-ms:60000}")
|
||||||
|
void scheduledSubscriptionSync() {
|
||||||
|
requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drainSubscriptionSyncQueue() {
|
||||||
try {
|
try {
|
||||||
do {
|
while (subscriptionSyncQueued.getAndSet(false)) {
|
||||||
subscriptionSyncQueued.set(false);
|
|
||||||
if (isDead())
|
if (isDead())
|
||||||
continue;
|
continue;
|
||||||
SubscriptionSnapshotMessage message = subscriptionStandbySnapshotService.build();
|
syncSubscriptionSnapshotOnce();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
subscriptionSyncRunning.set(false);
|
||||||
|
if (subscriptionSyncQueued.get())
|
||||||
|
requestSubscriptionSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncSubscriptionSnapshotOnce() {
|
||||||
|
SubscriptionSnapshotMessage message = null;
|
||||||
|
DefaultPromise<AbstractMessage> promise = null;
|
||||||
|
try {
|
||||||
|
message = subscriptionStandbySnapshotService.build();
|
||||||
message.setMessageId(atomicInteger.getAndIncrement());
|
message.setMessageId(atomicInteger.getAndIncrement());
|
||||||
DefaultPromise<AbstractMessage> promise = new DefaultPromise<>(eventLoopGroup);
|
promise = new DefaultPromise<>(eventLoopGroup);
|
||||||
promiseHashMap.put(message.messageId, promise);
|
promiseHashMap.put(message.messageId, promise);
|
||||||
channel.writeAndFlush(message);
|
channel.writeAndFlush(message);
|
||||||
if (promise.await(30, TimeUnit.SECONDS)) {
|
if (promise.await(30, TimeUnit.SECONDS)) {
|
||||||
AbstractMessage reply = promise.getNow();
|
AbstractMessage reply = promise.getNow();
|
||||||
if (reply instanceof ResponseMessage response && response.getResult() == 0)
|
if (reply instanceof ResponseMessage response && (response.getResult() == 0 || response.getResult() == 3))
|
||||||
log.info("订阅快照同步成功 revision={}", shortRevision(message.getRevision()));
|
log.info("订阅快照同步完成 revision={} result={}", shortRevision(message.getRevision()), response.getResult());
|
||||||
else
|
else
|
||||||
log.warn("订阅快照同步失败 revision={} result={}", shortRevision(message.getRevision()),
|
log.warn("订阅快照同步失败 revision={} result={}", shortRevision(message.getRevision()),
|
||||||
reply instanceof ResponseMessage response ? response.getResult() : "timeout");
|
reply instanceof ResponseMessage response ? response.getResult() : "invalid-response");
|
||||||
} else {
|
} else {
|
||||||
log.warn("订阅快照同步超时 revision={}", shortRevision(message.getRevision()));
|
log.warn("订阅快照同步超时 revision={}", shortRevision(message.getRevision()));
|
||||||
}
|
}
|
||||||
promiseHashMap.remove(message.messageId, promise);
|
} catch (InterruptedException e) {
|
||||||
} while (subscriptionSyncQueued.getAndSet(false));
|
Thread.currentThread().interrupt();
|
||||||
|
log.warn("订阅快照同步线程被中断");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("生成或发送订阅快照失败: {}", e.getMessage());
|
log.warn("生成或发送订阅快照失败: {}", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (message != null && promise != null)
|
||||||
|
promiseHashMap.remove(message.messageId, promise);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String shortRevision(String revision) {
|
private static String shortRevision(String revision) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -32,8 +34,13 @@ public class SubService {
|
|||||||
final UserMapper userMapper;
|
final UserMapper userMapper;
|
||||||
final SubscriptionRefreshService refreshService;
|
final SubscriptionRefreshService refreshService;
|
||||||
final RemoteService remoteService;
|
final RemoteService remoteService;
|
||||||
|
final SubscriptionStateCoordinator stateCoordinator;
|
||||||
|
|
||||||
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
|
return withWriteLock(() -> insertSubscriptionAccountUnlocked(name, upstreamKey, filterHighMultiplier, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String insertSubscriptionAccountUnlocked(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
|
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
|
||||||
return response.failure("名称和上游 key 不能为空").toJSONString();
|
return response.failure("名称和上游 key 不能为空").toJSONString();
|
||||||
@@ -57,6 +64,10 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String updateSubscriptionAccount(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
public String updateSubscriptionAccount(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
|
return withWriteLock(() -> updateSubscriptionAccountUnlocked(id, name, upstreamKey, filterHighMultiplier, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String updateSubscriptionAccountUnlocked(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
||||||
if (account == null)
|
if (account == null)
|
||||||
@@ -79,6 +90,10 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String deleteSubscriptionAccount(Integer id) {
|
public String deleteSubscriptionAccount(Integer id) {
|
||||||
|
return withWriteLock(() -> deleteSubscriptionAccountUnlocked(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deleteSubscriptionAccountUnlocked(Integer id) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
|
||||||
if (account == null)
|
if (account == null)
|
||||||
@@ -91,12 +106,20 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String refreshSubscriptionAccount(Integer id) {
|
public String refreshSubscriptionAccount(Integer id) {
|
||||||
|
return withWriteLock(() -> refreshSubscriptionAccountUnlocked(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String refreshSubscriptionAccountUnlocked(Integer id) {
|
||||||
boolean success = refreshService.refresh(id);
|
boolean success = refreshService.refresh(id);
|
||||||
remoteService.requestSubscriptionSync();
|
remoteService.requestSubscriptionSync();
|
||||||
return success ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
|
return success ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String insertSubBind(String user, Integer accountId) {
|
public String insertSubBind(String user, Integer accountId) {
|
||||||
|
return withWriteLock(() -> insertSubBindUnlocked(user, accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String insertSubBindUnlocked(String user, Integer accountId) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
|
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
|
||||||
return response.failure("用户不存在").toJSONString();
|
return response.failure("用户不存在").toJSONString();
|
||||||
@@ -116,6 +139,10 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String resetKey(String user) {
|
public String resetKey(String user) {
|
||||||
|
return withWriteLock(() -> resetKeyUnlocked(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resetKeyUnlocked(String user) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
if (subMapper.countSubBindByUser(user) == 0)
|
if (subMapper.countSubBindByUser(user) == 0)
|
||||||
return response.failure("绑定不存在").toJSONString();
|
return response.failure("绑定不存在").toJSONString();
|
||||||
@@ -129,6 +156,10 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String rebind(String user, Integer accountId) {
|
public String rebind(String user, Integer accountId) {
|
||||||
|
return withWriteLock(() -> rebindUnlocked(user, accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String rebindUnlocked(String user, Integer accountId) {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
|
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
|
||||||
if (account == null || !account.isEnabled())
|
if (account == null || !account.isEnabled())
|
||||||
@@ -193,12 +224,26 @@ public class SubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String deleteSubBind(String user) {
|
public String deleteSubBind(String user) {
|
||||||
|
return withWriteLock(() -> deleteSubBindUnlocked(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deleteSubBindUnlocked(String user) {
|
||||||
subMapper.deleteSubBind(user);
|
subMapper.deleteSubBind(user);
|
||||||
subMapper.deleteSubUpdateRecord(user);
|
subMapper.deleteSubUpdateRecord(user);
|
||||||
remoteService.requestSubscriptionSync();
|
remoteService.requestSubscriptionSync();
|
||||||
return Response._success("删除成功");
|
return Response._success("删除成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String withWriteLock(Supplier<String> action) {
|
||||||
|
Lock stateLock = stateCoordinator.writeLock();
|
||||||
|
stateLock.lock();
|
||||||
|
try {
|
||||||
|
return action.get();
|
||||||
|
} finally {
|
||||||
|
stateLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SubscriptionAccount firstEnabledAccount() {
|
private SubscriptionAccount firstEnabledAccount() {
|
||||||
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
|
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.nio.file.*;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -29,6 +30,8 @@ public class SubscriptionRefreshService {
|
|||||||
|
|
||||||
final SubMapper subMapper;
|
final SubMapper subMapper;
|
||||||
|
|
||||||
|
final SubscriptionStateCoordinator stateCoordinator;
|
||||||
|
|
||||||
@Value("${subscription.upstream.v2-url-template}")
|
@Value("${subscription.upstream.v2-url-template}")
|
||||||
String v2UrlTemplate;
|
String v2UrlTemplate;
|
||||||
|
|
||||||
@@ -48,6 +51,9 @@ public class SubscriptionRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean refresh(Integer accountId) {
|
public boolean refresh(Integer accountId) {
|
||||||
|
Lock stateLock = stateCoordinator.writeLock();
|
||||||
|
stateLock.lock();
|
||||||
|
try {
|
||||||
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
|
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
|
||||||
if (account == null || !account.isEnabled())
|
if (account == null || !account.isEnabled())
|
||||||
return false;
|
return false;
|
||||||
@@ -66,6 +72,9 @@ public class SubscriptionRefreshService {
|
|||||||
log.error("刷新子账号订阅失败 accountId={}: {}", accountId, message);
|
log.error("刷新子账号订阅失败 accountId={}: {}", accountId, message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
stateLock.unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String v2Url(SubscriptionAccount account) {
|
public String v2Url(SubscriptionAccount account) {
|
||||||
@@ -204,11 +213,15 @@ public class SubscriptionRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void invalidateCache(Integer accountId) {
|
public void invalidateCache(Integer accountId) {
|
||||||
|
Lock stateLock = stateCoordinator.writeLock();
|
||||||
|
stateLock.lock();
|
||||||
try {
|
try {
|
||||||
Files.deleteIfExists(cachedPath(accountId, "v2"));
|
Files.deleteIfExists(cachedPath(accountId, "v2"));
|
||||||
Files.deleteIfExists(cachedPath(accountId, "cat"));
|
Files.deleteIfExists(cachedPath(accountId, "cat"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warn("清理失效订阅缓存失败 accountId={}", accountId, e);
|
log.warn("清理失效订阅缓存失败 accountId={}", accountId, e);
|
||||||
|
} finally {
|
||||||
|
stateLock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.lion.lionwebsite.Util.CustomUtil;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
@@ -20,18 +21,32 @@ import java.nio.file.Path;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.zip.GZIPOutputStream;
|
import java.util.zip.GZIPOutputStream;
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SubscriptionStandbySnapshotService {
|
public class SubscriptionStandbySnapshotService {
|
||||||
private final SubMapper subMapper;
|
private final SubMapper subMapper;
|
||||||
private final SubscriptionRefreshService refreshService;
|
private final SubscriptionRefreshService refreshService;
|
||||||
|
private final SubscriptionStateCoordinator stateCoordinator;
|
||||||
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
|
private final ObjectMapper objectMapper = CustomUtil.objectMapper;
|
||||||
|
|
||||||
@Value("${subscription.standby.sync-secret:}")
|
@Value("${subscription.standby.sync-secret:}")
|
||||||
String syncSecret;
|
String syncSecret;
|
||||||
|
|
||||||
|
@Value("${subscription.standby.sync-enabled:false}")
|
||||||
|
boolean syncEnabled;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void validateConfiguration() {
|
||||||
|
if (syncEnabled && (syncSecret == null || syncSecret.isBlank()))
|
||||||
|
throw new IllegalStateException("启用订阅备机同步时必须配置 SUBSCRIPTION_SYNC_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
public SubscriptionSnapshotMessage build() throws IOException {
|
public SubscriptionSnapshotMessage build() throws IOException {
|
||||||
|
Lock stateLock = stateCoordinator.readLock();
|
||||||
|
stateLock.lock();
|
||||||
|
try {
|
||||||
Map<Integer, SubscriptionAccountSnapshot> accountMap = new HashMap<>();
|
Map<Integer, SubscriptionAccountSnapshot> accountMap = new HashMap<>();
|
||||||
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
|
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
|
||||||
if (!account.isEnabled() || !refreshService.hasCompleteCache(account.getId()))
|
if (!account.isEnabled() || !refreshService.hasCompleteCache(account.getId()))
|
||||||
@@ -83,6 +98,9 @@ public class SubscriptionStandbySnapshotService {
|
|||||||
message.setPayloadSha256(payloadSha256);
|
message.setPayloadSha256(payloadSha256);
|
||||||
message.setSignature(hmac(signatureInput.getBytes(StandardCharsets.UTF_8)));
|
message.setSignature(hmac(signatureInput.getBytes(StandardCharsets.UTF_8)));
|
||||||
return message;
|
return message;
|
||||||
|
} finally {
|
||||||
|
stateLock.unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String hmac(byte[] input) {
|
private String hmac(byte[] input) {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.Lock;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
|
||||||
|
/** Coordinates database bindings and the two cache files as one subscription state. */
|
||||||
|
@Component
|
||||||
|
public final class SubscriptionStateCoordinator {
|
||||||
|
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||||
|
|
||||||
|
public Lock readLock() {
|
||||||
|
return lock.readLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Lock writeLock() {
|
||||||
|
return lock.writeLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ subscription:
|
|||||||
standby:
|
standby:
|
||||||
sync-enabled: "${SUBSCRIPTION_STANDBY_SYNC_ENABLED:false}"
|
sync-enabled: "${SUBSCRIPTION_STANDBY_SYNC_ENABLED:false}"
|
||||||
sync-secret: "${SUBSCRIPTION_SYNC_SECRET:}"
|
sync-secret: "${SUBSCRIPTION_SYNC_SECRET:}"
|
||||||
|
retry-interval-ms: 60000
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
|
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubBind;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import com.lion.lionwebsite.Message.SubscriptionSnapshotMessage;
|
||||||
|
import com.lion.lionwebsite.Message.SubscriptionSnapshotPayload;
|
||||||
|
import com.lion.lionwebsite.Util.CustomUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.zip.GZIPInputStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class SubscriptionStandbySnapshotServiceTest {
|
||||||
|
@Test
|
||||||
|
void buildsDeterministicSnapshotWithoutPlainPublicKeys(@TempDir Path directory) throws Exception {
|
||||||
|
SubMapper mapper = mock(SubMapper.class);
|
||||||
|
SubscriptionRefreshService refreshService = mock(SubscriptionRefreshService.class);
|
||||||
|
SubscriptionStateCoordinator coordinator = new SubscriptionStateCoordinator();
|
||||||
|
SubscriptionAccount account = new SubscriptionAccount(1, "account", "upstream-secret", true, true,
|
||||||
|
null, null, null, null, 2, null, null);
|
||||||
|
when(mapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(java.util.List.of(account)));
|
||||||
|
when(mapper.selectAllSubBind()).thenReturn(new ArrayList<>(java.util.List.of(
|
||||||
|
new SubBind("public-key-a", "user-a", 1, "account", true, true),
|
||||||
|
new SubBind("public-key-b", "user-b", 1, "account", true, true))));
|
||||||
|
Path v2 = directory.resolve("v2ray.txt");
|
||||||
|
Path clash = directory.resolve("clash.yaml");
|
||||||
|
Files.writeString(v2, "v2-content", StandardCharsets.UTF_8);
|
||||||
|
Files.writeString(clash, "clash-content", StandardCharsets.UTF_8);
|
||||||
|
when(refreshService.hasCompleteCache(1)).thenReturn(true);
|
||||||
|
when(refreshService.cachedPath(1, "v2")).thenReturn(v2);
|
||||||
|
when(refreshService.cachedPath(1, "cat")).thenReturn(clash);
|
||||||
|
|
||||||
|
SubscriptionStandbySnapshotService service = new SubscriptionStandbySnapshotService(mapper, refreshService, coordinator);
|
||||||
|
service.syncSecret = "test-sync-secret";
|
||||||
|
SubscriptionSnapshotMessage first = service.build();
|
||||||
|
SubscriptionSnapshotMessage second = service.build();
|
||||||
|
|
||||||
|
assertEquals(first.getRevision(), second.getRevision());
|
||||||
|
byte[] compressed = Base64.getDecoder().decode(first.getPayloadBase64());
|
||||||
|
byte[] json;
|
||||||
|
try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) {
|
||||||
|
json = gzip.readAllBytes();
|
||||||
|
}
|
||||||
|
String rawPayload = new String(json, StandardCharsets.UTF_8);
|
||||||
|
assertFalse(rawPayload.contains("public-key-a"));
|
||||||
|
assertFalse(rawPayload.contains("public-key-b"));
|
||||||
|
assertFalse(rawPayload.contains("upstream-secret"));
|
||||||
|
SubscriptionSnapshotPayload payload = CustomUtil.objectMapper.readValue(json, SubscriptionSnapshotPayload.class);
|
||||||
|
assertEquals(1, payload.getAccounts().size());
|
||||||
|
assertEquals(2, payload.getBindings().size());
|
||||||
|
assertTrue(payload.getAccounts().getFirst().isFilterHighMultiplier());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user