订阅刷新改为每账号错峰,保证 24 小时内各刷一次
原先每 24 小时一次性刷新全部子账号:12 个账号在 16 秒内背靠背发完 24 个请求,且 @Scheduled(fixedRate) 未设 initialDelay,首次触发即立刻 执行,导致每次重启都重刷一遍全部账号——上游看到的请求密度实际取决于 部署频率。请求头也只带 HttpClient 默认值,上游收到的是 "Apache-HttpClient/5.6.4 (Java/25.0.4)"。 改为高频轻量 tick(5 分钟,延迟 2 分钟启动),只刷新已到期账号,单轮 最多 2 个。24 小时保证由三层构成:成功后按「成功时刻 + 窗口 − 一个 tick」 推进相位,使分散效果自我维持;到期时刻持久化在数据库,停机期间错过的 账号恢复后立即到期;兜底把被写到超过一个窗口之后的计划判为损坏并立即 刷新。失败用固定间隔重试(数据库没有连续失败次数字段,做不出真正的指数 退避),重试排在将来时被尊重,避免故障账号每个 tick 重打上游。从未成功过 的账号按 tick 间隔尽快刷,已有缓存的按窗口分散。超过两倍窗口仍未成功的 账号推送告警。 请求头按账号与订阅格式确定性地伪装成 mihomo/clash-verge/v2rayN 等真实 客户端身份;同一账号固定用同一身份,逐请求更换反而更像机器。实现前已 实测确认显式设置 Accept-Encoding 不会破坏 HttpClient5 的透明解压。注意 这只能伪装到 HTTP 头,TLS 指纹仍与真实客户端不同。 新增 next_refresh_at、last_success_epoch 两列,均为 Epoch 毫秒整数:SQLite 的 CURRENT_TIMESTAMP 写 UTC 文本而被 JDBC 按本地时区解释,实测偏差 8 小时, 不能用于时间比较。迁移为纯新增列,旧二进制忽略它们,故回滚程序无需回滚 数据库;回填 last_success_epoch 必须执行,否则既有账号会被当成从未成功过 而集中补刷,正好复现本次要消除的爆发。 同时移除 LocalService 的 24 小时全量入口与 refreshAll()(它们正是爆发式 写法),并将调度线程池由默认 1 调到 2,避免刷新阻塞连接自检。 测试 439 通过,指令覆盖率 82.1%。
This commit is contained in:
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
db_path="${1:-LionWebsite.db}"
|
||||||
|
|
||||||
|
if [[ ! -f "$db_path" ]]; then
|
||||||
|
echo "数据库不存在: $db_path" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet lionwebsite 2>/dev/null; then
|
||||||
|
echo "检测到 lionwebsite.service 仍在运行;请先停止服务再迁移,避免写冲突。" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pgrep -f 'java .*lionwebsite\.jar' >/dev/null 2>&1; then
|
||||||
|
echo "检测到主站进程仍在运行;请先停止服务再迁移,避免写冲突。" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup="${db_path}.before-refresh-schedule.$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
cp "$db_path" "$backup"
|
||||||
|
echo "已备份: $backup"
|
||||||
|
|
||||||
|
sqlite3 "$db_path" < "$(dirname "$0")/migrate_subscription_refresh.sql"
|
||||||
|
|
||||||
|
echo "--- 校验 ---"
|
||||||
|
sqlite3 "$db_path" "select count(*) as accounts, sum(next_refresh_at is null) as missing_schedule from subscription_account;"
|
||||||
|
echo "订阅刷新计划迁移完成: $db_path"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 为每账号分散刷新增加计划字段。
|
||||||
|
-- 纯新增列:旧版本二进制忽略这两列,因此回滚程序时不必回滚数据库。
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- next_refresh_at:该子账号下一次应刷新的时刻,Epoch 毫秒。
|
||||||
|
-- 用整数存时刻,避免 SQLite 文本时间戳被按本地时区解释(实证存在 8 小时偏差)。
|
||||||
|
-- last_success_epoch:最近一次成功刷新的真实时刻,Epoch 毫秒。
|
||||||
|
-- 既有的 last_success_at 保持原样,仅供页面展示,不参与调度判断。
|
||||||
|
ALTER TABLE subscription_account ADD COLUMN next_refresh_at INTEGER;
|
||||||
|
ALTER TABLE subscription_account ADD COLUMN last_success_epoch INTEGER;
|
||||||
|
|
||||||
|
-- 回填 last_success_epoch:SQLite 的 strftime 把文本时间按 UTC 解释,而
|
||||||
|
-- CURRENT_TIMESTAMP 写入的正是 UTC,因此这里能得到正确时刻(JDBC 读取则会偏 8 小时)。
|
||||||
|
-- 必须回填:否则所有既有账号都会被当成「从未成功过」,迁移后立刻集中补刷一遍,
|
||||||
|
-- 正好复现本次改造要消除的爆发。
|
||||||
|
UPDATE subscription_account
|
||||||
|
SET last_success_epoch = CAST(strftime('%s', last_success_at) AS INTEGER) * 1000
|
||||||
|
WHERE last_success_epoch IS NULL AND last_success_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- 已有账号在窗口内按 id 错开,避免迁移后同一 tick 集中开火。
|
||||||
|
-- 以迁移时刻为基准按 37 分钟步长铺开(12 个账号约 7.4 小时排完)。
|
||||||
|
UPDATE subscription_account
|
||||||
|
SET next_refresh_at = (CAST(strftime('%s', 'now') AS INTEGER) * 1000) + (id * 37 * 60 * 1000)
|
||||||
|
WHERE next_refresh_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -2,6 +2,7 @@ package com.lion.lionwebsite.Configuration;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Domain.*;
|
import com.lion.lionwebsite.Domain.*;
|
||||||
import com.lion.lionwebsite.Message.*;
|
import com.lion.lionwebsite.Message.*;
|
||||||
|
import com.lion.lionwebsite.Service.SubscriptionRefreshPlanner;
|
||||||
import com.lion.lionwebsite.Util.GalleryUtil;
|
import com.lion.lionwebsite.Util.GalleryUtil;
|
||||||
import com.pengrad.telegrambot.TelegramBot;
|
import com.pengrad.telegrambot.TelegramBot;
|
||||||
import com.pengrad.telegrambot.model.*;
|
import com.pengrad.telegrambot.model.*;
|
||||||
@@ -14,6 +15,10 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
|
@RegisterReflectionForBinding(classes = {CustomConfiguration.class, GidToKey.class, ImageKeyCache.class,
|
||||||
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
|
GalleryForQuery.class, Gallery.class, GalleryTask.class, HikariConfig.class,
|
||||||
@@ -41,4 +46,22 @@ public class CustomBean {
|
|||||||
public TelegramBot getTelegramBot(){
|
public TelegramBot getTelegramBot(){
|
||||||
return new TelegramBot(botToken);
|
return new TelegramBot(botToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅分散刷新的排程器。
|
||||||
|
*
|
||||||
|
* <p>放在这里是为了让窗口、最小间隔、tick 周期与重试间隔集中由配置注入,
|
||||||
|
* 同时保持 {@link SubscriptionRefreshPlanner} 本身是可直接构造的纯对象
|
||||||
|
* (便于用固定时钟与固定随机种子做确定性单元测试)。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SubscriptionRefreshPlanner subscriptionRefreshPlanner(
|
||||||
|
@Value("${subscription.refresh.window-hours:24}") long windowHours,
|
||||||
|
@Value("${subscription.refresh.min-gap-minutes:60}") long minGapMinutes,
|
||||||
|
@Value("${subscription.refresh.tick-interval-ms:300000}") long tickIntervalMs,
|
||||||
|
@Value("${subscription.refresh.retry-delay-minutes:60}") long retryDelayMinutes) {
|
||||||
|
return new SubscriptionRefreshPlanner(Clock.systemDefaultZone(), new Random(),
|
||||||
|
Duration.ofHours(windowHours), Duration.ofMinutes(minGapMinutes),
|
||||||
|
Duration.ofMillis(tickIntervalMs), Duration.ofMinutes(retryDelayMinutes));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class PersonalController {
|
|||||||
@PostMapping("/updateSub")
|
@PostMapping("/updateSub")
|
||||||
public String updateSub() throws IOException {
|
public String updateSub() throws IOException {
|
||||||
Response response = Response.generateResponse();
|
Response response = Response.generateResponse();
|
||||||
if(localService.updateSub(true))
|
if(localService.updateSub())
|
||||||
response.success();
|
response.success();
|
||||||
else
|
else
|
||||||
response.failure();
|
response.failure();
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ public interface SubMapper {
|
|||||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||||
void insertSubscriptionAccount(SubscriptionAccount account);
|
void insertSubscriptionAccount(SubscriptionAccount account);
|
||||||
|
|
||||||
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa order by id")
|
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, next_refresh_at as nextRefreshAt, last_success_epoch as lastSuccessEpoch, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa order by id")
|
||||||
ArrayList<SubscriptionAccount> selectAllSubscriptionAccounts();
|
ArrayList<SubscriptionAccount> selectAllSubscriptionAccounts();
|
||||||
|
|
||||||
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa where id=#{id}")
|
@Select("select id, name, upstream_key as upstreamKey, filter_high_multiplier as filterHighMultiplier, enabled, last_success_at as lastSuccessAt, last_error as lastError, created_at as createdAt, updated_at as updatedAt, next_refresh_at as nextRefreshAt, last_success_epoch as lastSuccessEpoch, (select count(*) from sub_bind sb where sb.subscription_account_id = sa.id) as boundUserCount from subscription_account sa where id=#{id}")
|
||||||
SubscriptionAccount selectSubscriptionAccount(Integer id);
|
SubscriptionAccount selectSubscriptionAccount(Integer id);
|
||||||
|
|
||||||
@Select("select count(*) from subscription_account where name=#{name}")
|
@Select("select count(*) from subscription_account where name=#{name}")
|
||||||
@@ -28,12 +28,18 @@ public interface SubMapper {
|
|||||||
@Update("update subscription_account set name=#{name}, upstream_key=#{upstreamKey}, filter_high_multiplier=#{filterHighMultiplier}, enabled=#{enabled}, updated_at=CURRENT_TIMESTAMP, last_error=null where id=#{id}")
|
@Update("update subscription_account set name=#{name}, upstream_key=#{upstreamKey}, filter_high_multiplier=#{filterHighMultiplier}, enabled=#{enabled}, updated_at=CURRENT_TIMESTAMP, last_error=null where id=#{id}")
|
||||||
void updateSubscriptionAccount(SubscriptionAccount account);
|
void updateSubscriptionAccount(SubscriptionAccount account);
|
||||||
|
|
||||||
@Update("update subscription_account set last_success_at=CURRENT_TIMESTAMP, last_error=null, updated_at=CURRENT_TIMESTAMP where id=#{id}")
|
// last_success_at 仍写 SQLite 的 UTC 文本时间,仅供页面显示;
|
||||||
void markSubscriptionRefreshSuccess(Integer id);
|
// last_success_epoch 是调度判断用的权威时刻,两者口径不同,不要互相推导。
|
||||||
|
@Update("update subscription_account set last_success_at=CURRENT_TIMESTAMP, last_success_epoch=#{epoch}, last_error=null, updated_at=CURRENT_TIMESTAMP where id=#{id}")
|
||||||
|
void markSubscriptionRefreshSuccess(@Param("id") Integer id, @Param("epoch") long epoch);
|
||||||
|
|
||||||
@Update("update subscription_account set last_error=#{error}, updated_at=CURRENT_TIMESTAMP where id=#{id}")
|
@Update("update subscription_account set last_error=#{error}, updated_at=CURRENT_TIMESTAMP where id=#{id}")
|
||||||
void markSubscriptionRefreshFailure(@Param("id") Integer id, @Param("error") String error);
|
void markSubscriptionRefreshFailure(@Param("id") Integer id, @Param("error") String error);
|
||||||
|
|
||||||
|
/** 排定下一次刷新时刻;失败重试与次日分槽都走这里。 */
|
||||||
|
@Update("update subscription_account set next_refresh_at=#{nextRefreshAt}, updated_at=CURRENT_TIMESTAMP where id=#{id}")
|
||||||
|
void updateNextRefreshAt(@Param("id") Integer id, @Param("nextRefreshAt") long nextRefreshAt);
|
||||||
|
|
||||||
@Delete("delete from subscription_account where id=#{id}")
|
@Delete("delete from subscription_account where id=#{id}")
|
||||||
void deleteSubscriptionAccount(Integer id);
|
void deleteSubscriptionAccount(Integer id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.lion.lionwebsite.Domain;
|
package com.lion.lionwebsite.Domain;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
@@ -8,7 +7,6 @@ import java.util.Date;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
|
||||||
public class SubscriptionAccount {
|
public class SubscriptionAccount {
|
||||||
private Integer id;
|
private Integer id;
|
||||||
private String name;
|
private String name;
|
||||||
@@ -22,4 +20,12 @@ public class SubscriptionAccount {
|
|||||||
private Integer boundUserCount;
|
private Integer boundUserCount;
|
||||||
private String v2Url;
|
private String v2Url;
|
||||||
private String clashUrl;
|
private String clashUrl;
|
||||||
|
/**
|
||||||
|
* 下一次应刷新的时刻(Epoch 毫秒),由分散调度器排定。
|
||||||
|
* 刻意用整数而非 DATETIME:SQLite 文本时间戳会被 JDBC 按本地时区解释,
|
||||||
|
* 实测偏差 8 小时,不能用于时间比较。
|
||||||
|
*/
|
||||||
|
private Long nextRefreshAt;
|
||||||
|
/** 最近一次成功刷新的真实时刻(Epoch 毫秒),用于「24 小时内必刷一次」的判断与陈旧告警。 */
|
||||||
|
private Long lastSuccessEpoch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class LocalService{
|
|||||||
|
|
||||||
final RemoteService remoteService;
|
final RemoteService remoteService;
|
||||||
|
|
||||||
final SubscriptionRefreshService subscriptionRefreshService;
|
final SubscriptionRefreshScheduler subscriptionRefreshScheduler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查连接是否有效,如果无效自动重连
|
* 检查连接是否有效,如果无效自动重连
|
||||||
@@ -104,115 +104,23 @@ public class LocalService{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时更新订阅
|
* 手动「更新订阅」:立即刷新全部启用账号,供管理页按钮调用。
|
||||||
* @throws IOException 下载以及保存异常
|
*
|
||||||
|
* <p>定时刷新已移交 {@link SubscriptionRefreshScheduler}。原先这里还有一个
|
||||||
|
* {@code @Scheduled(fixedRate = 86400000)} 的定时入口,它会一次性刷新全部账号
|
||||||
|
* (十几个账号在十几秒内打满),且因为没有 initialDelay,每次重启都会立刻重刷一遍,
|
||||||
|
* 使上游看到的请求密度取决于部署频率。该入口已移除。
|
||||||
|
*
|
||||||
|
* <p>不再保留 {@code isManual} 参数:定时路径已不存在,留着它只会让人以为
|
||||||
|
* 「传 false 就是不刷新」,而实际语义是「什么都不做却报成功」。
|
||||||
*/
|
*/
|
||||||
@Scheduled(fixedRate = 86400000)
|
public boolean updateSub() {
|
||||||
public void updateSubScheduler() throws IOException {
|
boolean success = subscriptionRefreshScheduler.refreshAllNow();
|
||||||
updateSub(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新订阅链接的实际方法
|
|
||||||
*/
|
|
||||||
public boolean updateSub(boolean isManual) throws IOException {
|
|
||||||
// 手动和定时入口均刷新全部启用子账号;全部成功后更新原有“上次更新时间”。
|
|
||||||
boolean success = subscriptionRefreshService.refreshAll();
|
|
||||||
remoteService.requestSubscriptionSync();
|
remoteService.requestSubscriptionSync();
|
||||||
if (success)
|
if (success)
|
||||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
|
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
|
||||||
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
|
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
|
||||||
return success;
|
return success;
|
||||||
/*
|
|
||||||
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
|
|
||||||
CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
|
|
||||||
|
|
||||||
//如果不是手动,则判断更新间隔是否满足,不满足则取消更新
|
|
||||||
if (!isManual) {
|
|
||||||
LocalDateTime lastUpdate = LocalDateTime.parse(customConfiguration.getValue(), CustomUtil.dateTimeFormatter());
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
now = now.plusHours(-3);
|
|
||||||
if(now.isBefore(lastUpdate))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
File DouNaiClashFile = new File("sub/DouNaiClash.txt");
|
|
||||||
File DouNaiV2rayFile = new File("sub/DouNaiV2ray.txt");
|
|
||||||
File directory = new File("sub");
|
|
||||||
|
|
||||||
if(!directory.isDirectory())
|
|
||||||
Files.createDirectory(Paths.get("sub"));
|
|
||||||
|
|
||||||
List<String> DouNaiClash_profile;
|
|
||||||
|
|
||||||
//下载豆奶v2ray订阅
|
|
||||||
try(FileWriter writer = new FileWriter(DouNaiV2rayFile)) {
|
|
||||||
String DouNaiV2rayRaw = Get(DouNaiV2ray).getFirst();
|
|
||||||
String[] v2rayPlain = new String(Base64.getDecoder().decode(DouNaiV2rayRaw)).split("\n");
|
|
||||||
StringBuilder stringBuilder = new StringBuilder();
|
|
||||||
Pattern pattern = Pattern.compile("-?\\d+(\\.\\d+)?");
|
|
||||||
|
|
||||||
//过滤高倍率节点
|
|
||||||
for(String node: v2rayPlain){
|
|
||||||
String name = URLDecoder.decode(node.split("#")[1], StandardCharsets.UTF_8);
|
|
||||||
if(name.contains("流量")){
|
|
||||||
Matcher matcher = pattern.matcher(name.substring(name.indexOf("(") + 1, name.indexOf(")")));
|
|
||||||
|
|
||||||
if (matcher.find()) {
|
|
||||||
// 将匹配到的数字添加到列表中
|
|
||||||
float ratio = Float.parseFloat(matcher.group());
|
|
||||||
if(ratio <= 2) {
|
|
||||||
stringBuilder.append(node).append("\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stringBuilder.append(node).append("\n");
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
stringBuilder.append(node).append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.write(new String(Base64.getEncoder().encode(stringBuilder.toString().getBytes(StandardCharsets.UTF_8))));
|
|
||||||
|
|
||||||
log.info("load DouNai v2ray complete");
|
|
||||||
}catch (IOException e){
|
|
||||||
log.error("load DouNai v2ray failure", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
//下载豆奶clash订阅
|
|
||||||
try(FileWriter writer = new FileWriter(DouNaiClashFile)) {
|
|
||||||
DouNaiClash_profile = Get(DouNaiClash);
|
|
||||||
//过滤高倍率节点
|
|
||||||
ArrayList<String> clashProcessed = new ArrayList<>();
|
|
||||||
boolean isProxies = false;
|
|
||||||
boolean skip = false;
|
|
||||||
for(String line: DouNaiClash_profile){
|
|
||||||
if(line.equals("proxies:"))
|
|
||||||
isProxies = true;
|
|
||||||
else if(line.equals("proxy-groups:") && isProxies)
|
|
||||||
isProxies = false;
|
|
||||||
|
|
||||||
if(isProxies) {
|
|
||||||
if (line.contains("name"))
|
|
||||||
skip = line.contains("流量");
|
|
||||||
if (!skip)
|
|
||||||
clashProcessed.add(line);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
if (!line.contains("流量"))
|
|
||||||
clashProcessed.add(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(String line: clashProcessed)
|
|
||||||
writer.write(line + "\n");
|
|
||||||
log.info("load DouNai clash complete");
|
|
||||||
}catch (IOException e){
|
|
||||||
log.error("load DouNai clash failure", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now()));
|
|
||||||
return true;
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ public class SubService {
|
|||||||
return response.failure("名称和上游 key 不能为空").toJSONString();
|
return response.failure("名称和上游 key 不能为空").toJSONString();
|
||||||
if (subMapper.countSubscriptionAccountName(name.trim()) > 0 || subMapper.countSubscriptionAccountKey(upstreamKey.trim()) > 0)
|
if (subMapper.countSubscriptionAccountName(name.trim()) > 0 || subMapper.countSubscriptionAccountKey(upstreamKey.trim()) > 0)
|
||||||
return response.failure("名称或上游 key 已存在").toJSONString();
|
return response.failure("名称或上游 key 已存在").toJSONString();
|
||||||
account = new SubscriptionAccount(null, name.trim(), upstreamKey.trim(), filterHighMultiplier, enabled, null, null, null, null, 0, null, null);
|
account = new SubscriptionAccount();
|
||||||
|
account.setName(name.trim());
|
||||||
|
account.setUpstreamKey(upstreamKey.trim());
|
||||||
|
account.setFilterHighMultiplier(filterHighMultiplier);
|
||||||
|
account.setEnabled(enabled);
|
||||||
subMapper.insertSubscriptionAccount(account);
|
subMapper.insertSubscriptionAccount(account);
|
||||||
} finally {
|
} finally {
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为一批子账号排定刷新时刻。
|
||||||
|
*
|
||||||
|
* <p>目标:每个账号每 24 小时内至少刷新一次,且各账号的时刻互相错开,
|
||||||
|
* 不出现「一次全量、十几个账号在十几秒内打完」的爆发式请求。
|
||||||
|
*
|
||||||
|
* <p>排程模型是「错峰 + 固定相位」:
|
||||||
|
* <ol>
|
||||||
|
* <li><b>错峰</b>:首次排程把账号随机铺满一个窗口,两两之间至少隔开 {@code minGap};</li>
|
||||||
|
* <li><b>固定相位</b>:账号每次成功后按窗口长度(略减一个 tick)推进,
|
||||||
|
* 因此各自的相位保持不变,分散效果自我维持,不需要每天重新洗牌;</li>
|
||||||
|
* <li><b>兜底</b>:任何情况下下一次刷新都不会晚于「上次成功 + 窗口」,
|
||||||
|
* 把「24 小时内必然刷新一次」从概率保证变成确定保证。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>本类是纯函数式设计(注入 {@link Clock} 与带种子的 {@link Random}),
|
||||||
|
* 不触碰数据库与网络,因此可以直接做确定性单元测试。
|
||||||
|
*/
|
||||||
|
public class SubscriptionRefreshPlanner {
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final Random random;
|
||||||
|
private final Duration window;
|
||||||
|
private final Duration minGap;
|
||||||
|
/** 一个调度周期的最大延迟;用它给窗口留余量,使实际间隔严格落在窗口内。 */
|
||||||
|
private final Duration tickInterval;
|
||||||
|
/** 刷新失败后的重试间隔。 */
|
||||||
|
private final Duration retryDelay;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param clock 时间源,测试可注入固定时钟
|
||||||
|
* @param random 随机源,测试可注入固定种子以复现排程
|
||||||
|
* @param window 刷新窗口,即「24 小时内至少一次」中的 24 小时
|
||||||
|
* @param minGap 相邻两个账号之间的最小间隔
|
||||||
|
* @param tickInterval 调度周期,用于给窗口留出余量
|
||||||
|
* @param retryDelay 失败后的重试间隔
|
||||||
|
*/
|
||||||
|
public SubscriptionRefreshPlanner(Clock clock, Random random, Duration window, Duration minGap,
|
||||||
|
Duration tickInterval, Duration retryDelay) {
|
||||||
|
if (window.isZero() || window.isNegative())
|
||||||
|
throw new IllegalArgumentException("刷新窗口必须为正数");
|
||||||
|
if (minGap.isNegative())
|
||||||
|
throw new IllegalArgumentException("最小间隔不能为负数");
|
||||||
|
if (tickInterval.isNegative())
|
||||||
|
throw new IllegalArgumentException("调度周期不能为负数");
|
||||||
|
if (retryDelay.isZero() || retryDelay.isNegative())
|
||||||
|
throw new IllegalArgumentException("重试间隔必须为正数");
|
||||||
|
if (retryDelay.compareTo(window) > 0)
|
||||||
|
throw new IllegalArgumentException("重试间隔不能大于刷新窗口,否则无法保证窗口内重试");
|
||||||
|
this.clock = clock;
|
||||||
|
this.random = random;
|
||||||
|
this.window = window;
|
||||||
|
this.minGap = minGap;
|
||||||
|
this.tickInterval = tickInterval;
|
||||||
|
this.retryDelay = retryDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷构造:重试间隔默认取 1 小时。 */
|
||||||
|
public SubscriptionRefreshPlanner(Clock clock, Random random, Duration window, Duration minGap,
|
||||||
|
Duration tickInterval) {
|
||||||
|
this(clock, random, window, minGap, tickInterval, Duration.ofHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号数与最小间隔是否放得下。
|
||||||
|
*
|
||||||
|
* <p>注意这里刻意只用于「提示」而不是阻断启动:账号数是由用户在管理页决定的,
|
||||||
|
* 若因为它超限就让整个应用起不来,等于把配置细节变成一次线上故障。
|
||||||
|
* 放不下时 {@link #initialSchedule} 仍会把账号铺满整个窗口(只是间隔小于期望值),
|
||||||
|
* 结果是错峰效果变弱,而不是退回串行爆发。
|
||||||
|
*/
|
||||||
|
public boolean isCapacitySufficient(int accountCount) {
|
||||||
|
if (accountCount <= 1)
|
||||||
|
return true;
|
||||||
|
return minGap.toMillis() * (accountCount - 1) <= window.toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 容量不足时的告警文案,供调用方记录日志。 */
|
||||||
|
public String capacityMessage(int accountCount) {
|
||||||
|
long required = minGap.toMillis() * (accountCount - 1);
|
||||||
|
return String.format(
|
||||||
|
"刷新窗口放不下最小间隔:账号 %d 个、最小间隔 %d 分钟,需要 %d 分钟,但窗口只有 %d 分钟;"
|
||||||
|
+ "实际仍会铺满窗口,只是账号间间隔会小于期望值。可调小 subscription.refresh.min-gap-minutes。",
|
||||||
|
accountCount, minGap.toMinutes(),
|
||||||
|
Duration.ofMillis(required).toMinutes(), window.toMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为一批「已成功刷新过」的账号铺开计划,返回顺序与 accountIds 一致。
|
||||||
|
*
|
||||||
|
* <p>已成功过的账号内容仍然可用,因此可以安心分散到整个窗口,
|
||||||
|
* 不会因为排在 20 小时后而让用户拿不到订阅。
|
||||||
|
*/
|
||||||
|
public List<Long> initialSchedule(List<Integer> accountIds) {
|
||||||
|
List<Long> result = new ArrayList<>(accountIds.size());
|
||||||
|
if (accountIds.isEmpty())
|
||||||
|
return result;
|
||||||
|
|
||||||
|
long now = clock.millis();
|
||||||
|
long windowMillis = window.toMillis();
|
||||||
|
int count = accountIds.size();
|
||||||
|
long slotWidth = windowMillis / count;
|
||||||
|
long jitterCap = Math.max(1, Math.min(slotWidth, Math.max(1, slotWidth - minGap.toMillis())));
|
||||||
|
|
||||||
|
List<Integer> slots = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
slots.add(i);
|
||||||
|
Collections.shuffle(slots, random);
|
||||||
|
|
||||||
|
long[] planned = new long[count];
|
||||||
|
for (int accountIndex = 0; accountIndex < count; accountIndex++) {
|
||||||
|
long offset = slots.get(accountIndex) * slotWidth + (long) (random.nextDouble() * jitterCap);
|
||||||
|
planned[accountIndex] = now + Math.min(offset, windowMillis - 1);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
result.add(planned[i]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为尚未成功过的账号排程:尽快刷,但互相错开。
|
||||||
|
*
|
||||||
|
* <p>这类账号(新建、或上游 key 刚改过)可能根本没有可用缓存,用户立刻请求订阅
|
||||||
|
* 就会拿不到内容,因此不能像常规账号那样分散到整个窗口。这里把它们按
|
||||||
|
* {@code spacing} 依次排开,配合调度器的单轮上限逐批刷新,
|
||||||
|
* 既尽早补齐,又避免一批新账号同时打上游。
|
||||||
|
*
|
||||||
|
* @param accountIds 账号 ID,顺序即刷新优先级
|
||||||
|
* @param spacing 相邻两个账号的间隔(通常取一个 tick)
|
||||||
|
*/
|
||||||
|
public List<Long> coldStartSchedule(List<Integer> accountIds, Duration spacing) {
|
||||||
|
List<Long> result = new ArrayList<>(accountIds.size());
|
||||||
|
long now = clock.millis();
|
||||||
|
long step = Math.max(1, spacing.toMillis());
|
||||||
|
for (int i = 0; i < accountIds.size(); i++)
|
||||||
|
result.add(now + i * step);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已排定的时刻是否已到期。
|
||||||
|
*
|
||||||
|
* <p>直接以排定时刻为准,这样分散效果与失败退避都得以保持。
|
||||||
|
* 只额外兜住一种异常:计划被写到超过一个窗口之后。这不可能是本调度器产生的
|
||||||
|
* 排程(成功时排在窗口减一个 tick 之后,失败时排在 4 小时以内),
|
||||||
|
* 按「计划损坏」处理并立即到期,从而保证 24 小时内必定刷新。
|
||||||
|
*
|
||||||
|
* <p>尚未排程({@code scheduled == null})视为立即到期,由调用方先行铺开计划。
|
||||||
|
*/
|
||||||
|
public boolean isDue(Long scheduled, long now) {
|
||||||
|
if (scheduled == null)
|
||||||
|
return true;
|
||||||
|
if (scheduled - now > window.toMillis())
|
||||||
|
return true;
|
||||||
|
return scheduled <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功之后的下一次刷新时刻。
|
||||||
|
*
|
||||||
|
* <p>只推进「窗口 - 一个调度周期」,使 tick 即使晚触发也不会突破 24 小时;
|
||||||
|
* 从上次成功的时刻起算,保证相邻两次的间隔严格小于窗口。
|
||||||
|
*/
|
||||||
|
public long nextAfterSuccess(long successMillis) {
|
||||||
|
long advance = Math.max(1, window.minus(tickInterval).toMillis());
|
||||||
|
return successMillis + advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败之后的重试时刻。
|
||||||
|
*
|
||||||
|
* <p>用固定的重试间隔而不是「失败次数 × 指数退避」:刷新成功会清空 {@code last_error},
|
||||||
|
* 数据库里没有「连续失败次数」这一栏,退避指数实际上只能取到 0 或 1,
|
||||||
|
* 写出来会让人误以为有指数退避而其实没有。固定的重试间隔行为可预期,
|
||||||
|
* 且远小于窗口,不会威胁「24 小时内尝试刷新一次」。
|
||||||
|
*
|
||||||
|
* <p>唯一需要裁剪的情况:若「上次成功 + 窗口」还没到,则重试不得晚于它;
|
||||||
|
* 已经越过该点时保留重试时刻本身——重复请求并不会提高成功率,
|
||||||
|
* 反过来在每个 tick 重打上游才是真正的问题,这种情况交由陈旧告警处理。
|
||||||
|
*
|
||||||
|
* @param now 当前时刻
|
||||||
|
* @param lastSuccess 上次成功时刻;未知时传 null,不做裁剪
|
||||||
|
*/
|
||||||
|
public long nextAfterFailure(long now, Long lastSuccess) {
|
||||||
|
long candidate = now + retryDelay.toMillis();
|
||||||
|
if (lastSuccess == null)
|
||||||
|
return candidate;
|
||||||
|
long deadline = lastSuccess + window.toMillis();
|
||||||
|
return deadline > now ? Math.min(candidate, deadline) : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已越过兜底线(距上次成功已达一个窗口),用于诊断与告警判定。 */
|
||||||
|
public boolean isOverdue(Long lastSuccess, long now) {
|
||||||
|
return lastSuccess != null && now - lastSuccess >= window.toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long now() {
|
||||||
|
return clock.millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Clock clock() {
|
||||||
|
return clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration window() {
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration minGap() {
|
||||||
|
return minGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration tickInterval() {
|
||||||
|
return tickInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分散刷新各子账号的订阅。
|
||||||
|
*
|
||||||
|
* <p>取代原先「每 24 小时一次全量」的调度。旧写法有两个问题:所有账号在十几秒内
|
||||||
|
* 背靠背打完,且 {@code @Scheduled(fixedRate = 86400000)} 没有 initialDelay,
|
||||||
|
* 首次触发即立刻执行——于是每次重启都会重刷一遍全部账号,上游看到的请求密度取决于
|
||||||
|
* 部署频率,而不是每天一次。
|
||||||
|
*
|
||||||
|
* <p>现在改为高频轻量 tick,每轮只刷新已经到期的账号。「24 小时内每个账号至少
|
||||||
|
* 更新一次」由三层保证:
|
||||||
|
* <ol>
|
||||||
|
* <li>成功后排定「成功时刻 + 窗口 - 一个 tick」,相位自我维持,分散效果不会因
|
||||||
|
* 重启或重排而退化;</li>
|
||||||
|
* <li>到期时刻存在数据库里,停机期间错过计划的账号在恢复后立即到期;</li>
|
||||||
|
* <li>兜底闸:任何排定时刻都被裁剪到「上次成功 + 窗口」以内,越线即立刻刷新。</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class SubscriptionRefreshScheduler {
|
||||||
|
|
||||||
|
private final SubMapper subMapper;
|
||||||
|
private final SubscriptionRefreshService refreshService;
|
||||||
|
private final SubscriptionRefreshPlanner planner;
|
||||||
|
private final PushService pushService;
|
||||||
|
|
||||||
|
/** 单个 tick 最多刷新几个账号,避免重启后一批到期账号同时开火。 */
|
||||||
|
@Value("${subscription.refresh.max-per-tick:2}")
|
||||||
|
int maxPerTick;
|
||||||
|
|
||||||
|
/** 超过窗口的这个倍数仍未成功即告警,避免静默失败。 */
|
||||||
|
@Value("${subscription.refresh.stale-alert-multiplier:2.0}")
|
||||||
|
double staleAlertMultiplier;
|
||||||
|
|
||||||
|
/** 已告警过的账号,避免每轮重复推送;刷新成功后移除。 */
|
||||||
|
private final Set<Integer> alertedAccounts = new HashSet<>();
|
||||||
|
|
||||||
|
public SubscriptionRefreshScheduler(SubMapper subMapper,
|
||||||
|
SubscriptionRefreshService refreshService,
|
||||||
|
SubscriptionRefreshPlanner planner,
|
||||||
|
PushService pushService) {
|
||||||
|
this.subMapper = subMapper;
|
||||||
|
this.refreshService = refreshService;
|
||||||
|
this.planner = planner;
|
||||||
|
this.pushService = pushService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量 tick:只处理已到期的账号,且单轮有数量上限。
|
||||||
|
*
|
||||||
|
* <p>用 fixedDelay 而非 fixedRate,一轮结束再计下一轮,避免上一轮没跑完就叠加触发;
|
||||||
|
* initialDelay 让重启后的第一轮稍晚开始,先让应用完成启动。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${subscription.refresh.tick-interval-ms:300000}",
|
||||||
|
initialDelayString = "${subscription.refresh.initial-delay-ms:120000}")
|
||||||
|
public void tick() {
|
||||||
|
try {
|
||||||
|
refreshDueAccounts();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 调度方法抛出异常会导致后续触发被取消,这里必须兜住。
|
||||||
|
log.error("订阅分散刷新 tick 失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新当前已到期的账号。
|
||||||
|
*
|
||||||
|
* @return 本轮实际刷新的账号数
|
||||||
|
*/
|
||||||
|
public int refreshDueAccounts() {
|
||||||
|
List<SubscriptionAccount> enabled = enabledAccounts();
|
||||||
|
ensureScheduled(enabled);
|
||||||
|
|
||||||
|
long now = planner.clock().millis();
|
||||||
|
int refreshed = 0;
|
||||||
|
for (SubscriptionAccount account : enabled) {
|
||||||
|
if (refreshed >= maxPerTick)
|
||||||
|
break;
|
||||||
|
if (!isDue(account, now))
|
||||||
|
continue;
|
||||||
|
refreshOne(account, now);
|
||||||
|
refreshed++;
|
||||||
|
}
|
||||||
|
reportStale(enabled, now);
|
||||||
|
return refreshed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新入口:立即刷新全部启用账号。
|
||||||
|
*
|
||||||
|
* <p>刻意不受 {@code maxPerTick} 限制——管理页「更新订阅」的语义就是立刻全部刷新。
|
||||||
|
* 但成功后同样要重排各自的下一次时刻,否则刚手动刷完会让当天的计划作废,
|
||||||
|
* 该账号反而可能超过 24 小时没有下次更新。
|
||||||
|
*/
|
||||||
|
public boolean refreshAllNow() {
|
||||||
|
boolean success = true;
|
||||||
|
long now = planner.clock().millis();
|
||||||
|
for (SubscriptionAccount account : enabledAccounts()) {
|
||||||
|
if (!refreshOne(account, now))
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SubscriptionAccount> enabledAccounts() {
|
||||||
|
return subMapper.selectAllSubscriptionAccounts().stream()
|
||||||
|
.filter(SubscriptionAccount::isEnabled)
|
||||||
|
.sorted(Comparator.comparing(SubscriptionAccount::getId))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新单个账号并按结果重排下一次时刻。 */
|
||||||
|
private boolean refreshOne(SubscriptionAccount account, long now) {
|
||||||
|
boolean ok = refreshService.refresh(account.getId());
|
||||||
|
if (ok) {
|
||||||
|
long next = planner.nextAfterSuccess(planner.clock().millis());
|
||||||
|
subMapper.updateNextRefreshAt(account.getId(), next);
|
||||||
|
alertedAccounts.remove(account.getId());
|
||||||
|
log.info("订阅刷新成功 accountId={} 下次刷新={}", account.getId(), next);
|
||||||
|
} else {
|
||||||
|
long next = planner.nextAfterFailure(now, account.getLastSuccessEpoch());
|
||||||
|
subMapper.updateNextRefreshAt(account.getId(), next);
|
||||||
|
log.warn("订阅刷新失败 accountId={} 下次重试={}", account.getId(), next);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号是否到期。
|
||||||
|
*
|
||||||
|
* <p>以排定时刻为准;尚未排程或计划明显损坏(超出窗口)时视为立即到期,
|
||||||
|
* 由 {@link #ensureScheduled} 先铺开或纠正。
|
||||||
|
*/
|
||||||
|
private boolean isDue(SubscriptionAccount account, long now) {
|
||||||
|
return planner.isDue(account.getNextRefreshAt(), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为尚无计划的账号补齐计划。
|
||||||
|
*
|
||||||
|
* <p>按紧迫性分三类,因为它们对「排到多晚」的容忍度不同:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>从未成功过</b>(新建账号、刚改过上游 key):可能还没有可用缓存,
|
||||||
|
* 用户此时请求订阅会拿不到内容,因此尽快刷,只按 tick 间隔互相错开;</li>
|
||||||
|
* <li><b>已成功过但已超期</b>(距上次成功已达一个窗口):保证即将失效,
|
||||||
|
* 必须立即刷新,不能排到窗口内更晚的位置;</li>
|
||||||
|
* <li><b>已成功过且未超期</b>:缓存仍可分发,按窗口分散即可,不必挤在一起。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private void ensureScheduled(List<SubscriptionAccount> enabled) {
|
||||||
|
long now = planner.clock().millis();
|
||||||
|
List<SubscriptionAccount> cold = new ArrayList<>();
|
||||||
|
List<SubscriptionAccount> overdue = new ArrayList<>();
|
||||||
|
List<SubscriptionAccount> warm = new ArrayList<>();
|
||||||
|
for (SubscriptionAccount account : enabled) {
|
||||||
|
if (account.getNextRefreshAt() != null)
|
||||||
|
continue;
|
||||||
|
if (account.getLastSuccessEpoch() == null)
|
||||||
|
cold.add(account);
|
||||||
|
else if (planner.isOverdue(account.getLastSuccessEpoch(), now))
|
||||||
|
overdue.add(account);
|
||||||
|
else
|
||||||
|
warm.add(account);
|
||||||
|
}
|
||||||
|
if (cold.isEmpty() && overdue.isEmpty() && warm.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!planner.isCapacitySufficient(enabled.size()))
|
||||||
|
log.warn("{}", planner.capacityMessage(enabled.size()));
|
||||||
|
|
||||||
|
// 超期账号立即到期(计划设为当前时刻),本轮就会被刷新。
|
||||||
|
for (SubscriptionAccount account : overdue)
|
||||||
|
schedule(account, now);
|
||||||
|
|
||||||
|
int index = 0;
|
||||||
|
List<Long> coldPlan = planner.coldStartSchedule(
|
||||||
|
cold.stream().map(SubscriptionAccount::getId).toList(), planner.tickInterval());
|
||||||
|
for (SubscriptionAccount account : cold)
|
||||||
|
schedule(account, coldPlan.get(index++));
|
||||||
|
|
||||||
|
List<Integer> warmIds = warm.stream().map(SubscriptionAccount::getId).toList();
|
||||||
|
List<Long> warmPlan = planner.initialSchedule(warmIds);
|
||||||
|
for (int i = 0; i < warm.size(); i++)
|
||||||
|
schedule(warm.get(i), warmPlan.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入新计划,并同步更新内存中的对象。
|
||||||
|
*
|
||||||
|
* <p>必须同时更新内存副本:否则刚被排到几小时后的账号在本轮仍带着
|
||||||
|
* {@code nextRefreshAt == null},会被 {@link #isDue} 判为立即到期并当场刷新,
|
||||||
|
* 于是「铺开」在第一个 tick 就失效了(迁移后首批账号会全部集中刷新)。
|
||||||
|
*/
|
||||||
|
private void schedule(SubscriptionAccount account, long nextRefreshAt) {
|
||||||
|
subMapper.updateNextRefreshAt(account.getId(), nextRefreshAt);
|
||||||
|
account.setNextRefreshAt(nextRefreshAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 陈旧账号告警:同一账号在连续陈旧期间只推一次,刷新成功后重置。 */
|
||||||
|
private void reportStale(List<SubscriptionAccount> enabled, long now) {
|
||||||
|
long threshold = (long) (planner.window().toMillis() * staleAlertMultiplier);
|
||||||
|
for (SubscriptionAccount account : enabled) {
|
||||||
|
Long lastSuccess = account.getLastSuccessEpoch();
|
||||||
|
if (lastSuccess == null || now - lastSuccess < threshold)
|
||||||
|
continue;
|
||||||
|
if (!alertedAccounts.add(account.getId()))
|
||||||
|
continue;
|
||||||
|
long hours = (now - lastSuccess) / 3_600_000L;
|
||||||
|
String message = String.format("订阅账号 %d(%s)已 %d 小时未成功刷新,最近错误:%s",
|
||||||
|
account.getId(), account.getName(), hours,
|
||||||
|
account.getLastError() == null ? "无" : account.getLastError());
|
||||||
|
log.error("{}", message);
|
||||||
|
pushService.sendToMe(message);
|
||||||
|
}
|
||||||
|
// 已删除或停用的账号不再保留告警状态,避免集合无限增长。
|
||||||
|
Set<Integer> alive = new HashSet<>();
|
||||||
|
for (SubscriptionAccount account : enabled)
|
||||||
|
alive.add(account.getId());
|
||||||
|
alertedAccounts.retainAll(alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionRefreshPlanner planner() {
|
||||||
|
return planner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.lion.lionwebsite.Service;
|
|||||||
|
|
||||||
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import com.lion.lionwebsite.Util.SubscriptionClientProfile;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
@@ -18,6 +19,7 @@ import java.io.*;
|
|||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.*;
|
import java.nio.file.*;
|
||||||
|
import java.time.Clock;
|
||||||
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;
|
||||||
@@ -52,14 +54,8 @@ public class SubscriptionRefreshService {
|
|||||||
@Value("${subscription.cache-root:sub/accounts}")
|
@Value("${subscription.cache-root:sub/accounts}")
|
||||||
String cacheRoot;
|
String cacheRoot;
|
||||||
|
|
||||||
public boolean refreshAll() {
|
/** 时间源,测试可替换以固定成功时刻。 */
|
||||||
boolean success = true;
|
Clock clock = Clock.systemDefaultZone();
|
||||||
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
|
|
||||||
if (account.isEnabled() && !refresh(account.getId()))
|
|
||||||
success = false;
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long refreshSequence;
|
private long refreshSequence;
|
||||||
// Accessed only while holding the coordinator write lock.
|
// Accessed only while holding the coordinator write lock.
|
||||||
@@ -82,8 +78,10 @@ public class SubscriptionRefreshService {
|
|||||||
|
|
||||||
// Network access and parsing never hold the shared subscription lock.
|
// Network access and parsing never hold the shared subscription lock.
|
||||||
try {
|
try {
|
||||||
String v2 = processV2(firstLine(download(v2Url(account))), account.isFilterHighMultiplier(), highMultiplierThreshold);
|
String v2 = processV2(firstLine(download(v2Url(account), SubscriptionClientProfile.forAccount(accountId, false))),
|
||||||
List<String> clash = processClash(download(clashUrl(account)), account.isFilterHighMultiplier(), highMultiplierThreshold);
|
account.isFilterHighMultiplier(), highMultiplierThreshold);
|
||||||
|
List<String> clash = processClash(download(clashUrl(account), SubscriptionClientProfile.forAccount(accountId, true)),
|
||||||
|
account.isFilterHighMultiplier(), highMultiplierThreshold);
|
||||||
stateLock.lock();
|
stateLock.lock();
|
||||||
try {
|
try {
|
||||||
if (!isCurrent(account, version))
|
if (!isCurrent(account, version))
|
||||||
@@ -92,7 +90,7 @@ public class SubscriptionRefreshService {
|
|||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
atomicWrite(dir.resolve("v2ray.txt"), v2.getBytes(StandardCharsets.UTF_8));
|
atomicWrite(dir.resolve("v2ray.txt"), v2.getBytes(StandardCharsets.UTF_8));
|
||||||
atomicWrite(dir.resolve("clash.yaml"), String.join("\n", clash).concat("\n").getBytes(StandardCharsets.UTF_8));
|
atomicWrite(dir.resolve("clash.yaml"), String.join("\n", clash).concat("\n").getBytes(StandardCharsets.UTF_8));
|
||||||
subMapper.markSubscriptionRefreshSuccess(accountId);
|
subMapper.markSubscriptionRefreshSuccess(accountId, clock.millis());
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
stateLock.unlock();
|
stateLock.unlock();
|
||||||
@@ -219,8 +217,24 @@ public class SubscriptionRefreshService {
|
|||||||
return matcher.find() && Double.parseDouble(matcher.group(1)) > threshold;
|
return matcher.find() && Double.parseDouble(matcher.group(1)) > threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> download(String url) throws IOException {
|
/**
|
||||||
|
* 下载上游订阅正文。
|
||||||
|
*
|
||||||
|
* @param profile 要伪装的客户端身份;为 null 时保留 HttpClient 默认头(仅测试使用)。
|
||||||
|
* 生产路径必须传入,否则上游会看到 {@code Apache-HttpClient/... (Java/...)}。
|
||||||
|
*/
|
||||||
|
List<String> download(String url, SubscriptionClientProfile profile) throws IOException {
|
||||||
HttpGet get = new HttpGet(url);
|
HttpGet get = new HttpGet(url);
|
||||||
|
if (profile != null) {
|
||||||
|
get.addHeader("User-Agent", profile.userAgent());
|
||||||
|
get.addHeader("Accept", profile.accept());
|
||||||
|
get.addHeader("Accept-Language", profile.acceptLanguage());
|
||||||
|
// 实测:显式设置 Accept-Encoding 不会破坏 HttpClient5 的透明解压,
|
||||||
|
// ContentCompressionExec 仍按 Content-Encoding 正确解码。
|
||||||
|
get.addHeader("Accept-Encoding", "gzip, deflate");
|
||||||
|
// 订阅客户端是长连接复用,且不会发送 Referer。
|
||||||
|
get.addHeader("Connection", "keep-alive");
|
||||||
|
}
|
||||||
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
|
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
|
||||||
if (response.getCode() != 200)
|
if (response.getCode() != 200)
|
||||||
throw new IOException("上游 HTTP 状态码 " + response.getCode());
|
throw new IOException("上游 HTTP 状态码 " + response.getCode());
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抓取上游订阅时伪装的客户端身份。
|
||||||
|
*
|
||||||
|
* <p>此前请求只带 HttpClient 的默认头,实测上游收到的是
|
||||||
|
* {@code User-agent: Apache-HttpClient/5.6.4 (Java/25.0.4)},
|
||||||
|
* 对订阅站点而言等同于「这是一个 Java 程序在批量拉取」。
|
||||||
|
* 这里按订阅格式给出与真实客户端一致的常见头组合。
|
||||||
|
*
|
||||||
|
* <p>注意:只能伪装到 HTTP 头这一层。真实 Clash/Mihomo 使用 Go 的 TLS 栈与
|
||||||
|
* HTTP/2,TLS 指纹与 Java HttpClient 仍有差异;若上游做主动指纹识别,
|
||||||
|
* 需要另外引入真实客户端进程,本枚举解决不了。
|
||||||
|
*/
|
||||||
|
public enum SubscriptionClientProfile {
|
||||||
|
MIHOMO("mihomo/v1.19.2", "application/yaml, */*", "zh-CN,zh;q=0.9"),
|
||||||
|
CLASH_VERGE("clash-verge/v2.0.3", "application/yaml, */*", "zh-CN,zh;q=0.9"),
|
||||||
|
CLASH_WINDOWS("ClashforWindows/0.20.39", "*/*", "zh-CN,zh;q=0.9"),
|
||||||
|
V2RAYN("v2rayN/6.45", "*/*", "zh-CN,zh;q=0.9"),
|
||||||
|
V2RAYNG("v2rayNG/1.9.16", "*/*", "zh-CN,zh;q=0.9"),
|
||||||
|
SHADOWROCKET("Shadowrocket/2.2.39", "*/*", "zh-CN,zh;q=0.9");
|
||||||
|
|
||||||
|
static final List<SubscriptionClientProfile> CLASH_PROFILES = List.of(MIHOMO, CLASH_VERGE, CLASH_WINDOWS);
|
||||||
|
static final List<SubscriptionClientProfile> V2_PROFILES = List.of(V2RAYN, V2RAYNG, SHADOWROCKET);
|
||||||
|
|
||||||
|
private final String userAgent;
|
||||||
|
private final String accept;
|
||||||
|
private final String acceptLanguage;
|
||||||
|
|
||||||
|
SubscriptionClientProfile(String userAgent, String accept, String acceptLanguage) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
this.accept = accept;
|
||||||
|
this.acceptLanguage = acceptLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String userAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String accept() {
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String acceptLanguage() {
|
||||||
|
return acceptLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按账号与订阅格式确定性地选择一个身份。
|
||||||
|
*
|
||||||
|
* <p>刻意不使用随机数:同一个账号每次都用同一个客户端,才符合真实用户的样子;
|
||||||
|
* 逐请求更换 User-Agent 本身就是更明显的机器特征。不同账号使用不同客户端,
|
||||||
|
* 又便于在必要时按账号区分上游流量。
|
||||||
|
*
|
||||||
|
* @param accountId 子账号 ID,可为 null(视为 0)
|
||||||
|
* @param clash true 表示 Clash 格式,false 表示 V2Ray 格式
|
||||||
|
*/
|
||||||
|
public static SubscriptionClientProfile forAccount(Integer accountId, boolean clash) {
|
||||||
|
List<SubscriptionClientProfile> candidates = clash ? CLASH_PROFILES : V2_PROFILES;
|
||||||
|
int id = accountId == null ? 0 : accountId;
|
||||||
|
return candidates.get(Math.floorMod(id, candidates.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ spring:
|
|||||||
datasource-cache:
|
datasource-cache:
|
||||||
driver-class-name: org.sqlite.JDBC
|
driver-class-name: org.sqlite.JDBC
|
||||||
jdbc-url: jdbc:sqlite:cache.db
|
jdbc-url: jdbc:sqlite:cache.db
|
||||||
|
# 定时任务默认只有 1 个线程。订阅刷新一轮最多 2 个账号、每个 2 次请求(各 15 秒超时),
|
||||||
|
# 最坏可占用约 1 分钟;单线程会让连接自检、cookie 检测等任务在此期间全部排队。
|
||||||
|
task:
|
||||||
|
scheduling:
|
||||||
|
pool:
|
||||||
|
size: 2
|
||||||
mvc:
|
mvc:
|
||||||
view:
|
view:
|
||||||
prefix: /resources/templates/
|
prefix: /resources/templates/
|
||||||
@@ -38,6 +44,19 @@ local:
|
|||||||
|
|
||||||
subscription:
|
subscription:
|
||||||
cache-root: sub/accounts
|
cache-root: sub/accounts
|
||||||
|
# 分散刷新:每个账号在 window-hours 内至少刷新一次,两两之间至少相隔 min-gap-minutes;
|
||||||
|
# max-per-tick 限制单个 tick 刷新几个账号,避免重启后一批到期账号同时开火。
|
||||||
|
refresh:
|
||||||
|
window-hours: 24
|
||||||
|
min-gap-minutes: 60
|
||||||
|
# 5 分钟一个轻量 tick,只处理已到期账号。
|
||||||
|
tick-interval-ms: 300000
|
||||||
|
# 重启后延迟 2 分钟再开始,先让应用完成启动。
|
||||||
|
initial-delay-ms: 120000
|
||||||
|
max-per-tick: 2
|
||||||
|
# 失败后的重试间隔,必须小于 window-hours。
|
||||||
|
retry-delay-minutes: 60
|
||||||
|
stale-alert-multiplier: 2.0
|
||||||
upstream:
|
upstream:
|
||||||
# Use environment variables or an external config file in production.
|
# Use environment variables or an external config file in production.
|
||||||
v2-url-template: "https://aaaa.gay/link/{key}?client=v2"
|
v2-url-template: "https://aaaa.gay/link/{key}?client=v2"
|
||||||
|
|||||||
@@ -107,19 +107,19 @@ class PersonalControllerTest {
|
|||||||
/** 手动更新订阅:成功与失败必须映射成不同的 result,前端据此提示。 */
|
/** 手动更新订阅:成功与失败必须映射成不同的 result,前端据此提示。 */
|
||||||
@Test
|
@Test
|
||||||
void updateSubReflectsServiceOutcome() throws Exception {
|
void updateSubReflectsServiceOutcome() throws Exception {
|
||||||
when(localService.updateSub(true)).thenReturn(true);
|
when(localService.updateSub()).thenReturn(true);
|
||||||
|
|
||||||
var ok = mockMvc.perform(post("/personal/updateSub"))
|
var ok = mockMvc.perform(post("/personal/updateSub"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andReturn();
|
.andReturn();
|
||||||
assertTrue(ok.getResponse().getContentAsString().contains("\"result\":\"success\""));
|
assertTrue(ok.getResponse().getContentAsString().contains("\"result\":\"success\""));
|
||||||
|
|
||||||
when(localService.updateSub(true)).thenReturn(false);
|
when(localService.updateSub()).thenReturn(false);
|
||||||
var failed = mockMvc.perform(post("/personal/updateSub"))
|
var failed = mockMvc.perform(post("/personal/updateSub"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andReturn();
|
.andReturn();
|
||||||
assertTrue(failed.getResponse().getContentAsString().contains("\"result\":\"failure\""));
|
assertTrue(failed.getResponse().getContentAsString().contains("\"result\":\"failure\""));
|
||||||
|
|
||||||
verify(localService, times(2)).updateSub(true);
|
verify(localService, times(2)).updateSub();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class LocalServiceTest {
|
|||||||
private GalleryMapper galleryMapper;
|
private GalleryMapper galleryMapper;
|
||||||
private PushService pushService;
|
private PushService pushService;
|
||||||
private RemoteService remoteService;
|
private RemoteService remoteService;
|
||||||
private SubscriptionRefreshService refreshService;
|
private SubscriptionRefreshScheduler refreshScheduler;
|
||||||
private LocalService service;
|
private LocalService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -40,9 +40,9 @@ class LocalServiceTest {
|
|||||||
galleryMapper = mock(GalleryMapper.class);
|
galleryMapper = mock(GalleryMapper.class);
|
||||||
pushService = mock(PushService.class);
|
pushService = mock(PushService.class);
|
||||||
remoteService = mock(RemoteService.class);
|
remoteService = mock(RemoteService.class);
|
||||||
refreshService = mock(SubscriptionRefreshService.class);
|
refreshScheduler = mock(SubscriptionRefreshScheduler.class);
|
||||||
service = new LocalService(configurationMapper, shareFileMapper, galleryMapper,
|
service = new LocalService(configurationMapper, shareFileMapper, galleryMapper,
|
||||||
pushService, remoteService, refreshService);
|
pushService, remoteService, refreshScheduler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- CheckConnectionAvailability ----------
|
// ---------- CheckConnectionAvailability ----------
|
||||||
@@ -151,9 +151,9 @@ class LocalServiceTest {
|
|||||||
/** 全部子账号刷新成功:同步给节点并记录更新时间。 */
|
/** 全部子账号刷新成功:同步给节点并记录更新时间。 */
|
||||||
@Test
|
@Test
|
||||||
void updateSubStampsTimeWhenAllAccountsSucceed() throws Exception {
|
void updateSubStampsTimeWhenAllAccountsSucceed() throws Exception {
|
||||||
when(refreshService.refreshAll()).thenReturn(true);
|
when(refreshScheduler.refreshAllNow()).thenReturn(true);
|
||||||
|
|
||||||
assertTrue(service.updateSub(true));
|
assertTrue(service.updateSub());
|
||||||
|
|
||||||
verify(remoteService).requestSubscriptionSync();
|
verify(remoteService).requestSubscriptionSync();
|
||||||
verify(configurationMapper).updateConfiguration(
|
verify(configurationMapper).updateConfiguration(
|
||||||
@@ -163,23 +163,26 @@ class LocalServiceTest {
|
|||||||
/** 刷新失败时仍要通知节点,但不更新「上次更新时间」,避免掩盖故障。 */
|
/** 刷新失败时仍要通知节点,但不更新「上次更新时间」,避免掩盖故障。 */
|
||||||
@Test
|
@Test
|
||||||
void updateSubDoesNotStampTimeWhenRefreshFails() throws Exception {
|
void updateSubDoesNotStampTimeWhenRefreshFails() throws Exception {
|
||||||
when(refreshService.refreshAll()).thenReturn(false);
|
when(refreshScheduler.refreshAllNow()).thenReturn(false);
|
||||||
|
|
||||||
assertFalse(service.updateSub(false));
|
assertFalse(service.updateSub());
|
||||||
|
|
||||||
verify(remoteService).requestSubscriptionSync();
|
verify(remoteService).requestSubscriptionSync();
|
||||||
verify(configurationMapper, never()).updateConfiguration(anyString(), anyString());
|
verify(configurationMapper, never()).updateConfiguration(anyString(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 定时入口必须走同一套逻辑(手动与定时行为一致)。 */
|
/**
|
||||||
|
* 定时刷新入口必须已经移除。
|
||||||
|
* 旧的 {@code updateSubScheduler} 会一次性刷新全部账号且没有 initialDelay,
|
||||||
|
* 于是每次重启都触发一遍全量刷新——这正是本次要消除的行为。
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
void scheduledUpdateDelegatesToSamePath() throws Exception {
|
void scheduledFullRefreshEntryPointIsGone() {
|
||||||
when(refreshService.refreshAll()).thenReturn(true);
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSubScheduler"));
|
||||||
service.updateSubScheduler();
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSub", boolean.class),
|
||||||
verify(refreshService).refreshAll();
|
"不应再保留 isManual 参数:定时路径已不存在");
|
||||||
verify(remoteService).requestSubscriptionSync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- checkShareCode ----------
|
// ---------- checkShareCode ----------
|
||||||
@@ -219,11 +222,8 @@ class LocalServiceTest {
|
|||||||
assertCron("verifyCookie", "0 0 0 * * *");
|
assertCron("verifyCookie", "0 0 0 * * *");
|
||||||
assertCron("checkShareCode", "0 0 4 * * *");
|
assertCron("checkShareCode", "0 0 4 * * *");
|
||||||
assertCron("clearThumbnailCache", "0 0 4 1 * *");
|
assertCron("clearThumbnailCache", "0 0 4 1 * *");
|
||||||
|
// 订阅刷新已改为 SubscriptionRefreshScheduler 的分散 tick,
|
||||||
var m = LocalService.class.getMethod("updateSubScheduler");
|
// 其周期与初始延迟由 SubscriptionRefreshSchedulerTest 锁定。
|
||||||
assertEquals(86400000L,
|
|
||||||
m.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class).fixedRate(),
|
|
||||||
"订阅更新应为每 24 小时一次");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertCron(String method, String expected) throws Exception {
|
private static void assertCron(String method, String expected) throws Exception {
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ class SubServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static SubscriptionAccount account(Integer id, String name, String key, boolean enabled) {
|
private static SubscriptionAccount account(Integer id, String name, String key, boolean enabled) {
|
||||||
return new SubscriptionAccount(id, name, key, false, enabled, null, null, null, null, 0, null, null);
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName(name);
|
||||||
|
a.setUpstreamKey(key);
|
||||||
|
a.setFilterHighMultiplier(false);
|
||||||
|
a.setEnabled(enabled);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean ok(String json) {
|
private static boolean ok(String json) {
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class SubscriptionFilteringTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void stubUpstream(List<String> v2, List<String> clash) throws Exception {
|
private void stubUpstream(List<String> v2, List<String> clash) throws Exception {
|
||||||
doReturn(v2).when(service).download(contains("client=v2"));
|
doReturn(v2).when(service).download(contains("client=v2"), any());
|
||||||
doReturn(clash).when(service).download(contains("client=clashmeta"));
|
doReturn(clash).when(service).download(contains("client=clashmeta"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- URL 模板 ----------
|
// ---------- URL 模板 ----------
|
||||||
@@ -108,7 +108,7 @@ class SubscriptionFilteringTest {
|
|||||||
String decodedV2 = new String(Base64.getMimeDecoder()
|
String decodedV2 = new String(Base64.getMimeDecoder()
|
||||||
.decode(Files.readString(v2)), StandardCharsets.UTF_8);
|
.decode(Files.readString(v2)), StandardCharsets.UTF_8);
|
||||||
assertTrue(decodedV2.contains("vmess://node-a"), "实际内容: " + decodedV2);
|
assertTrue(decodedV2.contains("vmess://node-a"), "实际内容: " + decodedV2);
|
||||||
verify(subMapper).markSubscriptionRefreshSuccess(1);
|
verify(subMapper).markSubscriptionRefreshSuccess(eq(1), anyLong());
|
||||||
verify(subMapper, never()).markSubscriptionRefreshFailure(anyInt(), anyString());
|
verify(subMapper, never()).markSubscriptionRefreshFailure(anyInt(), anyString());
|
||||||
// 临时文件不应残留
|
// 临时文件不应残留
|
||||||
assertFalse(Files.exists(cacheRoot.resolve("1/clash.yaml.tmp")));
|
assertFalse(Files.exists(cacheRoot.resolve("1/clash.yaml.tmp")));
|
||||||
@@ -123,7 +123,7 @@ class SubscriptionFilteringTest {
|
|||||||
when(subMapper.selectSubscriptionAccount(2)).thenReturn(null);
|
when(subMapper.selectSubscriptionAccount(2)).thenReturn(null);
|
||||||
assertFalse(service.refresh(2));
|
assertFalse(service.refresh(2));
|
||||||
|
|
||||||
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt());
|
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt(), anyLong());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 高倍率过滤(v2) ----------
|
// ---------- 高倍率过滤(v2) ----------
|
||||||
@@ -264,12 +264,12 @@ class SubscriptionFilteringTest {
|
|||||||
void refreshFailsWhenUpstreamReturnsEmpty() throws Exception {
|
void refreshFailsWhenUpstreamReturnsEmpty() throws Exception {
|
||||||
SubscriptionAccount acc = account(12, "key12", true, false);
|
SubscriptionAccount acc = account(12, "key12", true, false);
|
||||||
when(subMapper.selectSubscriptionAccount(12)).thenReturn(acc);
|
when(subMapper.selectSubscriptionAccount(12)).thenReturn(acc);
|
||||||
doReturn(new ArrayList<String>()).when(service).download(anyString());
|
doReturn(new ArrayList<String>()).when(service).download(anyString(), any());
|
||||||
|
|
||||||
assertFalse(service.refresh(12));
|
assertFalse(service.refresh(12));
|
||||||
|
|
||||||
verify(subMapper).markSubscriptionRefreshFailure(eq(12), contains("为空"));
|
verify(subMapper).markSubscriptionRefreshFailure(eq(12), contains("为空"));
|
||||||
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt());
|
verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt(), anyLong());
|
||||||
assertFalse(Files.exists(cacheRoot.resolve("12/v2ray.txt")), "失败时不应产出缓存");
|
assertFalse(Files.exists(cacheRoot.resolve("12/v2ray.txt")), "失败时不应产出缓存");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ class SubscriptionFilteringTest {
|
|||||||
void refreshFailsOnNetworkError() throws Exception {
|
void refreshFailsOnNetworkError() throws Exception {
|
||||||
SubscriptionAccount acc = account(14, "key14", true, false);
|
SubscriptionAccount acc = account(14, "key14", true, false);
|
||||||
when(subMapper.selectSubscriptionAccount(14)).thenReturn(acc);
|
when(subMapper.selectSubscriptionAccount(14)).thenReturn(acc);
|
||||||
doThrow(new java.io.IOException("connection refused")).when(service).download(anyString());
|
doThrow(new java.io.IOException("connection refused")).when(service).download(anyString(), any());
|
||||||
|
|
||||||
assertFalse(service.refresh(14));
|
assertFalse(service.refresh(14));
|
||||||
|
|
||||||
@@ -303,7 +303,7 @@ class SubscriptionFilteringTest {
|
|||||||
void refreshTruncatesLongErrorMessages() throws Exception {
|
void refreshTruncatesLongErrorMessages() throws Exception {
|
||||||
SubscriptionAccount acc = account(15, "key15", true, false);
|
SubscriptionAccount acc = account(15, "key15", true, false);
|
||||||
when(subMapper.selectSubscriptionAccount(15)).thenReturn(acc);
|
when(subMapper.selectSubscriptionAccount(15)).thenReturn(acc);
|
||||||
doThrow(new java.io.IOException("x".repeat(900))).when(service).download(anyString());
|
doThrow(new java.io.IOException("x".repeat(900))).when(service).download(anyString(), any());
|
||||||
|
|
||||||
assertFalse(service.refresh(15));
|
assertFalse(service.refresh(15));
|
||||||
|
|
||||||
@@ -312,17 +312,20 @@ class SubscriptionFilteringTest {
|
|||||||
assertEquals(500, captor.getValue().length(), "错误信息应截断到 500 字符");
|
assertEquals(500, captor.getValue().length(), "错误信息应截断到 500 字符");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- refreshAll ----------
|
// ---------- 全量刷新 ----------
|
||||||
|
//
|
||||||
|
// 「遍历所有启用账号」的批量入口已从本服务移除(它正是会一次性打满上游的爆发写法),
|
||||||
|
// 现在由 SubscriptionRefreshScheduler 按账号到期逐个刷新。
|
||||||
|
// 调度行为与失败隔离见 SubscriptionRefreshSchedulerTest。
|
||||||
|
|
||||||
/** 只刷新启用账号;任一失败即整体返回 false,但其余账号仍继续刷新。 */
|
/**
|
||||||
|
* 单个账号刷新失败时,其余账号仍应正常写入缓存——失败隔离是分散刷新能替代
|
||||||
|
* 批量刷新的前提:一个坏账号不该拖垮其他人。
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
void refreshAllSkipsDisabledAndReportsAnyFailure() throws Exception {
|
void oneAccountFailureDoesNotPreventOthersFromCaching() throws Exception {
|
||||||
SubscriptionAccount ok = account(20, "k20", true, false);
|
SubscriptionAccount ok = account(20, "k20", true, false);
|
||||||
SubscriptionAccount off = account(21, "k21", false, false);
|
|
||||||
SubscriptionAccount bad = account(22, "k22", true, false);
|
SubscriptionAccount bad = account(22, "k22", true, false);
|
||||||
when(subMapper.selectAllSubscriptionAccounts())
|
|
||||||
.thenReturn(new ArrayList<>(List.of(ok, off, bad)));
|
|
||||||
|
|
||||||
when(subMapper.selectSubscriptionAccount(20)).thenReturn(ok);
|
when(subMapper.selectSubscriptionAccount(20)).thenReturn(ok);
|
||||||
when(subMapper.selectSubscriptionAccount(22)).thenReturn(bad);
|
when(subMapper.selectSubscriptionAccount(22)).thenReturn(bad);
|
||||||
doAnswer(inv -> {
|
doAnswer(inv -> {
|
||||||
@@ -332,36 +335,15 @@ class SubscriptionFilteringTest {
|
|||||||
if (url.contains("client=v2"))
|
if (url.contains("client=v2"))
|
||||||
return List.of(Base64.getEncoder().encodeToString("vmess://a".getBytes(StandardCharsets.UTF_8)));
|
return List.of(Base64.getEncoder().encodeToString("vmess://a".getBytes(StandardCharsets.UTF_8)));
|
||||||
return List.of("proxies:");
|
return List.of("proxies:");
|
||||||
}).when(service).download(anyString());
|
}).when(service).download(anyString(), any());
|
||||||
|
|
||||||
assertFalse(service.refreshAll(), "存在失败账号时整体应为 false");
|
assertTrue(service.refresh(20));
|
||||||
|
assertFalse(service.refresh(22));
|
||||||
|
|
||||||
verify(subMapper).markSubscriptionRefreshSuccess(20);
|
verify(subMapper).markSubscriptionRefreshSuccess(eq(20), anyLong());
|
||||||
verify(subMapper, never()).selectSubscriptionAccount(21); // 停用账号不刷新
|
verify(subMapper).markSubscriptionRefreshFailure(eq(22), anyString());
|
||||||
assertTrue(Files.exists(cacheRoot.resolve("20/v2ray.txt")));
|
assertTrue(Files.exists(cacheRoot.resolve("20/v2ray.txt")), "成功账号应落盘");
|
||||||
}
|
assertFalse(Files.exists(cacheRoot.resolve("22/v2ray.txt")), "失败账号不应落盘");
|
||||||
|
|
||||||
/** 全部成功时返回 true。 */
|
|
||||||
@Test
|
|
||||||
void refreshAllReturnsTrueWhenEveryAccountSucceeds() throws Exception {
|
|
||||||
SubscriptionAccount a1 = account(30, "k30", true, false);
|
|
||||||
SubscriptionAccount a2 = account(31, "k31", true, false);
|
|
||||||
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(List.of(a1, a2)));
|
|
||||||
when(subMapper.selectSubscriptionAccount(30)).thenReturn(a1);
|
|
||||||
when(subMapper.selectSubscriptionAccount(31)).thenReturn(a2);
|
|
||||||
stubUpstream(v2Upstream("vmess://a"), List.of("proxies:"));
|
|
||||||
|
|
||||||
assertTrue(service.refreshAll());
|
|
||||||
|
|
||||||
verify(subMapper, times(2)).markSubscriptionRefreshSuccess(anyInt());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 没有任何账号时视为成功(无事可做)。 */
|
|
||||||
@Test
|
|
||||||
void refreshAllSucceedsWithNoAccounts() {
|
|
||||||
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>());
|
|
||||||
|
|
||||||
assertTrue(service.refreshAll());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 缓存状态 ----------
|
// ---------- 缓存状态 ----------
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅刷新排程:把「一天刷一次」变成「分散到一天里、但保证 24 小时内每个账号都刷到」。
|
||||||
|
*
|
||||||
|
* <p>这里锁定三条最关键的契约:
|
||||||
|
* <ol>
|
||||||
|
* <li>首次排程真的把账号铺开,不是挤在一起(否则等于没改);</li>
|
||||||
|
* <li>下一次刷新一定在窗口内,这是「24 小时内必刷一次」的硬保证;</li>
|
||||||
|
* <li>兜底裁剪能兜住计划缺失、长期失败等异常,不会让账号无限期不刷。</li>
|
||||||
|
* </ol>
|
||||||
|
* 全部用固定时钟与固定随机种子,结果可复现。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshPlannerTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final Instant BASE = Instant.parse("2026-09-15T02:00:00Z"); // 北京时间 10:00
|
||||||
|
|
||||||
|
private static SubscriptionRefreshPlanner planner(long seed, Duration window, Duration minGap, Duration tick) {
|
||||||
|
return new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(seed), window, minGap, tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionRefreshPlanner defaultPlanner(long seed) {
|
||||||
|
return planner(seed, Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> ids(int count) {
|
||||||
|
List<Integer> list = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= count; i++)
|
||||||
|
list.add(i);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 首次排程:分散 ----------
|
||||||
|
|
||||||
|
/** 每个账号都必须拿到计划,且都落在「现在」到「现在 + 窗口」之间。 */
|
||||||
|
@Test
|
||||||
|
void initialSchedulePlacesEveryAccountInsideWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(12));
|
||||||
|
|
||||||
|
assertEquals(12, planned.size());
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (long at : planned) {
|
||||||
|
assertTrue(at >= now, "计划不应早于当前时刻: " + at);
|
||||||
|
assertTrue(at <= now + Duration.ofHours(24).toMillis(), "计划不应超出窗口: " + at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 12 个账号必须被铺开到明显超过「挨个背靠背」的跨度。
|
||||||
|
* 旧实现里它们全部落在 16 秒内,这里要求至少铺开窗口的一半。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void initialScheduleSpreadsAccountsAcrossWindow() {
|
||||||
|
var planner = defaultPlanner(7);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(12));
|
||||||
|
|
||||||
|
long min = planned.stream().mapToLong(Long::longValue).min().orElseThrow();
|
||||||
|
long max = planned.stream().mapToLong(Long::longValue).max().orElseThrow();
|
||||||
|
long spread = max - min;
|
||||||
|
|
||||||
|
assertTrue(spread >= Duration.ofHours(12).toMillis(),
|
||||||
|
"12 个账号应铺开至少 12 小时,实际跨度 " + Duration.ofMillis(spread).toHours() + " 小时");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序后相邻账号之间应保持最小间隔,避免两个账号在同一分钟内打上游。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleRespectsMinimumGap() {
|
||||||
|
var planner = defaultPlanner(3);
|
||||||
|
List<Long> planned = new ArrayList<>(planner.initialSchedule(ids(12)));
|
||||||
|
planned.sort(Long::compareTo);
|
||||||
|
|
||||||
|
long minGap = Duration.ofHours(1).toMillis();
|
||||||
|
for (int i = 1; i < planned.size(); i++) {
|
||||||
|
long gap = planned.get(i) - planned.get(i - 1);
|
||||||
|
assertTrue(gap >= minGap,
|
||||||
|
"第 " + i + " 个间隔只有 " + Duration.ofMillis(gap).toMinutes() + " 分钟,应不少于 60 分钟");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同种子应给出不同排程;若与种子无关,说明随机化没生效。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleDependsOnRandomSource() {
|
||||||
|
var a = defaultPlanner(1).initialSchedule(ids(12));
|
||||||
|
var b = defaultPlanner(99).initialSchedule(ids(12));
|
||||||
|
assertNotEquals(a, b, "不同随机种子不应得到完全相同的排程");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一种子必须可复现:这是重启后行为一致的前提。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleIsReproducibleForSameSeed() {
|
||||||
|
assertEquals(defaultPlanner(42).initialSchedule(ids(12)),
|
||||||
|
defaultPlanner(42).initialSchedule(ids(12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialScheduleHandlesEmptyInput() {
|
||||||
|
assertTrue(defaultPlanner(1).initialSchedule(List.of()).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单账号时也应拿到一个窗口内的计划,不能抛异常。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleHandlesSingleAccount() {
|
||||||
|
List<Long> planned = defaultPlanner(5).initialSchedule(ids(1));
|
||||||
|
assertEquals(1, planned.size());
|
||||||
|
assertTrue(planned.getFirst() >= BASE.toEpochMilli());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 容量校验 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capacityIsSufficientWhenGapsFit() {
|
||||||
|
// 12 个账号、间隔 1 小时 → 需要 11 小时,24 小时窗口放得下
|
||||||
|
assertTrue(defaultPlanner(1).isCapacitySufficient(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capacityIsInsufficientWhenGapsDoNotFit() {
|
||||||
|
// 50 个账号、间隔 1 小时 → 需要 49 小时,放不下
|
||||||
|
assertFalse(defaultPlanner(1).isCapacitySufficient(50));
|
||||||
|
assertTrue(defaultPlanner(1).capacityMessage(50).contains("放不下"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 容量不足也不能让排程崩溃:仍要给出窗口内的计划。 */
|
||||||
|
@Test
|
||||||
|
void initialScheduleStillWorksWhenCapacityInsufficient() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.initialSchedule(ids(200));
|
||||||
|
assertEquals(200, planned.size());
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (long at : planned)
|
||||||
|
assertTrue(at >= now && at <= now + Duration.ofHours(24).toMillis(),
|
||||||
|
"容量不足时仍须落在窗口内: " + at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 24 小时保证 ----------
|
||||||
|
|
||||||
|
/** 成功后下一次刷新必须严格在 24 小时以内(留出一个 tick 的余量)。 */
|
||||||
|
@Test
|
||||||
|
void nextAfterSuccessStaysWithinWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long success = BASE.toEpochMilli();
|
||||||
|
long next = planner.nextAfterSuccess(success);
|
||||||
|
|
||||||
|
assertTrue(next > success, "下一次应晚于本次成功");
|
||||||
|
assertTrue(next - success < Duration.ofHours(24).toMillis(),
|
||||||
|
"相邻两次成功应小于 24 小时,实际 " + Duration.ofMillis(next - success).toMinutes() + " 分钟");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相位自我维持:连续推进多次都不应突破窗口。 */
|
||||||
|
@Test
|
||||||
|
void successiveRefreshesNeverExceedWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long at = BASE.toEpochMilli();
|
||||||
|
for (int i = 0; i < 40; i++) {
|
||||||
|
long next = planner.nextAfterSuccess(at);
|
||||||
|
assertTrue(next - at < Duration.ofHours(24).toMillis(), "第 " + i + " 次推进超出窗口");
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 失败重试与兜底 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败后应延后重试,而不是立刻重打上游。
|
||||||
|
* 间隔是固定的(数据库没有「连续失败次数」字段,做不出真正的指数退避)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nextAfterFailureDelaysRetry() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
|
||||||
|
long retry = planner.nextAfterFailure(now, now);
|
||||||
|
|
||||||
|
assertTrue(retry > now, "失败后不应立即重试");
|
||||||
|
assertTrue(retry - now <= Duration.ofHours(1).toMillis(),
|
||||||
|
"重试不应拖太久,实际 " + Duration.ofMillis(retry - now).toMinutes() + " 分钟");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重试间隔必须小于窗口,否则「窗口内重试」无从谈起。 */
|
||||||
|
@Test
|
||||||
|
void constructorRejectsRetryDelayLargerThanWindow() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(1),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), Duration.ofHours(48)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new SubscriptionRefreshPlanner(Clock.fixed(BASE, ZONE), new Random(1),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), Duration.ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退避不得把账号推过兜底线:仍要在窗口内重试。 */
|
||||||
|
@Test
|
||||||
|
void nextAfterFailureNeverExceedsWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli();
|
||||||
|
long now = lastSuccess + Duration.ofHours(23).toMillis(); // 已经很接近兜底线
|
||||||
|
|
||||||
|
long next = planner.nextAfterFailure(now, lastSuccess);
|
||||||
|
|
||||||
|
assertTrue(next <= lastSuccess + Duration.ofHours(24).toMillis(),
|
||||||
|
"重试时刻不得越过「上次成功 + 窗口」");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已越过兜底线时应立刻到期,这是「必须刷新」的判定。 */
|
||||||
|
@Test
|
||||||
|
void isOverdueWhenWindowElapsed() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli();
|
||||||
|
|
||||||
|
assertFalse(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(23).toMillis()));
|
||||||
|
assertTrue(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(24).toMillis()));
|
||||||
|
assertTrue(planner.isOverdue(lastSuccess, lastSuccess + Duration.ofHours(30).toMillis()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从未成功过的账号不参与过期判定,避免刚建好的账号被立刻强刷。 */
|
||||||
|
@Test
|
||||||
|
void isOverdueIsFalseWithoutKnownSuccess() {
|
||||||
|
assertFalse(defaultPlanner(1).isOverdue(null, BASE.toEpochMilli()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- 到期判定与计划损坏兜底 ----------
|
||||||
|
|
||||||
|
/** 计划在未来:未到期,不应刷新(否则分散效果会被自己破坏)。 */
|
||||||
|
@Test
|
||||||
|
void isDueIsFalseBeforeScheduledTime() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertFalse(planner.isDue(now + Duration.ofHours(3).toMillis(), now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划已到或已过:到期。 */
|
||||||
|
@Test
|
||||||
|
void isDueIsTrueAtOrAfterScheduledTime() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertTrue(planner.isDue(now, now), "正好到点应可刷新");
|
||||||
|
assertTrue(planner.isDue(now - Duration.ofMinutes(1).toMillis(), now), "已过点应可刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划被写到超过一个窗口之后,按「计划损坏」处理并立即到期。
|
||||||
|
* 这是 24 小时保证的兜底:即使字段被手工写坏,账号也不会长期不刷。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void isDueTreatsFarFutureScheduleAsCorrupt() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertTrue(planner.isDue(now + Duration.ofDays(10).toMillis(), now),
|
||||||
|
"超出窗口的计划不可能是本调度器产生的,应立即到期");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尚未排程视为立即到期,避免新账号永远不刷。 */
|
||||||
|
@Test
|
||||||
|
void isDueWithoutScheduleIsImmediate() {
|
||||||
|
assertTrue(defaultPlanner(1).isDue(null, BASE.toEpochMilli()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败重试排在将来时必须被尊重:否则越线的账号会在每个 tick 都重打上游。
|
||||||
|
* 重试间隔是 30 分钟到 4 小时,远小于窗口,不构成「计划损坏」。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void retryScheduleIsRespectedNotTreatedAsCorrupt() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - Duration.ofHours(30).toMillis(); // 已越线
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
long retry = planner.nextAfterFailure(now, lastSuccess);
|
||||||
|
|
||||||
|
assertTrue(retry > now, "越线后的重试仍应排在将来");
|
||||||
|
assertFalse(planner.isDue(retry, now), "重试尚未到点,不应重复刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 冷启动排程 ----------
|
||||||
|
|
||||||
|
/** 从未成功过的账号应尽快刷,而不是被分散到 20 小时后。 */
|
||||||
|
@Test
|
||||||
|
void coldStartScheduleIsImmediateAndStaggered() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
List<Long> planned = planner.coldStartSchedule(ids(5), Duration.ofMinutes(5));
|
||||||
|
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
assertEquals(now, planned.getFirst(), "第一个应立即可刷");
|
||||||
|
for (int i = 1; i < planned.size(); i++) {
|
||||||
|
assertEquals(Duration.ofMinutes(5).toMillis(), planned.get(i) - planned.get(i - 1),
|
||||||
|
"相邻账号应按 tick 间隔错开");
|
||||||
|
}
|
||||||
|
assertTrue(planned.getLast() <= now + Duration.ofMinutes(30).toMillis(),
|
||||||
|
"全部应在半小时内排完,避免新账号长时间没有可用订阅");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coldStartScheduleHandlesEmptyInput() {
|
||||||
|
assertTrue(defaultPlanner(1).coldStartSchedule(List.of(), Duration.ofMinutes(5)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 构造参数校验 ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRejectsNonPositiveWindow() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ZERO, Duration.ofHours(1), Duration.ofMinutes(5)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(-1), Duration.ofHours(1), Duration.ofMinutes(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorRejectsNegativeGapOrTick() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(24), Duration.ofHours(-1), Duration.ofMinutes(5)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> planner(1, Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(-5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置访问器应如实回传构造时的值,供调度器日志与告警阈值使用。 */
|
||||||
|
@Test
|
||||||
|
void exposesConfiguredWindow() {
|
||||||
|
var planner = defaultPlanner(1);
|
||||||
|
assertEquals(Duration.ofHours(24), planner.window());
|
||||||
|
assertEquals(Duration.ofHours(1), planner.minGap());
|
||||||
|
assertEquals(Duration.ofMinutes(5), planner.tickInterval());
|
||||||
|
assertEquals(BASE, planner.clock().instant());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import com.lion.lionwebsite.Domain.SubscriptionAccount;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分散刷新的调度行为。
|
||||||
|
*
|
||||||
|
* <p>这里锁定用户明确要求的几条:每个账号 24 小时内必刷一次、时间互相错开、
|
||||||
|
* 以及「中途重启导致当天没刷」必须能自愈。全部使用固定时钟,不依赖真实时间流逝。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshSchedulerTest {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final Instant BASE = Instant.parse("2026-09-15T02:00:00Z");
|
||||||
|
|
||||||
|
private static final long HOUR = 3_600_000L;
|
||||||
|
private static final long MINUTE = 60_000L;
|
||||||
|
|
||||||
|
private SubMapper subMapper;
|
||||||
|
private SubscriptionRefreshService refreshService;
|
||||||
|
private PushService pushService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
subMapper = mock(SubMapper.class);
|
||||||
|
refreshService = mock(SubscriptionRefreshService.class);
|
||||||
|
pushService = mock(PushService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用固定时钟构造,使「现在」可控。 */
|
||||||
|
private SubscriptionRefreshScheduler schedulerAt(Instant instant) {
|
||||||
|
SubscriptionRefreshPlanner planner = new SubscriptionRefreshPlanner(
|
||||||
|
Clock.fixed(instant, ZONE), new Random(42),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
SubscriptionRefreshScheduler scheduler = new SubscriptionRefreshScheduler(
|
||||||
|
subMapper, refreshService, planner, pushService);
|
||||||
|
scheduler.maxPerTick = 2;
|
||||||
|
scheduler.staleAlertMultiplier = 2.0;
|
||||||
|
return scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionRefreshScheduler scheduler() {
|
||||||
|
return schedulerAt(BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(int id, Long nextRefreshAt, Long lastSuccessEpoch) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName("acc-" + id);
|
||||||
|
a.setUpstreamKey("key-" + id);
|
||||||
|
a.setEnabled(true);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
a.setNextRefreshAt(nextRefreshAt);
|
||||||
|
a.setLastSuccessEpoch(lastSuccessEpoch);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void existing(SubscriptionAccount... accounts) {
|
||||||
|
ArrayList<SubscriptionAccount> list = new ArrayList<>(List.of(accounts));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新成功时返回 true。 */
|
||||||
|
private void refreshSucceedsForAll() {
|
||||||
|
when(refreshService.refresh(anyInt())).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 到期判定 ----------
|
||||||
|
|
||||||
|
/** 计划时刻未到:本轮不应刷新,避免把「分散」又变回「每次都刷」。 */
|
||||||
|
@Test
|
||||||
|
void accountNotDueIsSkipped() {
|
||||||
|
long future = BASE.toEpochMilli() + 5 * HOUR;
|
||||||
|
existing(account(1, future, BASE.toEpochMilli()));
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计划时刻已到:必须刷新,并重排下一次。 */
|
||||||
|
@Test
|
||||||
|
void accountDueIsRefreshedAndRescheduled() {
|
||||||
|
long past = BASE.toEpochMilli() - MINUTE;
|
||||||
|
existing(account(1, past, BASE.toEpochMilli() - 2 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
int refreshed = scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
assertEquals(1, refreshed);
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用的账号即使到期也不刷新。 */
|
||||||
|
@Test
|
||||||
|
void disabledAccountsAreNeverRefreshed() {
|
||||||
|
SubscriptionAccount disabled = account(1, BASE.toEpochMilli() - HOUR, null);
|
||||||
|
disabled.setEnabled(false);
|
||||||
|
existing(disabled);
|
||||||
|
|
||||||
|
assertEquals(0, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 24 小时硬保证 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底线:即使计划缺失(例如手工清了字段),只要距上次成功已过一个窗口,
|
||||||
|
* 也必须立即刷新。这是「24 小时内必刷一次」的最后一道闸。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void overdueAccountIsRefreshedEvenWithoutSchedule() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 25 * HOUR;
|
||||||
|
existing(account(1, null, lastSuccess));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(1, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 到期但未越线的账号若计划被写得很晚,兜底裁剪要能把它拉回来。 */
|
||||||
|
@Test
|
||||||
|
void overdueAccountBeatsFarFutureSchedule() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 25 * HOUR;
|
||||||
|
long farFuture = BASE.toEpochMilli() + 30 * 24 * HOUR;
|
||||||
|
existing(account(1, farFuture, lastSuccess));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(1, scheduler().refreshDueAccounts(), "越线账号必须无视过晚的计划立即刷新");
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功后重排的下一次必须在 24 小时以内,锁定「必刷一次」的硬上限。 */
|
||||||
|
@Test
|
||||||
|
void rescheduledTimeStaysWithinWindow() {
|
||||||
|
long past = BASE.toEpochMilli() - MINUTE;
|
||||||
|
existing(account(1, past, BASE.toEpochMilli() - 2 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
long delta = next.getValue() - BASE.toEpochMilli();
|
||||||
|
assertTrue(delta > 0, "下一次应在未来");
|
||||||
|
assertTrue(delta < 24 * HOUR, "下一次必须在 24 小时内,实际 " + delta / HOUR + " 小时");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重启兜底 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启后的追赶:停机 6 小时期间错过的账号,恢复后应被补刷。
|
||||||
|
* 这是用户明确要求的「中途重启导致当天没更新」兜底。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void accountsMissedDuringDowntimeAreCaughtUpAfterRestart() {
|
||||||
|
long downtimeStart = BASE.toEpochMilli() - 6 * HOUR;
|
||||||
|
// 三个账号的计划都落在停机窗口里,即「本来该刷但进程没在跑」
|
||||||
|
existing(
|
||||||
|
account(1, downtimeStart, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(2, downtimeStart + HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(3, downtimeStart + 2 * HOUR, BASE.toEpochMilli() - 20 * HOUR));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
// 一个 tick 最多 2 个:先补 2 个,避免同时开火
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连续多个 tick 应能把停机期间积压的账号全部排空(不会永久卡住)。
|
||||||
|
*
|
||||||
|
* <p>mock 的 mapper 不会像数据库那样保存状态,因此这里显式模拟持久化:
|
||||||
|
* {@code updateNextRefreshAt} 会把新时刻写回对象,成功时同时更新 lastSuccessEpoch。
|
||||||
|
* 否则被刷过的账号在内存里仍显示为「到期」,测出的排空行为没有意义。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void catchUpDrainsBacklogAcrossTicks() {
|
||||||
|
long downtimeStart = BASE.toEpochMilli() - 6 * HOUR;
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>(List.of(
|
||||||
|
account(1, downtimeStart, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(2, downtimeStart + HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(3, downtimeStart + 2 * HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(4, downtimeStart + 3 * HOUR, BASE.toEpochMilli() - 20 * HOUR),
|
||||||
|
account(5, downtimeStart + 4 * HOUR, BASE.toEpochMilli() - 20 * HOUR)));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
persistSchedules(accounts);
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
assertEquals(2, scheduler.refreshDueAccounts(), "第一轮最多 2 个");
|
||||||
|
assertEquals(2, scheduler.refreshDueAccounts(), "第二轮再 2 个");
|
||||||
|
assertEquals(1, scheduler.refreshDueAccounts(), "第三轮补齐剩余 1 个");
|
||||||
|
verify(refreshService, times(5)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟数据库的持久化:把 updateNextRefreshAt 写回对象。
|
||||||
|
* 成功刷新还必须推进 lastSuccessEpoch,否则兜底线会一直把账号判为到期。
|
||||||
|
*/
|
||||||
|
private void persistSchedules(List<SubscriptionAccount> accounts) {
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
int id = invocation.getArgument(0);
|
||||||
|
long next = invocation.getArgument(1);
|
||||||
|
long now = BASE.toEpochMilli();
|
||||||
|
for (SubscriptionAccount a : accounts) {
|
||||||
|
if (a.getId() == id) {
|
||||||
|
a.setNextRefreshAt(next);
|
||||||
|
a.setLastSuccessEpoch(now);
|
||||||
|
a.setLastError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).when(subMapper).updateNextRefreshAt(anyInt(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 单轮上限 ----------
|
||||||
|
|
||||||
|
/** 单轮刷新数量必须有上限,否则重启后一批到期账号会同时打上游。 */
|
||||||
|
@Test
|
||||||
|
void maxPerTickLimitsConcurrentRefreshes() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 12; i++)
|
||||||
|
accounts.add(account(i, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts());
|
||||||
|
verify(refreshService, times(2)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 失败处理 ----------
|
||||||
|
|
||||||
|
/** 单个账号失败不影响其他账号继续刷新。 */
|
||||||
|
@Test
|
||||||
|
void oneFailureDoesNotBlockOthers() {
|
||||||
|
existing(
|
||||||
|
account(1, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR),
|
||||||
|
account(2, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(false);
|
||||||
|
when(refreshService.refresh(2)).thenReturn(true);
|
||||||
|
|
||||||
|
assertEquals(2, scheduler().refreshDueAccounts(), "失败的账号也要计入本轮尝试");
|
||||||
|
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
verify(refreshService).refresh(2);
|
||||||
|
// 失败账号也要重排,否则会每个 tick 反复重打上游
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败后的重试时刻应晚于当前,避免失败的账号被连续重打。 */
|
||||||
|
@Test
|
||||||
|
void failureReschedulesIntoFuture() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() - HOUR, BASE.toEpochMilli() - 30 * HOUR));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(false);
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
assertTrue(next.getValue() > BASE.toEpochMilli(), "重试时刻应在未来");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动全量刷新不受单轮上限限制:管理页按钮的语义就是立刻全部刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowIgnoresPerTickLimit() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 5; i++)
|
||||||
|
accounts.add(account(i, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()));
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
assertTrue(scheduler().refreshAllNow());
|
||||||
|
verify(refreshService, times(5)).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新后必须重排计划。否则「刚手动刷完」会让当天的计划作废,
|
||||||
|
* 该账号反而可能超过 24 小时没有下次更新。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void refreshAllNowReschedulesEveryAccount() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()),
|
||||||
|
account(2, BASE.toEpochMilli() + 10 * HOUR, BASE.toEpochMilli()));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshAllNow();
|
||||||
|
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(1), anyLong());
|
||||||
|
verify(subMapper).updateNextRefreshAt(eq(2), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动刷新中只要有一个失败,整体结果即为失败(页面据此提示)。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowReportsFailureIfAnyAccountFails() {
|
||||||
|
existing(account(1, BASE.toEpochMilli(), BASE.toEpochMilli()),
|
||||||
|
account(2, BASE.toEpochMilli(), BASE.toEpochMilli()));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true);
|
||||||
|
when(refreshService.refresh(2)).thenReturn(false);
|
||||||
|
|
||||||
|
assertFalse(scheduler().refreshAllNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用账号不参与手动全量刷新。 */
|
||||||
|
@Test
|
||||||
|
void refreshAllNowSkipsDisabled() {
|
||||||
|
SubscriptionAccount disabled = account(1, BASE.toEpochMilli(), BASE.toEpochMilli());
|
||||||
|
disabled.setEnabled(false);
|
||||||
|
existing(disabled);
|
||||||
|
|
||||||
|
assertTrue(scheduler().refreshAllNow());
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 计划补齐 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从未成功过的账号(没有计划)应被自动排上并尽快刷新,而不是永远不刷。
|
||||||
|
* 会被写入两次计划:一次是冷启动排程,一次是刷新成功后的重排。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void missingScheduleIsFilledInAndRefreshedPromptly() {
|
||||||
|
existing(account(1, null, null));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(refreshService).refresh(1);
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper, times(2)).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
assertTrue(next.getAllValues().getFirst() <= BASE.toEpochMilli() + 5 * MINUTE,
|
||||||
|
"从未成功过的账号应尽快刷新");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补齐的计划应落在窗口内,且不会让账号立刻被刷(避免与建号时的首刷重复)。 */
|
||||||
|
@Test
|
||||||
|
void filledScheduleLandsInsideWindow() {
|
||||||
|
existing(account(1, null, null));
|
||||||
|
|
||||||
|
// 只跑到补齐逻辑:不提供成功桩,refreshDueAccounts 仍会先补齐计划
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true);
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
ArgumentCaptor<Long> next = ArgumentCaptor.forClass(Long.class);
|
||||||
|
verify(subMapper, atLeastOnce()).updateNextRefreshAt(eq(1), next.capture());
|
||||||
|
long earliest = next.getAllValues().stream().mapToLong(Long::longValue).min().orElseThrow();
|
||||||
|
assertTrue(earliest <= BASE.toEpochMilli() + 24 * HOUR, "补齐的计划必须落在窗口内");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回归测试:刚被铺开计划的账号不应在同一轮里就被刷新。
|
||||||
|
*
|
||||||
|
* <p>曾经的缺陷是「把计划写进数据库却忘了更新内存里的对象」——这些账号在本轮
|
||||||
|
* 仍带着 null 计划,被判定为立即到期,于是迁移后首批账号会在第一个 tick
|
||||||
|
* 全部集中刷新,铺开形同虚设。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void newlyScheduledAccountsAreNotRefreshedInSameTick() {
|
||||||
|
List<SubscriptionAccount> accounts = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 5; i++)
|
||||||
|
accounts.add(account(i, null, BASE.toEpochMilli() - 2 * HOUR)); // 曾成功过 → 按窗口铺开
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(accounts));
|
||||||
|
refreshSucceedsForAll();
|
||||||
|
|
||||||
|
int refreshed = scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
assertEquals(0, refreshed, "刚铺开计划的账号本轮不应立即刷新");
|
||||||
|
verify(refreshService, never()).refresh(anyInt());
|
||||||
|
assertEquals(5, accounts.stream().filter(a -> a.getNextRefreshAt() != null).count(),
|
||||||
|
"铺开的计划应写回内存对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已有计划的账号不应被重新排程,否则每次 tick 都会打乱分散效果。 */
|
||||||
|
@Test
|
||||||
|
void existingScheduleIsNotOverwritten() {
|
||||||
|
long future = BASE.toEpochMilli() + 8 * HOUR;
|
||||||
|
existing(account(1, future, BASE.toEpochMilli()));
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(subMapper, never()).updateNextRefreshAt(anyInt(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 陈旧告警 ----------
|
||||||
|
|
||||||
|
/** 超过两个窗口仍未成功应告警,让运维在用户投诉前发现问题。 */
|
||||||
|
@Test
|
||||||
|
void staleAccountTriggersAlert() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
SubscriptionAccount stale = account(1, BASE.toEpochMilli() + HOUR, lastSuccess);
|
||||||
|
stale.setLastError("上游 HTTP 状态码 403");
|
||||||
|
existing(stale);
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService).sendToMe(contains("未成功刷新"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警只发一次,避免每个 tick 都刷屏。 */
|
||||||
|
@Test
|
||||||
|
void staleAlertIsSentOnlyOnce() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
existing(account(1, BASE.toEpochMilli() + HOUR, lastSuccess));
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
scheduler.refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService, times(1)).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未陈旧的账号不应告警。 */
|
||||||
|
@Test
|
||||||
|
void healthyAccountDoesNotAlert() {
|
||||||
|
existing(account(1, BASE.toEpochMilli() + HOUR, BASE.toEpochMilli() - HOUR));
|
||||||
|
|
||||||
|
scheduler().refreshDueAccounts();
|
||||||
|
|
||||||
|
verify(pushService, never()).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号恢复正常后,若再次陈旧应能重新告警。 */
|
||||||
|
@Test
|
||||||
|
void alertResetsAfterSuccessfulRefresh() {
|
||||||
|
long lastSuccess = BASE.toEpochMilli() - 50 * HOUR;
|
||||||
|
SubscriptionAccount stale = account(1, BASE.toEpochMilli() - MINUTE, lastSuccess);
|
||||||
|
when(subMapper.selectAllSubscriptionAccounts())
|
||||||
|
.thenReturn(new ArrayList<>(List.of(stale)));
|
||||||
|
when(refreshService.refresh(1)).thenReturn(true, false);
|
||||||
|
|
||||||
|
var scheduler = scheduler();
|
||||||
|
scheduler.refreshDueAccounts(); // 告警 + 成功 → 重置
|
||||||
|
scheduler.refreshDueAccounts(); // 失败,但尚未再次陈旧
|
||||||
|
|
||||||
|
verify(pushService, times(1)).sendToMe(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 调度注解契约 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tick 必须用 fixedDelay(一轮跑完再计下一轮)而不是 fixedRate,
|
||||||
|
* 并且必须有 initialDelay——这正是旧实现「每次重启立刻全量重刷」的根因。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void tickUsesFixedDelayWithInitialDelay() throws Exception {
|
||||||
|
var annotation = SubscriptionRefreshScheduler.class.getMethod("tick")
|
||||||
|
.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class);
|
||||||
|
|
||||||
|
assertNotNull(annotation, "tick 应带 @Scheduled");
|
||||||
|
assertEquals("${subscription.refresh.tick-interval-ms:300000}", annotation.fixedDelayString());
|
||||||
|
assertEquals("${subscription.refresh.initial-delay-ms:120000}", annotation.initialDelayString());
|
||||||
|
assertEquals(-1, annotation.fixedRate(), "不应再使用 fixedRate(未设置时为 -1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧的「每 24 小时一次全量」入口必须已经消失。
|
||||||
|
* 它没有 initialDelay,会在每次重启时立刻重刷全部账号。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void localServiceNoLongerSchedulesFullRefresh() {
|
||||||
|
assertThrows(NoSuchMethodException.class,
|
||||||
|
() -> LocalService.class.getMethod("updateSubScheduler"),
|
||||||
|
"LocalService 不应再暴露 24 小时全量刷新入口");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,19 +11,27 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class SubscriptionRefreshServiceTest {
|
class SubscriptionRefreshServiceTest {
|
||||||
|
|
||||||
|
private static SubscriptionAccount account(String key) {
|
||||||
|
SubscriptionAccount a = new SubscriptionAccount();
|
||||||
|
a.setId(1);
|
||||||
|
a.setName("sample");
|
||||||
|
a.setUpstreamKey(key);
|
||||||
|
a.setEnabled(true);
|
||||||
|
a.setBoundUserCount(0);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
@Test
|
@Test
|
||||||
void stalledDownloadDoesNotHoldStateLockAndStaleResultIsDiscarded(@TempDir Path directory) throws Exception {
|
void stalledDownloadDoesNotHoldStateLockAndStaleResultIsDiscarded(@TempDir Path directory) throws Exception {
|
||||||
SubMapper mapper = mock(SubMapper.class);
|
SubMapper mapper = mock(SubMapper.class);
|
||||||
SubscriptionAccount original = new SubscriptionAccount(1, "sample", "old", false, true,
|
SubscriptionAccount original = account("old");
|
||||||
null, null, null, null, 0, null, null);
|
SubscriptionAccount changed = account("new");
|
||||||
SubscriptionAccount changed = new SubscriptionAccount(1, "sample", "new", false, true,
|
|
||||||
null, null, null, null, 0, null, null);
|
|
||||||
when(mapper.selectSubscriptionAccount(1)).thenReturn(original);
|
when(mapper.selectSubscriptionAccount(1)).thenReturn(original);
|
||||||
var coordinator = new SubscriptionStateCoordinator();
|
var coordinator = new SubscriptionStateCoordinator();
|
||||||
CountDownLatch started = new CountDownLatch(1);
|
CountDownLatch started = new CountDownLatch(1);
|
||||||
CountDownLatch release = new CountDownLatch(1);
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
var service = new SubscriptionRefreshService(mapper, coordinator) {
|
var service = new SubscriptionRefreshService(mapper, coordinator) {
|
||||||
@Override List<String> download(String url) throws java.io.IOException {
|
@Override List<String> download(String url, com.lion.lionwebsite.Util.SubscriptionClientProfile profile) throws java.io.IOException {
|
||||||
started.countDown();
|
started.countDown();
|
||||||
try {
|
try {
|
||||||
if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("test timed out");
|
if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("test timed out");
|
||||||
@@ -47,7 +55,7 @@ class SubscriptionRefreshServiceTest {
|
|||||||
release.countDown();
|
release.countDown();
|
||||||
assertFalse(refresh.get(2, TimeUnit.SECONDS));
|
assertFalse(refresh.get(2, TimeUnit.SECONDS));
|
||||||
assertFalse(service.hasCompleteCache(1));
|
assertFalse(service.hasCompleteCache(1));
|
||||||
verify(mapper, never()).markSubscriptionRefreshSuccess(any());
|
verify(mapper, never()).markSubscriptionRefreshSuccess(any(), anyLong());
|
||||||
} finally { release.countDown(); worker.shutdownNow(); }
|
} finally { release.countDown(); worker.shutdownNow(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.lion.lionwebsite.Service;
|
||||||
|
|
||||||
|
import com.lion.lionwebsite.Configuration.CustomBean;
|
||||||
|
import com.lion.lionwebsite.Dao.normal.SubMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新调度组件的 Spring 装配。
|
||||||
|
*
|
||||||
|
* <p>纯 Mockito 测试不会发现「bean 无法装配」这类问题——线上表现是整个应用起不来,
|
||||||
|
* 因此这里用真实的 Spring 容器把 {@link SubscriptionRefreshScheduler} 装一遍。
|
||||||
|
* 只注册必要的 bean,不加载 Web 与数据源,避免测试依赖数据库与端口。
|
||||||
|
*/
|
||||||
|
class SubscriptionRefreshWiringTest {
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class Stub {
|
||||||
|
@Bean
|
||||||
|
SubMapper subMapper() {
|
||||||
|
return mock(SubMapper.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionStateCoordinator subscriptionStateCoordinator() {
|
||||||
|
return new SubscriptionStateCoordinator();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshService subscriptionRefreshService(SubMapper subMapper,
|
||||||
|
SubscriptionStateCoordinator coordinator) {
|
||||||
|
return new SubscriptionRefreshService(subMapper, coordinator);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PushService pushService() {
|
||||||
|
return mock(PushService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshPlanner subscriptionRefreshPlanner() {
|
||||||
|
return new SubscriptionRefreshPlanner(java.time.Clock.systemDefaultZone(), new java.util.Random(),
|
||||||
|
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SubscriptionRefreshScheduler subscriptionRefreshScheduler(SubMapper subMapper,
|
||||||
|
SubscriptionRefreshService refreshService,
|
||||||
|
SubscriptionRefreshPlanner planner,
|
||||||
|
PushService pushService) {
|
||||||
|
return new SubscriptionRefreshScheduler(subMapper, refreshService, planner, pushService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调度器必须能从容器里装配出来,且依赖都已就位。 */
|
||||||
|
@Test
|
||||||
|
void schedulerBeanIsWired() {
|
||||||
|
try (var ctx = new AnnotationConfigApplicationContext(Stub.class)) {
|
||||||
|
SubscriptionRefreshScheduler scheduler = ctx.getBean(SubscriptionRefreshScheduler.class);
|
||||||
|
assertNotNull(scheduler);
|
||||||
|
assertNotNull(scheduler.planner());
|
||||||
|
assertEquals(Duration.ofHours(24), scheduler.planner().window());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产用的 planner bean 必须真正读到 application.yaml 里的值。
|
||||||
|
* 这里直接调用 {@link CustomBean} 的工厂方法验证默认值与注入方向一致,
|
||||||
|
* 避免「配置写了但没人读」以及「读错键」两类静默故障。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void plannerFactoryReadsConfiguredWindow() {
|
||||||
|
SubscriptionRefreshPlanner planner = new CustomBean()
|
||||||
|
.subscriptionRefreshPlanner(24, 60, 300_000, 60);
|
||||||
|
|
||||||
|
assertEquals(Duration.ofHours(24), planner.window());
|
||||||
|
assertEquals(Duration.ofMinutes(60), planner.minGap());
|
||||||
|
assertEquals(Duration.ofMinutes(5), planner.tickInterval());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 12 个账号的现状下,默认配置(24 小时窗口、60 分钟间隔)应当放得下。 */
|
||||||
|
@Test
|
||||||
|
void defaultConfigurationFitsCurrentAccountCount() {
|
||||||
|
SubscriptionRefreshPlanner planner = new CustomBean()
|
||||||
|
.subscriptionRefreshPlanner(24, 60, 300_000, 60);
|
||||||
|
|
||||||
|
assertTrue(planner.isCapacitySufficient(12),
|
||||||
|
"当前 12 个子账号在默认配置下应能铺开");
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-2
@@ -23,8 +23,13 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
class SubscriptionStandbySnapshotServiceTest {
|
class SubscriptionStandbySnapshotServiceTest {
|
||||||
@Test
|
@Test
|
||||||
void buildsDeterministicSnapshotWithoutPlainPublicKeys(@TempDir Path directory) throws Exception {
|
void buildsDeterministicSnapshotWithoutPlainPublicKeys(@TempDir Path directory) throws Exception {
|
||||||
SubscriptionAccount account = new SubscriptionAccount(1, "account", "upstream-secret", true, true,
|
SubscriptionAccount account = new SubscriptionAccount();
|
||||||
null, null, null, null, 2, null, null);
|
account.setId(1);
|
||||||
|
account.setName("account");
|
||||||
|
account.setUpstreamKey("upstream-secret");
|
||||||
|
account.setFilterHighMultiplier(true);
|
||||||
|
account.setEnabled(true);
|
||||||
|
account.setBoundUserCount(2);
|
||||||
ArrayList<SubscriptionAccount> accounts = new ArrayList<>(java.util.List.of(account));
|
ArrayList<SubscriptionAccount> accounts = new ArrayList<>(java.util.List.of(account));
|
||||||
ArrayList<SubBind> bindings = new ArrayList<>(java.util.List.of(
|
ArrayList<SubBind> bindings = new ArrayList<>(java.util.List.of(
|
||||||
new SubBind("public-key-a", "user-a", 1, "account", true, true),
|
new SubBind("public-key-a", "user-a", 1, "account", true, true),
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.lion.lionwebsite.Util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抓取上游订阅时伪装的客户端身份。
|
||||||
|
*
|
||||||
|
* <p>改造前上游看到的固定是 {@code Apache-HttpClient/5.6.4 (Java/25.0.4)},
|
||||||
|
* 相当于自报「我是服务器上的 Java 程序」。这里锁定三条:
|
||||||
|
* 不再出现 Java/HttpClient 字样、同一账号身份稳定、两种格式用各自合理的客户端。
|
||||||
|
*/
|
||||||
|
class SubscriptionClientProfileTest {
|
||||||
|
|
||||||
|
/** 任何伪装都必须先去掉 HttpClient 与 Java 版本的自报家门。 */
|
||||||
|
@Test
|
||||||
|
void noProfileLeaksJavaOrHttpClientIdentity() {
|
||||||
|
for (SubscriptionClientProfile profile : SubscriptionClientProfile.values()) {
|
||||||
|
String ua = profile.userAgent();
|
||||||
|
assertFalse(ua.contains("Java"), "不能出现 Java 版本: " + ua);
|
||||||
|
assertFalse(ua.contains("HttpClient"), "不能出现 HttpClient: " + ua);
|
||||||
|
assertFalse(ua.contains("Apache"), "不能出现 Apache: " + ua);
|
||||||
|
assertFalse(ua.isBlank(), "User-Agent 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 身份必须确定:同一账号每次都是同一个客户端,才像真实用户。 */
|
||||||
|
@Test
|
||||||
|
void profileIsStableForSameAccount() {
|
||||||
|
for (int id = 1; id <= 20; id++) {
|
||||||
|
assertEquals(SubscriptionClientProfile.forAccount(id, false),
|
||||||
|
SubscriptionClientProfile.forAccount(id, false));
|
||||||
|
assertEquals(SubscriptionClientProfile.forAccount(id, true),
|
||||||
|
SubscriptionClientProfile.forAccount(id, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 参数为 null 时不应抛异常(新建账号尚未拿到 ID 的场景)。 */
|
||||||
|
@Test
|
||||||
|
void nullAccountIdIsTolerated() {
|
||||||
|
assertNotNull(SubscriptionClientProfile.forAccount(null, false));
|
||||||
|
assertNotNull(SubscriptionClientProfile.forAccount(null, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同账号应分散到不同客户端,便于必要时按账号区分上游流量。 */
|
||||||
|
@Test
|
||||||
|
void differentAccountsShareTheProfilePoolEvenly() {
|
||||||
|
List<SubscriptionClientProfile> clash = List.of(SubscriptionClientProfile.values()).stream()
|
||||||
|
.filter(p -> p.userAgent().toLowerCase().contains("clash") || p.userAgent().toLowerCase().contains("mihomo"))
|
||||||
|
.toList();
|
||||||
|
assertFalse(clash.isEmpty(), "应存在 Clash 系客户端身份");
|
||||||
|
|
||||||
|
var first = SubscriptionClientProfile.forAccount(1, true);
|
||||||
|
var second = SubscriptionClientProfile.forAccount(2, true);
|
||||||
|
assertNotEquals(first, second, "相邻账号应使用不同客户端身份");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clash 格式不应伪装成 V2Ray 客户端,反之亦然。 */
|
||||||
|
@Test
|
||||||
|
void clashAndV2ProfilesAreDistinct() {
|
||||||
|
for (int id = 1; id <= 10; id++) {
|
||||||
|
SubscriptionClientProfile clash = SubscriptionClientProfile.forAccount(id, true);
|
||||||
|
SubscriptionClientProfile v2 = SubscriptionClientProfile.forAccount(id, false);
|
||||||
|
assertNotEquals(clash, v2, "同一账号的两种格式应使用不同客户端身份");
|
||||||
|
|
||||||
|
String clashUa = clash.userAgent().toLowerCase();
|
||||||
|
assertTrue(clashUa.contains("clash") || clashUa.contains("mihomo"),
|
||||||
|
"Clash 订阅应伪装成 Clash 系客户端: " + clash.userAgent());
|
||||||
|
|
||||||
|
String v2Ua = v2.userAgent().toLowerCase();
|
||||||
|
assertTrue(v2Ua.contains("v2ray") || v2Ua.contains("shadowrocket"),
|
||||||
|
"V2Ray 订阅应伪装成 V2Ray 系客户端: " + v2.userAgent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 负数 ID 也不能越界(防御性:ID 由数据库提供,但不应因异常值崩溃)。 */
|
||||||
|
@Test
|
||||||
|
void negativeAccountIdStaysInRange() {
|
||||||
|
assertNotNull(SubscriptionClientProfile.forAccount(-5, false));
|
||||||
|
assertNotNull(SubscriptionClientProfile.forAccount(-5, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个身份都应带上 Accept 与 Accept-Language,避免头部组合明显异常。 */
|
||||||
|
@Test
|
||||||
|
void everyProfileCarriesPlausibleAcceptHeaders() {
|
||||||
|
for (SubscriptionClientProfile profile : SubscriptionClientProfile.values()) {
|
||||||
|
assertFalse(profile.accept().isBlank(), "Accept 不能为空");
|
||||||
|
assertTrue(profile.acceptLanguage().contains("zh"), "应声明中文偏好: " + profile.acceptLanguage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user