diff --git a/scripts/migrate_subscription_accounts.sh b/scripts/migrate_subscription_accounts.sh new file mode 100755 index 0000000..62c9ab6 --- /dev/null +++ b/scripts/migrate_subscription_accounts.sh @@ -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 <现有共享订阅 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" diff --git a/scripts/migrate_subscription_accounts.sql b/scripts/migrate_subscription_accounts.sql new file mode 100644 index 0000000..29c8e30 --- /dev/null +++ b/scripts/migrate_subscription_accounts.sql @@ -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; diff --git a/src/main/java/com/lion/lionwebsite/Controller/SubController.java b/src/main/java/com/lion/lionwebsite/Controller/SubController.java index 72ebd3b..b480713 100644 --- a/src/main/java/com/lion/lionwebsite/Controller/SubController.java +++ b/src/main/java/com/lion/lionwebsite/Controller/SubController.java @@ -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); + } } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java index f1c17dd..1bc52f0 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/SubMapper.java @@ -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 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 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); } diff --git a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java index 6bb8f3f..7fea6ff 100644 --- a/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java +++ b/src/main/java/com/lion/lionwebsite/Dao/normal/UserMapper.java @@ -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(); diff --git a/src/main/java/com/lion/lionwebsite/Domain/SubBind.java b/src/main/java/com/lion/lionwebsite/Domain/SubBind.java index 557a7a2..2f1f42e 100644 --- a/src/main/java/com/lion/lionwebsite/Domain/SubBind.java +++ b/src/main/java/com/lion/lionwebsite/Domain/SubBind.java @@ -10,4 +10,8 @@ import lombok.NoArgsConstructor; public class SubBind { String key; String user; + Integer subscriptionAccountId; + String subscriptionAccountName; + boolean subscriptionAccountEnabled; + boolean filterHighMultiplier; } diff --git a/src/main/java/com/lion/lionwebsite/Domain/SubscriptionAccount.java b/src/main/java/com/lion/lionwebsite/Domain/SubscriptionAccount.java new file mode 100644 index 0000000..85470c3 --- /dev/null +++ b/src/main/java/com/lion/lionwebsite/Domain/SubscriptionAccount.java @@ -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; +} diff --git a/src/main/java/com/lion/lionwebsite/Service/LocalService.java b/src/main/java/com/lion/lionwebsite/Service/LocalService.java index e7b779e..6f10f88 100644 --- a/src/main/java/com/lion/lionwebsite/Service/LocalService.java +++ b/src/main/java/com/lion/lionwebsite/Service/LocalService.java @@ -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; + */ } /** diff --git a/src/main/java/com/lion/lionwebsite/Service/SubService.java b/src/main/java/com/lion/lionwebsite/Service/SubService.java index dc0abc9..c81810c 100644 --- a/src/main/java/com/lion/lionwebsite/Service/SubService.java +++ b/src/main/java/com/lion/lionwebsite/Service/SubService.java @@ -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 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 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 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) { } } } diff --git a/src/main/java/com/lion/lionwebsite/Service/SubscriptionRefreshService.java b/src/main/java/com/lion/lionwebsite/Service/SubscriptionRefreshService.java new file mode 100644 index 0000000..d938bbb --- /dev/null +++ b/src/main/java/com/lion/lionwebsite/Service/SubscriptionRefreshService.java @@ -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 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 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 processClash(List source, boolean filter, double threshold) { + Set removed = new HashSet<>(); + List 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 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 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); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e58f863..4e83a3d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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"