支持多子账号订阅与独立节点过滤

This commit is contained in:
root
2026-08-30 09:32:44 +08:00
parent 9475237731
commit fc8548f90f
11 changed files with 544 additions and 70 deletions
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
db_path="${1:-LionWebsite.db}"
legacy_key="${2:-}"
if [[ -z "$legacy_key" || ! "$legacy_key" =~ ^[A-Za-z0-9._~-]+$ ]]; then
echo "用法: $0 <LionWebsite.db> <现有共享订阅 upstream key>" >&2
exit 2
fi
if [[ ! -f "$db_path" ]]; then
echo "数据库不存在: $db_path" >&2
exit 2
fi
sqlite3 "$db_path" -cmd ".parameter init" -cmd ".parameter set :legacy_key '$legacy_key'" \
< "$(dirname "$0")/migrate_subscription_accounts.sql"
echo "订阅子账号迁移完成: $db_path"
+38
View File
@@ -0,0 +1,38 @@
BEGIN;
CREATE TABLE IF NOT EXISTS subscription_account (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
upstream_key VARCHAR(255) NOT NULL UNIQUE,
filter_high_multiplier INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
last_success_at DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO subscription_account (name, upstream_key, filter_high_multiplier, enabled)
SELECT '旧共享订阅', :legacy_key, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM subscription_account WHERE name = '旧共享订阅')
AND NOT EXISTS (SELECT 1 FROM subscription_account WHERE upstream_key = :legacy_key);
CREATE TABLE sub_bind_new (
key VARCHAR(255) NOT NULL PRIMARY KEY,
user VARCHAR(255) NOT NULL UNIQUE,
subscription_account_id INTEGER NOT NULL,
FOREIGN KEY (subscription_account_id) REFERENCES subscription_account(id)
);
INSERT INTO sub_bind_new (key, user, subscription_account_id)
SELECT sb.key, sb.user, sa.id
FROM sub_bind sb
JOIN (SELECT id FROM subscription_account
WHERE name = '旧共享订阅' OR upstream_key = :legacy_key
ORDER BY id LIMIT 1) sa;
DROP TABLE sub_bind;
ALTER TABLE sub_bind_new RENAME TO sub_bind;
CREATE INDEX idx_sub_bind_account ON sub_bind(subscription_account_id);
COMMIT;
@@ -11,8 +11,8 @@ public class SubController {
final SubService subService;
@PostMapping("")
public String addSubBind(String user){
return subService.insertSubBind(user);
public String addSubBind(String user, Integer accountId){
return subService.insertSubBind(user, accountId);
}
@PutMapping("")
@@ -34,4 +34,38 @@ public class SubController {
public String deleteSubBind(String user){
return subService.deleteSubBind(user);
}
@GetMapping("accounts")
public String getAccounts(){
return subService.listSubscriptionAccounts();
}
@PostMapping("accounts")
public String addAccount(String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.insertSubscriptionAccount(name, upstreamKey, filterHighMultiplier, enabled);
}
@PutMapping("accounts/{id}")
public String updateAccount(@PathVariable Integer id, String name, String upstreamKey,
@RequestParam(defaultValue = "true") boolean filterHighMultiplier,
@RequestParam(defaultValue = "true") boolean enabled){
return subService.updateSubscriptionAccount(id, name, upstreamKey, filterHighMultiplier, enabled);
}
@PostMapping("accounts/{id}/refresh")
public String refreshAccount(@PathVariable Integer id){
return subService.refreshSubscriptionAccount(id);
}
@DeleteMapping("accounts/{id}")
public String deleteAccount(@PathVariable Integer id){
return subService.deleteSubscriptionAccount(id);
}
@PutMapping("{user}/account")
public String rebind(@PathVariable String user, Integer accountId){
return subService.rebind(user, accountId);
}
}
@@ -2,29 +2,61 @@ package com.lion.lionwebsite.Dao.normal;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import org.apache.ibatis.annotations.*;
import java.util.ArrayList;
@Mapper
public interface SubMapper {
@Insert("insert into sub_bind values (#{key}, #{user})")
@Insert("insert into subscription_account (name, upstream_key, filter_high_multiplier, enabled, created_at, updated_at) values (#{name}, #{upstreamKey}, #{filterHighMultiplier}, #{enabled}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
@Options(useGeneratedKeys = true, keyProperty = "id")
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")
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}")
SubscriptionAccount selectSubscriptionAccount(Integer id);
@Select("select count(*) from subscription_account where name=#{name}")
int countSubscriptionAccountName(String name);
@Select("select count(*) from subscription_account where upstream_key=#{upstreamKey}")
int countSubscriptionAccountKey(String upstreamKey);
@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);
@Update("update subscription_account set last_success_at=CURRENT_TIMESTAMP, last_error=null, updated_at=CURRENT_TIMESTAMP where id=#{id}")
void markSubscriptionRefreshSuccess(Integer 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);
@Delete("delete from subscription_account where id=#{id}")
void deleteSubscriptionAccount(Integer id);
@Insert("insert into sub_bind (key, user, subscription_account_id) values (#{key}, #{user}, #{subscriptionAccountId})")
void insertSubBind(SubBind subBind);
@Select("select * from sub_bind")
@Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id order by sb.user")
ArrayList<SubBind> selectAllSubBind();
@Select("select * from sub_bind where key=#{key}")
@Select("select sb.key, sb.user, sb.subscription_account_id as subscriptionAccountId, sa.name as subscriptionAccountName, sa.enabled as subscriptionAccountEnabled, sa.filter_high_multiplier as filterHighMultiplier from sub_bind sb left join subscription_account sa on sa.id=sb.subscription_account_id where sb.key=#{key}")
SubBind selectSubBind(String key);
@Select("select count(key) from sub_bind where key=#{key}")
boolean selectSubBindExist(String key);
@Select("select count(user) from sub_update_record where user=#{user}")
Integer selectUpdateRecordCount(String user);
@Select("select count(*) from sub_bind where user=#{user}")
int countSubBindByUser(String user);
@Select("select min(id) from sub_update_record where user=#{user}")
Integer selectMinUpdateRecordId(String user);
@Update("update sub_bind set subscription_account_id=#{accountId} where user=#{user}")
int updateSubBindAccount(@Param("user") String user, @Param("accountId") Integer accountId);
@Update("update sub_bind set key=#{key} where user=#{user}")
int updateSubBindKey(@Param("user") String user, @Param("key") String key);
@Delete("delete from sub_bind where user=#{user}")
void deleteSubBind(String user);
@@ -38,6 +70,12 @@ public interface SubMapper {
@Delete("delete from sub_update_record where user=#{user}")
void deleteSubUpdateRecord(String user);
@Select("select count(user) from sub_update_record where user=#{user}")
Integer selectUpdateRecordCount(String user);
@Select("select min(id) from sub_update_record where user=#{user}")
Integer selectMinUpdateRecordId(String user);
@Delete("delete from sub_update_record where id=#{id}")
void deleteSubUpdateRecordById(int id);
}
@@ -11,6 +11,9 @@ public interface UserMapper {
@Select("select * from User where AuthCode=#{AuthCode}")
User selectUserByAuthCode(String AuthCode);
@Select("select * from User where username=#{username}")
User selectUserByUsername(String username);
@Select("select AuthCode from User")
String[] selectAllAuthCode();
@@ -10,4 +10,8 @@ import lombok.NoArgsConstructor;
public class SubBind {
String key;
String user;
Integer subscriptionAccountId;
String subscriptionAccountName;
boolean subscriptionAccountEnabled;
boolean filterHighMultiplier;
}
@@ -0,0 +1,25 @@
package com.lion.lionwebsite.Domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SubscriptionAccount {
private Integer id;
private String name;
private String upstreamKey;
private boolean filterHighMultiplier;
private boolean enabled;
private Date lastSuccessAt;
private String lastError;
private Date createdAt;
private Date updatedAt;
private Integer boundUserCount;
private String v2Url;
private String clashUrl;
}
@@ -53,6 +53,8 @@ public class LocalService{
final RemoteService remoteService;
final SubscriptionRefreshService subscriptionRefreshService;
/**
* 检查连接是否有效,如果无效自动重连
*/
@@ -114,6 +116,13 @@ public class LocalService{
* 更新订阅链接的实际方法
*/
public boolean updateSub(boolean isManual) throws IOException {
// 手动和定时入口均刷新全部启用子账号;全部成功后更新原有“上次更新时间”。
boolean success = subscriptionRefreshService.refreshAll();
if (success)
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME,
CustomUtil.dateTimeFormatter().format(LocalDateTime.now()));
return success;
/*
DateTimeFormatter dateTimeFormatter = CustomUtil.dateTimeFormatter();
CustomConfiguration customConfiguration = configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME);
@@ -202,6 +211,7 @@ public class LocalService{
configurationMapper.updateConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME, dateTimeFormatter.format(LocalDateTime.now()));
return true;
*/
}
/**
@@ -2,8 +2,10 @@ package com.lion.lionwebsite.Service;
import cn.hutool.core.util.RandomUtil;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Dao.normal.UserMapper;
import com.lion.lionwebsite.Domain.SubBind;
import com.lion.lionwebsite.Domain.SubUpdateRecord;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import com.lion.lionwebsite.Util.CustomUtil;
import com.lion.lionwebsite.Util.FileDownload;
import com.lion.lionwebsite.Util.GalleryUtil;
@@ -18,6 +20,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Date;
@@ -26,100 +29,179 @@ import java.util.Date;
@RequiredArgsConstructor
public class SubService {
final SubMapper subMapper;
final UserMapper userMapper;
final SubscriptionRefreshService refreshService;
public String insertSubBind(String user){
public String insertSubscriptionAccount(String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
Response response = Response.generateResponse();
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
return response.failure("名称和上游 key 不能为空").toJSONString();
if (subMapper.countSubscriptionAccountName(name.trim()) > 0 || subMapper.countSubscriptionAccountKey(upstreamKey.trim()) > 0)
return response.failure("名称或上游 key 已存在").toJSONString();
SubscriptionAccount account = new SubscriptionAccount(null, name.trim(), upstreamKey.trim(), filterHighMultiplier, enabled, null, null, null, null, 0, null, null);
subMapper.insertSubscriptionAccount(account);
if (enabled)
refreshService.refresh(account.getId());
return response.success(accountJson(account)).toJSONString();
}
public String listSubscriptionAccounts() {
ArrayList<SubscriptionAccount> accounts = subMapper.selectAllSubscriptionAccounts();
for (SubscriptionAccount account : accounts) {
account.setV2Url(refreshService.v2Url(account));
account.setClashUrl(refreshService.clashUrl(account));
}
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(accounts)).toJSONString();
}
public String updateSubscriptionAccount(Integer id, String name, String upstreamKey, boolean filterHighMultiplier, boolean enabled) {
Response response = Response.generateResponse();
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
if (account == null)
return response.failure("子账号不存在").toJSONString();
if (name == null || name.isBlank() || upstreamKey == null || upstreamKey.isBlank())
return response.failure("名称和上游 key 不能为空").toJSONString();
for (SubscriptionAccount existing : subMapper.selectAllSubscriptionAccounts()) {
if (!existing.getId().equals(id) && (existing.getName().equals(name.trim()) || existing.getUpstreamKey().equals(upstreamKey.trim())))
return response.failure("名称或上游 key 已存在").toJSONString();
}
account.setName(name.trim());
account.setUpstreamKey(upstreamKey.trim());
account.setFilterHighMultiplier(filterHighMultiplier);
account.setEnabled(enabled);
subMapper.updateSubscriptionAccount(account);
if (enabled && !refreshService.refresh(id))
refreshService.invalidateCache(id);
return response.success(accountJson(account)).toJSONString();
}
public String deleteSubscriptionAccount(Integer id) {
Response response = Response.generateResponse();
SubscriptionAccount account = subMapper.selectSubscriptionAccount(id);
if (account == null)
return response.failure("子账号不存在").toJSONString();
if (account.getBoundUserCount() != null && account.getBoundUserCount() > 0)
return response.failure("子账号仍绑定用户,请先改绑").toJSONString();
subMapper.deleteSubscriptionAccount(id);
return response.success("删除成功").toJSONString();
}
public String refreshSubscriptionAccount(Integer id) {
return refreshService.refresh(id) ? Response._success("刷新成功") : Response._failure("刷新失败,请查看子账号错误状态");
}
public String insertSubBind(String user, Integer accountId) {
Response response = Response.generateResponse();
if (user == null || user.isBlank() || userMapper.selectUserByUsername(user) == null)
return response.failure("用户不存在").toJSONString();
SubscriptionAccount account = accountId == null ? firstEnabledAccount() : subMapper.selectSubscriptionAccount(accountId);
if (account == null || !account.isEnabled())
return response.failure("子账号不存在或已停用").toJSONString();
if (!refreshService.hasCompleteCache(account.getId()))
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
if (subMapper.countSubBindByUser(user) > 0)
return response.failure("用户已绑定子账号,请使用改绑").toJSONString();
String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8);
SubBind subBind = new SubBind(key, user);
subMapper.insertSubBind(subBind);
subMapper.insertSubBind(new SubBind(key, user, account.getId(), account.getName(), account.isEnabled(), account.isFilterHighMultiplier()));
return response.success("添加成功").toJSONString();
}
public String resetKey(String user){
public String resetKey(String user) {
Response response = Response.generateResponse();
subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user);
if (subMapper.countSubBindByUser(user) == 0)
return response.failure("绑定不存在").toJSONString();
String key = RandomUtil.randomString(8);
while (subMapper.selectSubBindExist(key))
key = RandomUtil.randomString(8);
SubBind subBind = new SubBind(key, user);
subMapper.insertSubBind(subBind);
subMapper.updateSubBindKey(user, key);
subMapper.deleteSubUpdateRecord(user);
return response.success().toJSONString();
}
public String selectAllSubBind(){
public String rebind(String user, Integer accountId) {
Response response = Response.generateResponse();
ArrayList<SubBind> subBinds = subMapper.selectAllSubBind();
return response.success(CustomUtil.objectMapper.valueToTree(subBinds)).toJSONString();
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
if (account == null || !account.isEnabled())
return response.failure("子账号不存在或已停用").toJSONString();
if (!refreshService.hasCompleteCache(account.getId()))
return response.failure("子账号尚无有效缓存,请先刷新").toJSONString();
if (subMapper.updateSubBindAccount(user, accountId) == 0)
return response.failure("绑定不存在").toJSONString();
return response.success("改绑成功").toJSONString();
}
public String SelectAllSubUpdateRecord(){
Response response = Response.generateResponse();
ArrayList<SubUpdateRecord> subUpdateRecords = subMapper.selectAllSubUpdateRecord();
return response.success(CustomUtil.objectMapper.valueToTree(subUpdateRecords)).toJSONString();
public String selectAllSubBind() {
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubBind())).toJSONString();
}
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key){
if(key == null || client == null)
public String SelectAllSubUpdateRecord() {
return Response.generateResponse().success(CustomUtil.objectMapper.valueToTree(subMapper.selectAllSubUpdateRecord())).toJSONString();
}
public void updateSub(HttpServletResponse response, HttpServletRequest request, String client, String key) {
if (key == null || client == null)
return;
SubBind subBind = subMapper.selectSubBind(key);
if(subBind == null)
if (subBind == null || subBind.getSubscriptionAccountId() == null || !subBind.isSubscriptionAccountEnabled()) {
sendStatus(response, HttpServletResponse.SC_NOT_FOUND, "subscription not found");
return;
String ip;
if(request.getRemoteAddr().equals("127.0.0.1")){
}
String ip = request.getRemoteAddr();
if ("127.0.0.1".equals(ip)) {
ip = request.getHeader("X-Forwarded-For");
if(ip.contains(","))
ip = ip.split(",")[0].trim();
if(ip.contains(":"))
ip = ip.split(":")[0].trim();
} else
ip = request.getRemoteAddr();
String UA = request.getHeader("User-Agent");
if(UA == null)
if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim();
if (ip != null && ip.contains(":")) ip = ip.split(":")[0].trim();
}
String ua = request.getHeader("User-Agent");
if (ua == null) return;
recordUpdate(subBind.getUser(), ip, ua);
if (!"v2".equals(client) && !"cat".equals(client)) {
sendStatus(response, HttpServletResponse.SC_BAD_REQUEST, "client error");
return;
}
java.nio.file.Path path = refreshService.cachedPath(subBind.getSubscriptionAccountId(), client);
if (!Files.isRegularFile(path)) {
sendStatus(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "subscription unavailable");
return;
}
FileDownload.export(request, response, path.toString());
}
private void recordUpdate(String user, String ip, String ua) {
String location;
try {
String page = GalleryUtil.requests("https://www.ip138.com/iplookup.php?ip=" + ip, "get", null, null);
Elements tds = Jsoup.parse(page).select("body > div > div.container > div.content > div > div:nth-child(2) > div.group-left > div > div.bd > div.table-outer > div.table-box > table > tbody > tr > td");
if(tds.size() > 3)
location = tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim();
else
location = tds.get(1).text();
}catch (IOException e){
log.error("获取ip地址信息失败: {}", e.getMessage());
location = tds.size() > 3 ? tds.get(1).text().replace("中国", "") + " " + tds.get(3).text().trim() : (tds.isEmpty() ? "unknown" : tds.get(1).text());
} catch (Exception e) {
location = "unknown";
}
SubUpdateRecord subUpdateRecord = new SubUpdateRecord(0, subBind.getUser(), ip, UA, new Date(), location);
subMapper.insertSubUpdateRecord(subUpdateRecord);
if(subMapper.selectUpdateRecordCount(subBind.getUser()) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(subBind.getUser()));
switch (client){
case "v2" -> FileDownload.export(request, response, "sub/DouNaiV2ray.txt");
case "cat" -> FileDownload.export(request, response, "sub/DouNaiClash.txt");
default -> {
try{
response.getOutputStream().write("client error".getBytes(StandardCharsets.UTF_8));
} catch (IOException ignored){}
}
}
subMapper.insertSubUpdateRecord(new SubUpdateRecord(0, user, ip, ua, new Date(), location));
if (subMapper.selectUpdateRecordCount(user) > 10)
subMapper.deleteSubUpdateRecordById(subMapper.selectMinUpdateRecordId(user));
}
public String deleteSubBind(String user){
Response response = Response.generateResponse();
public String deleteSubBind(String user) {
subMapper.deleteSubBind(user);
subMapper.deleteSubUpdateRecord(user);
return response.success("删除成功").toJSONString();
return Response._success("删除成功");
}
private SubscriptionAccount firstEnabledAccount() {
return subMapper.selectAllSubscriptionAccounts().stream().filter(SubscriptionAccount::isEnabled).findFirst().orElse(null);
}
private String accountJson(SubscriptionAccount account) {
account.setV2Url(refreshService.v2Url(account));
account.setClashUrl(refreshService.clashUrl(account));
return CustomUtil.objectMapper.valueToTree(account).toString();
}
private static void sendStatus(HttpServletResponse response, int status, String message) {
try {
response.sendError(status, message);
} catch (IOException ignored) { }
}
}
@@ -0,0 +1,214 @@
package com.lion.lionwebsite.Service;
import com.lion.lionwebsite.Dao.normal.SubMapper;
import com.lion.lionwebsite.Domain.SubscriptionAccount;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.*;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@Slf4j
@RequiredArgsConstructor
public class SubscriptionRefreshService {
private static final CloseableHttpClient HTTP_CLIENT = HttpClients.createDefault();
private static final Pattern MULTIPLIER = Pattern.compile("(\\d+(?:\\.\\d+)?)x\\s*$", Pattern.CASE_INSENSITIVE);
final SubMapper subMapper;
@Value("${subscription.upstream.v2-url-template}")
String v2UrlTemplate;
@Value("${subscription.upstream.clash-url-template}")
String clashUrlTemplate;
@Value("${subscription.upstream.high-multiplier-threshold:2.0}")
double highMultiplierThreshold;
public boolean refreshAll() {
boolean success = true;
for (SubscriptionAccount account : subMapper.selectAllSubscriptionAccounts()) {
if (account.isEnabled() && !refresh(account.getId()))
success = false;
}
return success;
}
public boolean refresh(Integer accountId) {
SubscriptionAccount account = subMapper.selectSubscriptionAccount(accountId);
if (account == null || !account.isEnabled())
return false;
try {
String v2 = processV2(firstLine(download(v2Url(account))), account.isFilterHighMultiplier(), highMultiplierThreshold);
List<String> clash = processClash(download(clashUrl(account)), account.isFilterHighMultiplier(), highMultiplierThreshold);
Path dir = Paths.get("sub", "accounts", String.valueOf(accountId));
Files.createDirectories(dir);
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));
subMapper.markSubscriptionRefreshSuccess(accountId);
return true;
} catch (Exception e) {
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
subMapper.markSubscriptionRefreshFailure(accountId, message.length() > 500 ? message.substring(0, 500) : message);
log.error("刷新子账号订阅失败 accountId={}: {}", accountId, message);
return false;
}
}
public String v2Url(SubscriptionAccount account) {
return applyTemplate(v2UrlTemplate, account.getUpstreamKey());
}
public String clashUrl(SubscriptionAccount account) {
return applyTemplate(clashUrlTemplate, account.getUpstreamKey());
}
private String applyTemplate(String template, String key) {
if (template == null || template.indexOf("{key}") < 0 || template.indexOf("{key}") != template.lastIndexOf("{key}"))
throw new IllegalStateException("订阅 URL 模板必须包含且只能包含一个 {key}");
return template.replace("{key}", java.net.URLEncoder.encode(key, StandardCharsets.UTF_8));
}
private static String firstLine(List<String> lines) {
if (lines.isEmpty())
throw new IllegalStateException("V2Ray 上游返回为空");
return lines.getFirst().trim();
}
private static String processV2(String encoded, boolean filter, double threshold) {
byte[] decoded;
try {
decoded = Base64.getMimeDecoder().decode(encoded);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("V2Ray 上游不是有效 Base64");
}
StringBuilder kept = new StringBuilder();
for (String node : new String(decoded, StandardCharsets.UTF_8).split("\\R")) {
if (node.isBlank())
continue;
if (!filter || !isHigh(nodeName(node), threshold))
kept.append(node).append('\n');
}
return Base64.getEncoder().encodeToString(kept.toString().getBytes(StandardCharsets.UTF_8));
}
private static String nodeName(String node) {
int hash = node.lastIndexOf('#');
if (hash < 0 || hash == node.length() - 1)
return "";
return URLDecoder.decode(node.substring(hash + 1), StandardCharsets.UTF_8);
}
private List<String> processClash(List<String> source, boolean filter, double threshold) {
Set<String> removed = new HashSet<>();
List<String> result = new ArrayList<>();
boolean inProxies = false;
boolean skipNode = false;
for (String line : source) {
if (line.equals("proxies:")) {
inProxies = true;
skipNode = false;
result.add(line);
continue;
}
if (line.equals("proxy-groups:")) {
inProxies = false;
skipNode = false;
result.add(line);
continue;
}
if (inProxies && line.matches("^\\s{2}-\\s+name:.*")) {
String name = clashName(line);
skipNode = filter && isHigh(name, threshold);
if (skipNode)
removed.add(name);
else
result.add(line);
continue;
}
if (skipNode)
continue;
if (!inProxies && !removed.isEmpty() && line.trim().startsWith("- ")) {
String ref = line.trim().substring(2).trim();
if (removed.contains(unquote(ref)))
continue;
}
result.add(line);
}
return result;
}
private static String clashName(String line) {
int index = line.indexOf("name:");
return unquote(line.substring(index + 5).trim());
}
private static String unquote(String value) {
if (value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))))
return value.substring(1, value.length() - 1);
return value;
}
private static boolean isHigh(String name, double threshold) {
Matcher matcher = MULTIPLIER.matcher(name);
return matcher.find() && Double.parseDouble(matcher.group(1)) > threshold;
}
private static List<String> download(String url) throws IOException {
HttpGet get = new HttpGet(url);
try (CloseableHttpResponse response = HTTP_CLIENT.execute(get)) {
if (response.getStatusLine().getStatusCode() != 200)
throw new IOException("上游 HTTP 状态码 " + response.getStatusLine().getStatusCode());
HttpEntity entity = response.getEntity();
if (entity == null)
throw new IOException("上游返回为空");
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(entity.getContent(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null)
lines.add(line);
}
return lines;
}
}
private static void atomicWrite(Path target, byte[] data) throws IOException {
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
Files.write(temp, data);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
public Path cachedPath(Integer accountId, String client) {
return Paths.get("sub", "accounts", String.valueOf(accountId), client.equals("v2") ? "v2ray.txt" : "clash.yaml");
}
public boolean hasCompleteCache(Integer accountId) {
return Files.isRegularFile(cachedPath(accountId, "v2")) && Files.isRegularFile(cachedPath(accountId, "cat"));
}
public void invalidateCache(Integer accountId) {
try {
Files.deleteIfExists(cachedPath(accountId, "v2"));
Files.deleteIfExists(cachedPath(accountId, "cat"));
} catch (IOException e) {
log.warn("清理失效订阅缓存失败 accountId={}", accountId, e);
}
}
}
+10 -2
View File
@@ -33,8 +33,16 @@ remote:
ip: "5.255.110.45"
local:
dou-nai-clash: "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=clashmeta"
dou-nai-v2ray: "https://aaaa.gay/link/X7zEqkIx5gtIGugO?client=v2"
dou-nai-clash: "https://aaaa.gay/link/{key}?client=clashmeta"
dou-nai-v2ray: "https://aaaa.gay/link/{key}?client=v2"
subscription:
upstream:
# Use environment variables or an external config file in production.
v2-url-template: "https://aaaa.gay/link/{key}?client=v2"
clash-url-template: "https://aaaa.gay/link/{key}?client=clashmeta"
high-multiplier-threshold: 2.0
refresh-interval-ms: 86400000
bot:
token: "5222939329:AAHa6l9ZuVVdNSDLPI_H-c8O_VgeOEw5plA"