支持多子账号订阅与独立节点过滤
This commit is contained in:
Executable
+18
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user