3 Commits

Author SHA1 Message Date
riglen
ee9cc6f429 模块化 2026-04-20 11:47:10 +08:00
riglen
0a46ebf0a8 db 2026-04-20 11:09:36 +08:00
riglen
1cbf080e1e rule重构 2026-04-20 10:35:03 +08:00
31 changed files with 3599 additions and 997 deletions

View File

@@ -3,6 +3,10 @@ APP_ENV=prod
HOST=0.0.0.0
PORT=18080
LOG_LEVEL=info
DATABASE_URL=sqlite:////app/data/app.db
# Docker 运行时数据目录,建议放在仓库外
APP_DATA_DIR=../sub-provider-data
# 对外访问前缀,尽量改成足够长的随机字符串
PUBLIC_PATH=change-me-random-hash-path

2
.gitignore vendored
View File

@@ -62,6 +62,8 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
data/
output/bundle-cache/
# Flask stuff:
instance/

View File

@@ -40,6 +40,11 @@ sub-provider/
rules.py
subscriptions.py
config/
app.yaml
clients.yaml
policy-groups.yaml
regions.yaml
rules.yaml
sources.yaml
rules/
reject.yaml
@@ -67,19 +72,44 @@ cp .env.example .env
2. 编辑 `.env`
- `DATABASE_URL` 默认可用 `sqlite:////app/data/app.db`
- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data`
- `PUBLIC_PATH` 改成足够长的随机字符串
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt``file:///app/data/sources/airport-b.txt`
- 允许把其中一个留空;留空时这个机场会自动跳过
3. 启动
3. 按需调整 `config/` 下拆分后的配置文件
- `app.yaml`:公共访问路径
- `sources.yaml`:机场源配置
- `regions.yaml`:地区自动组
- `policy-groups.yaml`:选择器组和业务策略组
- `rules.yaml`:规则绑定
- `clients.yaml`:客户端模板
4. 如需切换到数据库配置源,先执行一次导入:
```bash
python -m app.import_config --replace
```
导入完成后,运行时会优先读取数据库;当数据库内没有配置时,仍会回退到 `config/` 下的 YAML 文件。
说明:
- `app.db`、抓取缓存等运行态数据默认写入 `/app/data`
- `docker-compose` 已将 `/app/data` 挂载到 `APP_DATA_DIR`
- 建议把这个目录放在仓库外,避免 `git pull`、切分支时碰到本地状态文件
5. 启动:
```bash
docker compose up -d --build
```
4. 访问检查:
6. 访问检查:
- 健康检查:`http://YOUR_HOST:18080/healthz`
- 单 provider

36
alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = sqlite:///./data/app.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

41
alembic/env.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.db_models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@

View File

@@ -26,8 +26,16 @@ class Settings(BaseSettings):
bundle_cache_ttl_seconds: int = 600
max_proxy_name_length: int = 80
default_user_agent: str = "sub-provider/0.2"
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
database_echo: bool = False
config_dir: Path = CONFIG_DIR
sources_file: Path = CONFIG_DIR / "sources.yaml"
app_config_file: Path = CONFIG_DIR / "app.yaml"
clients_file: Path = CONFIG_DIR / "clients.yaml"
regions_file: Path = CONFIG_DIR / "regions.yaml"
policy_groups_file: Path = CONFIG_DIR / "policy-groups.yaml"
rules_config_file: Path = CONFIG_DIR / "rules.yaml"
rules_dir: Path = CONFIG_DIR / "rules"
bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache"
fetch_cache_dir: Path = DATA_DIR / "fetch-cache"

40
app/db.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import DATA_DIR, get_settings
from app.db_models import Base
def _sqlite_connect_args(database_url: str) -> dict[str, bool]:
if database_url.startswith("sqlite"):
return {"check_same_thread": False}
return {}
@lru_cache(maxsize=1)
def get_engine():
settings = get_settings()
DATA_DIR.mkdir(parents=True, exist_ok=True)
return create_engine(
settings.database_url,
echo=settings.database_echo,
future=True,
connect_args=_sqlite_connect_args(settings.database_url),
)
@lru_cache(maxsize=1)
def get_session_factory():
return sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True)
def init_db() -> None:
Base.metadata.create_all(bind=get_engine())
def get_session() -> Session:
return get_session_factory()()

149
app/db_models.py Normal file
View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)
class AppSettingORM(Base, TimestampMixin):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
class SourceORM(Base, TimestampMixin):
__tablename__ = "sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
kind: Mapped[str] = mapped_column(String(50), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
headers_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
include_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
exclude_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
prefix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
suffix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
cache_ttl_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
class ProfileORM(Base, TimestampMixin):
__tablename__ = "profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
provider_interval: Mapped[int] = mapped_column(Integer, default=21600, nullable=False)
rule_interval: Mapped[int] = mapped_column(Integer, default=86400, nullable=False)
test_url: Mapped[str] = mapped_column(Text, nullable=False)
test_interval: Mapped[int] = mapped_column(Integer, default=300, nullable=False)
main_policy: Mapped[str] = mapped_column(String(255), nullable=False)
source_policy: Mapped[str] = mapped_column(String(255), nullable=False)
mixed_auto_policy: Mapped[str] = mapped_column(String(255), nullable=False)
manual_policy: Mapped[str] = mapped_column(String(255), nullable=False)
direct_policy: Mapped[str] = mapped_column(String(255), nullable=False)
mode: Mapped[str] = mapped_column(String(50), default="rule", nullable=False)
allow_lan: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
ipv6: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
mixed_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
socks_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
log_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
source_links: Mapped[list["ProfileSourceORM"]] = relationship(
back_populates="profile",
cascade="all, delete-orphan",
)
rule_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
back_populates="profile",
cascade="all, delete-orphan",
)
groups: Mapped[list["PolicyGroupORM"]] = relationship(
back_populates="profile",
cascade="all, delete-orphan",
)
class ProfileSourceORM(Base):
__tablename__ = "profile_sources"
__table_args__ = (UniqueConstraint("profile_id", "source_id", name="uq_profile_source"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
profile: Mapped[ProfileORM] = relationship(back_populates="source_links")
source: Mapped[SourceORM] = relationship()
class RuleModuleORM(Base, TimestampMixin):
__tablename__ = "rule_modules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
behavior: Mapped[str] = mapped_column(String(50), nullable=False)
format: Mapped[str] = mapped_column(String(50), nullable=False)
policy: Mapped[str] = mapped_column(String(255), nullable=False)
no_resolve: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
payload_json: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
profile_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
back_populates="rule_module",
cascade="all, delete-orphan",
)
class ProfileRuleModuleORM(Base):
__tablename__ = "profile_rule_modules"
__table_args__ = (UniqueConstraint("profile_id", "rule_module_id", name="uq_profile_rule_module"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
rule_module_id: Mapped[int] = mapped_column(ForeignKey("rule_modules.id"), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
policy_override: Mapped[str | None] = mapped_column(String(255), nullable=True)
payload_override_json: Mapped[str | None] = mapped_column(Text, nullable=True)
no_resolve_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
profile: Mapped[ProfileORM] = relationship(back_populates="rule_links")
rule_module: Mapped[RuleModuleORM] = relationship(back_populates="profile_links")
class PolicyGroupORM(Base, TimestampMixin):
__tablename__ = "policy_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
group_kind: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
proxies_json: Mapped[str | None] = mapped_column(Text, nullable=True)
filter_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
tolerance: Mapped[int | None] = mapped_column(Integer, nullable=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
interval: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
profile: Mapped[ProfileORM] = relationship(back_populates="groups")

24
app/import_config.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from app.config import get_settings
from app.services.config_store import import_yaml_config_to_db
def main() -> None:
parser = argparse.ArgumentParser(description="Import YAML config into the sub-provider database")
parser.add_argument(
"--replace",
action="store_true",
help="Clear existing config rows before import",
)
args = parser.parse_args()
settings = get_settings()
import_yaml_config_to_db(settings.sources_file, replace_existing=args.replace)
print(f"Imported config into {settings.database_url}")
if __name__ == "__main__":
main()

View File

@@ -150,7 +150,6 @@ async def client_profile(client_type: str, request: Request, sources: str | None
build_thin_profile(
client_type=client_type,
app_config=app_config,
client=client,
selected_source_names=[name for name, _ in source_items],
base_url=_base_url(request),
public_path=(app_config.public_path or settings.public_path).strip("/"),
@@ -199,7 +198,6 @@ async def bundle_profile(
build_bundle_profile(
client_type=client_type,
app_config=app_config,
client=client,
snapshots=snapshots,
)
)

View File

@@ -82,6 +82,26 @@ class ProviderDocument(BaseModel):
proxies: list[dict[str, Any]]
class ProxyNode(BaseModel):
name: str
type: str
server: str | None = None
port: int | None = None
udp: bool = True
tags: list[str] = Field(default_factory=list)
attrs: dict[str, Any] = Field(default_factory=dict)
def to_proxy_dict(self) -> dict[str, Any]:
data = {"name": self.name, "type": self.type}
if self.server is not None:
data["server"] = self.server
if self.port is not None:
data["port"] = self.port
data["udp"] = self.udp
data.update(self.attrs)
return data
class SubscriptionUserInfo(BaseModel):
upload: int | None = None
download: int | None = None
@@ -110,3 +130,13 @@ class SourceSnapshot(BaseModel):
document: ProviderDocument
headers: dict[str, str] = Field(default_factory=dict)
quota: SubscriptionUserInfo | None = None
class ResolvedProfile(BaseModel):
client_type: str
client: ClientConfig
selected_sources: dict[str, SourceConfig] = Field(default_factory=dict)
rules: dict[str, RuleConfig] = Field(default_factory=dict)
regions: dict[str, RegionConfig] = Field(default_factory=dict)
selector_groups: list[ProxyGroupConfig] = Field(default_factory=list)
policy_groups: list[ProxyGroupConfig] = Field(default_factory=list)

View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import json
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from app.db import get_session, init_db
from app.db_models import (
AppSettingORM,
PolicyGroupORM,
ProfileORM,
ProfileRuleModuleORM,
ProfileSourceORM,
RuleModuleORM,
SourceORM,
)
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, RegionConfig, RuleConfig, SourceConfig
from app.services.loader import load_yaml_app_config
def _json_dumps(value) -> str:
return json.dumps(value, ensure_ascii=False)
def _json_loads(value: str | None, default):
if not value:
return default
return json.loads(value)
def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> None:
app_config = load_yaml_app_config(config_path)
init_db()
with get_session() as session:
if replace_existing:
_clear_config_tables(session)
_upsert_setting(session, "public_path", app_config.public_path or "")
source_rows: list[SourceORM] = []
for key, source in app_config.sources.items():
row = _get_or_create_source(session, key)
row.enabled = source.enabled
row.kind = source.kind
row.url = source.url
row.display_name = source.display_name
row.headers_json = _json_dumps(source.headers)
row.include_regex = source.include_regex
row.exclude_regex = source.exclude_regex
row.prefix = source.prefix
row.suffix = source.suffix
row.cache_ttl_seconds = source.cache_ttl_seconds
source_rows.append(row)
module_rows: list[RuleModuleORM] = []
ordered_rule_keys = list(app_config.rules.keys())
for key, rule in app_config.rules.items():
row = _get_or_create_rule_module(session, key)
row.file_path = rule.file
row.behavior = rule.behavior
row.format = rule.format
row.policy = rule.policy
row.no_resolve = rule.no_resolve
row.payload_json = _json_dumps(rule.payload)
module_rows.append(row)
session.flush()
for client_key, client in app_config.clients.items():
profile = _get_or_create_profile(session, client_key)
_populate_profile(profile, client)
session.flush()
_replace_profile_sources(session, profile.id, source_rows)
_replace_profile_groups(session, profile.id, app_config)
_replace_profile_rule_links(session, profile.id, module_rows, ordered_rule_keys)
session.commit()
def load_app_config_from_db() -> AppConfig | None:
init_db()
with get_session() as session:
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
public_path = _setting_value(session, "public_path")
primary_profile = profiles[0]
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(primary_profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
rules: dict[str, RuleConfig] = {}
for link in sorted(primary_profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return AppConfig(
public_path=public_path or None,
sources={
source.key: SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
for source in sources
},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
def _clear_config_tables(session: Session) -> None:
session.execute(delete(ProfileRuleModuleORM))
session.execute(delete(PolicyGroupORM))
session.execute(delete(ProfileSourceORM))
session.execute(delete(ProfileORM))
session.execute(delete(RuleModuleORM))
session.execute(delete(SourceORM))
session.execute(delete(AppSettingORM))
session.flush()
def _upsert_setting(session: Session, key: str, value: str) -> None:
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
if row is None:
row = AppSettingORM(key=key, value=value)
session.add(row)
else:
row.value = value
def _setting_value(session: Session, key: str) -> str | None:
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
if row is None:
return None
return row.value
def _get_or_create_source(session: Session, key: str) -> SourceORM:
row = session.scalar(select(SourceORM).where(SourceORM.key == key))
if row is None:
row = SourceORM(key=key, kind="auto", url="")
session.add(row)
return row
def _get_or_create_rule_module(session: Session, key: str) -> RuleModuleORM:
row = session.scalar(select(RuleModuleORM).where(RuleModuleORM.key == key))
if row is None:
row = RuleModuleORM(key=key, behavior="classical", format="text", policy="DIRECT")
session.add(row)
return row
def _get_or_create_profile(session: Session, key: str) -> ProfileORM:
row = session.scalar(select(ProfileORM).where(ProfileORM.key == key))
if row is None:
row = ProfileORM(
key=key,
title=key,
test_url="https://www.gstatic.com/generate_204",
main_policy="🚀 节点选择",
source_policy="☁️ 机场选择",
mixed_auto_policy="♻️ 自动选择",
manual_policy="🚀 手动切换",
direct_policy="DIRECT",
)
session.add(row)
return row
def _populate_profile(profile: ProfileORM, client: ClientConfig) -> None:
profile.title = client.title
profile.provider_interval = client.provider_interval
profile.rule_interval = client.rule_interval
profile.test_url = str(client.test_url)
profile.test_interval = client.test_interval
profile.main_policy = client.main_policy
profile.source_policy = client.source_policy
profile.mixed_auto_policy = client.mixed_auto_policy
profile.manual_policy = client.manual_policy
profile.direct_policy = client.direct_policy
profile.mode = client.mode
profile.allow_lan = client.allow_lan
profile.ipv6 = client.ipv6
profile.mixed_port = client.mixed_port
profile.socks_port = client.socks_port
profile.log_level = client.log_level
def _replace_profile_sources(session: Session, profile_id: int, source_rows: list[SourceORM]) -> None:
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile_id))
for order_index, source in enumerate(source_rows):
session.add(
ProfileSourceORM(
profile_id=profile_id,
source_id=source.id,
order_index=order_index,
enabled=True,
)
)
def _replace_profile_groups(session: Session, profile_id: int, app_config: AppConfig) -> None:
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile_id))
order_index = 0
for region in app_config.regions.values():
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="region",
name=region.name,
type="url-test",
order_index=order_index,
filter_regex=region.filter,
tolerance=region.tolerance,
enabled=True,
)
)
order_index += 1
for group in app_config.selector_groups:
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="selector",
name=group.name,
type=group.type,
order_index=order_index,
proxies_json=_json_dumps(group.proxies),
filter_regex=group.filter,
tolerance=group.tolerance,
url=str(group.url) if group.url else None,
interval=group.interval,
enabled=True,
)
)
order_index += 1
for group in app_config.policy_groups:
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="policy",
name=group.name,
type=group.type,
order_index=order_index,
proxies_json=_json_dumps(group.proxies),
filter_regex=group.filter,
tolerance=group.tolerance,
url=str(group.url) if group.url else None,
interval=group.interval,
enabled=True,
)
)
order_index += 1
def _replace_profile_rule_links(
session: Session,
profile_id: int,
module_rows: list[RuleModuleORM],
ordered_rule_keys: list[str],
) -> None:
session.execute(delete(ProfileRuleModuleORM).where(ProfileRuleModuleORM.profile_id == profile_id))
module_by_key = {module.key: module for module in module_rows}
for order_index, key in enumerate(ordered_rule_keys):
module = module_by_key[key]
session.add(
ProfileRuleModuleORM(
profile_id=profile_id,
rule_module_id=module.id,
enabled=True,
order_index=order_index,
)
)
def _profile_to_client_config(profile: ProfileORM) -> ClientConfig:
return ClientConfig(
title=profile.title,
provider_interval=profile.provider_interval,
rule_interval=profile.rule_interval,
test_url=profile.test_url,
test_interval=profile.test_interval,
main_policy=profile.main_policy,
source_policy=profile.source_policy,
mixed_auto_policy=profile.mixed_auto_policy,
manual_policy=profile.manual_policy,
direct_policy=profile.direct_policy,
mode=profile.mode,
allow_lan=profile.allow_lan,
ipv6=profile.ipv6,
mixed_port=profile.mixed_port,
socks_port=profile.socks_port,
log_level=profile.log_level,
)
def _slugify(name: str) -> str:
text = "".join(char.lower() if char.isalnum() else "-" for char in name).strip("-")
while "--" in text:
text = text.replace("--", "-")
return text or "group"

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
import yaml
from app.db import init_db
from app.models import AppConfig
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
@@ -21,7 +23,54 @@ def _expand_env(value):
return value
def load_app_config(path: Path) -> AppConfig:
def _load_yaml(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
expanded = _expand_env(raw)
if not isinstance(raw, dict):
raise ValueError(f"Config file must contain a top-level mapping: {path}")
return raw
def _section_value(raw: dict[str, Any], section: str) -> Any:
if section in raw:
return raw[section]
return raw
def _load_split_config(path: Path) -> dict[str, Any]:
config_dir = path if path.is_dir() else path.parent
legacy_raw = _load_yaml(path) if path.is_file() else {}
app_raw = _load_yaml(config_dir / "app.yaml")
sources_raw = _load_yaml(config_dir / "sources.yaml")
regions_raw = _load_yaml(config_dir / "regions.yaml")
groups_raw = _load_yaml(config_dir / "policy-groups.yaml")
rules_raw = _load_yaml(config_dir / "rules.yaml")
clients_raw = _load_yaml(config_dir / "clients.yaml")
merged: dict[str, Any] = {}
merged.update({key: value for key, value in legacy_raw.items() if key not in AppConfig.model_fields})
merged["public_path"] = app_raw.get("public_path", legacy_raw.get("public_path"))
merged["sources"] = _section_value(sources_raw, "sources") if sources_raw else legacy_raw.get("sources", {})
merged["regions"] = _section_value(regions_raw, "regions") if regions_raw else legacy_raw.get("regions", {})
merged["selector_groups"] = groups_raw.get("selector_groups", legacy_raw.get("selector_groups", []))
merged["policy_groups"] = groups_raw.get("policy_groups", legacy_raw.get("policy_groups", []))
merged["rules"] = _section_value(rules_raw, "rules") if rules_raw else legacy_raw.get("rules", {})
merged["clients"] = _section_value(clients_raw, "clients") if clients_raw else legacy_raw.get("clients", {})
return merged
def load_app_config(path: Path) -> AppConfig:
init_db()
from app.services.config_store import load_app_config_from_db
db_config = load_app_config_from_db()
if db_config is not None:
return db_config
return load_yaml_app_config(path)
def load_yaml_app_config(path: Path) -> AppConfig:
expanded = _expand_env(_load_split_config(path))
return AppConfig.model_validate(expanded)

View File

@@ -0,0 +1,309 @@
from __future__ import annotations
import re
from typing import Any
from app.models import ProxyGroupConfig, ResolvedProfile, SourceSnapshot
from app.services.proxy_pipeline import source_auto_group_name
def _expand_proxy_tokens(
proxies: list[str],
*,
resolved_profile: ResolvedProfile,
source_auto_names: list[str],
selector_names: list[str],
) -> list[str]:
client = resolved_profile.client
tokens = {
"{{ main_policy }}": [client.main_policy],
"{{main_policy}}": [client.main_policy],
"{{ source_policy }}": [client.source_policy],
"{{source_policy}}": [client.source_policy],
"{{ mixed_auto_policy }}": [client.mixed_auto_policy],
"{{mixed_auto_policy}}": [client.mixed_auto_policy],
"{{ manual_policy }}": [client.manual_policy],
"{{manual_policy}}": [client.manual_policy],
"{{ direct_policy }}": [client.direct_policy],
"{{direct_policy}}": [client.direct_policy],
"{{ source_auto_groups }}": source_auto_names,
"{{source_auto_groups}}": source_auto_names,
"{{ selector_groups }}": selector_names,
"{{selector_groups}}": selector_names,
}
expanded: list[str] = []
for item in proxies:
expanded.extend(tokens.get(item, [item]))
return expanded
def _build_filter_group_for_thin(
*,
resolved_profile: ResolvedProfile,
group: ProxyGroupConfig,
selected_source_names: list[str],
) -> dict[str, Any]:
client = resolved_profile.client
built: dict[str, Any] = {"name": group.name, "type": group.type, "filter": group.filter}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
if resolved_profile.client_type == "mihomo":
built["use"] = selected_source_names
else:
built["include-all"] = True
return built
def _build_filter_group_for_bundle(
*,
resolved_profile: ResolvedProfile,
group: ProxyGroupConfig,
all_proxy_names: list[str],
) -> dict[str, Any]:
client = resolved_profile.client
matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)]
built: dict[str, Any] = {"name": group.name, "type": group.type, "proxies": matched or [client.direct_policy]}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
return built
def _build_custom_policy_groups(
*,
resolved_profile: ResolvedProfile,
source_auto_names: list[str],
selector_names: list[str],
) -> list[dict[str, Any]]:
groups: list[dict[str, Any]] = []
client = resolved_profile.client
for group in resolved_profile.policy_groups:
built: dict[str, Any] = {
"name": group.name,
"type": group.type,
"proxies": _expand_proxy_tokens(
group.proxies,
resolved_profile=resolved_profile,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
groups.append(built)
return groups
def build_thin_groups(
*,
resolved_profile: ResolvedProfile,
selected_source_names: list[str],
) -> list[dict[str, Any]]:
client = resolved_profile.client
groups: list[dict[str, Any]] = []
source_auto_names: list[str] = []
for source_name in selected_source_names:
source = resolved_profile.selected_sources[source_name]
group_name = source_auto_group_name(source.display_name or source_name)
source_auto_names.append(group_name)
groups.append(
{
"name": group_name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"use": [source_name],
}
)
if resolved_profile.client_type == "mihomo":
mixed_auto = {
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"include-all-providers": True,
}
manual = {
"name": client.manual_policy,
"type": "select",
"proxies": [client.direct_policy],
"include-all-providers": True,
}
else:
mixed_auto = {
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"include-all": True,
}
manual = {
"name": client.manual_policy,
"type": "select",
"proxies": [client.direct_policy],
"include-all": True,
}
groups.append(mixed_auto)
region_names = [region.name for region in resolved_profile.regions.values()]
selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]]
groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]})
groups.append(manual)
groups.append(
{
"name": client.main_policy,
"type": "select",
"proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy],
}
)
groups.extend(
_build_custom_policy_groups(
resolved_profile=resolved_profile,
source_auto_names=source_auto_names,
selector_names=selector_names,
)
)
for region in resolved_profile.regions.values():
group: dict[str, Any] = {
"name": region.name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"filter": region.filter,
"tolerance": region.tolerance,
}
if resolved_profile.client_type == "mihomo":
group["include-all-providers"] = True
else:
group["include-all"] = True
groups.append(group)
for selector in resolved_profile.selector_groups:
if selector.filter:
groups.append(
_build_filter_group_for_thin(
resolved_profile=resolved_profile,
group=selector,
selected_source_names=selected_source_names,
)
)
else:
groups.append(
{
"name": selector.name,
"type": selector.type,
"proxies": _expand_proxy_tokens(
selector.proxies,
resolved_profile=resolved_profile,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
)
return groups
def build_bundle_groups(
*,
resolved_profile: ResolvedProfile,
snapshots: list[SourceSnapshot],
source_proxy_names: dict[str, list[str]],
) -> list[dict[str, Any]]:
client = resolved_profile.client
groups: list[dict[str, Any]] = []
source_auto_names: list[str] = []
all_proxy_names = [name for names in source_proxy_names.values() for name in names]
for snapshot in snapshots:
group_name = source_auto_group_name(snapshot.display_name)
source_auto_names.append(group_name)
groups.append(
{
"name": group_name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy],
}
)
groups.append(
{
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"proxies": all_proxy_names or [client.direct_policy],
}
)
region_names = [region.name for region in resolved_profile.regions.values()]
selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]]
groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]})
groups.append({"name": client.manual_policy, "type": "select", "proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy]})
groups.append(
{
"name": client.main_policy,
"type": "select",
"proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy],
}
)
groups.extend(
_build_custom_policy_groups(
resolved_profile=resolved_profile,
source_auto_names=source_auto_names,
selector_names=selector_names,
)
)
for region in resolved_profile.regions.values():
matched = [name for name in all_proxy_names if re.search(region.filter, name)]
groups.append(
{
"name": region.name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"tolerance": region.tolerance,
"proxies": matched or [client.direct_policy],
}
)
for selector in resolved_profile.selector_groups:
if selector.filter:
groups.append(
_build_filter_group_for_bundle(
resolved_profile=resolved_profile,
group=selector,
all_proxy_names=all_proxy_names,
)
)
else:
groups.append(
{
"name": selector.name,
"type": selector.type,
"proxies": _expand_proxy_tokens(
selector.proxies,
resolved_profile=resolved_profile,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
)
return groups

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from app.models import AppConfig, ResolvedProfile
def resolve_profile(
*,
app_config: AppConfig,
client_type: str,
selected_source_names: list[str],
) -> ResolvedProfile:
client = app_config.clients[client_type]
return ResolvedProfile(
client_type=client_type,
client=client,
selected_sources={name: app_config.sources[name] for name in selected_source_names if name in app_config.sources},
rules=dict(app_config.rules),
regions=dict(app_config.regions),
selector_groups=list(app_config.selector_groups),
policy_groups=list(app_config.policy_groups),
)

View File

@@ -1,143 +1,44 @@
from __future__ import annotations
import re
from typing import Any
import yaml
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot
from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references
from app.models import AppConfig, SourceSnapshot
from app.services.policy_group_builder import build_bundle_groups, build_thin_groups
from app.services.profile_resolver import resolve_profile
from app.services.proxy_pipeline import build_bundle_proxy_inventory
from app.services.rule_resolver import build_rule_provider_entries, iter_resolved_rule_lines
def dump_yaml(data: dict[str, Any]) -> str:
return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
def _expand_proxy_tokens(
proxies: list[str],
*,
client: ClientConfig,
source_auto_names: list[str],
selector_names: list[str],
) -> list[str]:
expanded: list[str] = []
tokens = {
"{{ main_policy }}": [client.main_policy],
"{{main_policy}}": [client.main_policy],
"{{ source_policy }}": [client.source_policy],
"{{source_policy}}": [client.source_policy],
"{{ mixed_auto_policy }}": [client.mixed_auto_policy],
"{{mixed_auto_policy}}": [client.mixed_auto_policy],
"{{ manual_policy }}": [client.manual_policy],
"{{manual_policy}}": [client.manual_policy],
"{{ direct_policy }}": [client.direct_policy],
"{{direct_policy}}": [client.direct_policy],
"{{ source_auto_groups }}": source_auto_names,
"{{source_auto_groups}}": source_auto_names,
"{{ selector_groups }}": selector_names,
"{{selector_groups}}": selector_names,
}
for item in proxies:
expanded.extend(tokens.get(item, [item]))
return expanded
def _build_thin_filter_group(
*,
client_type: str,
client: ClientConfig,
group: ProxyGroupConfig,
selected_source_names: list[str],
) -> dict[str, Any]:
built: dict[str, Any] = {
"name": group.name,
"type": group.type,
"filter": group.filter,
}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
if client_type == "mihomo":
built["use"] = selected_source_names
else:
built["include-all"] = True
return built
def _build_bundle_filter_group(
*,
client: ClientConfig,
group: ProxyGroupConfig,
all_proxy_names: list[str],
) -> dict[str, Any]:
matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)]
built: dict[str, Any] = {
"name": group.name,
"type": group.type,
"proxies": matched or [client.direct_policy],
}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
return built
def _build_custom_policy_groups(
*,
app_config: AppConfig,
client: ClientConfig,
source_auto_names: list[str],
selector_names: list[str],
) -> list[dict[str, Any]]:
groups: list[dict[str, Any]] = []
for group in app_config.policy_groups:
built: dict[str, Any] = {
"name": group.name,
"type": group.type,
"proxies": _expand_proxy_tokens(
group.proxies,
client=client,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
if group.type == "url-test":
built["url"] = str(group.url or client.test_url)
built["interval"] = group.interval or client.test_interval
if group.tolerance is not None:
built["tolerance"] = group.tolerance
groups.append(built)
return groups
def build_thin_profile(
*,
client_type: str,
app_config: AppConfig,
client: ClientConfig,
selected_source_names: list[str],
base_url: str,
public_path: str,
) -> dict[str, Any]:
profile: dict[str, Any] = {
"mode": client.mode,
"ipv6": client.ipv6,
}
def _build_profile_header(*, resolved_profile) -> dict[str, Any]:
client = resolved_profile.client
profile: dict[str, Any] = {"mode": client.mode, "ipv6": client.ipv6}
if client.log_level:
profile["log-level"] = client.log_level
if client_type == "mihomo":
if resolved_profile.client_type == "mihomo":
if client.mixed_port is not None:
profile["mixed-port"] = client.mixed_port
if client.socks_port is not None:
profile["socks-port"] = client.socks_port
profile["allow-lan"] = client.allow_lan
return profile
def _build_proxy_providers(
*,
resolved_profile,
base_url: str,
public_path: str,
) -> dict[str, dict[str, Any]]:
proxy_providers: dict[str, dict[str, Any]] = {}
for name in selected_source_names:
if client_type == "mihomo":
client = resolved_profile.client
for name in resolved_profile.selected_sources:
if resolved_profile.client_type == "mihomo":
proxy_providers[name] = {
"type": "http",
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
@@ -154,10 +55,42 @@ def build_thin_profile(
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
"interval": client.provider_interval,
}
profile["proxy-providers"] = proxy_providers
profile["proxy-groups"] = _build_thin_groups(client_type, app_config, client, selected_source_names)
profile["rule-providers"] = build_rule_provider_entries(app_config, client, base_url, public_path)
profile["rules"] = build_rule_set_references(app_config, client)
return proxy_providers
def build_thin_profile(
*,
client_type: str,
app_config: AppConfig,
selected_source_names: list[str],
base_url: str,
public_path: str,
) -> dict[str, Any]:
resolved_profile = resolve_profile(
app_config=app_config,
client_type=client_type,
selected_source_names=selected_source_names,
)
profile = _build_profile_header(resolved_profile=resolved_profile)
profile["proxy-providers"] = _build_proxy_providers(
resolved_profile=resolved_profile,
base_url=base_url,
public_path=public_path,
)
profile["proxy-groups"] = build_thin_groups(
resolved_profile=resolved_profile,
selected_source_names=selected_source_names,
)
profile["rule-providers"] = build_rule_provider_entries(
resolved_profile=resolved_profile,
base_url=base_url,
public_path=public_path,
)
profile["rules"] = iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=True,
inline_file_payloads=False,
)
return profile
@@ -165,275 +98,24 @@ def build_bundle_profile(
*,
client_type: str,
app_config: AppConfig,
client: ClientConfig,
snapshots: list[SourceSnapshot],
) -> dict[str, Any]:
profile: dict[str, Any] = {
"mode": client.mode,
"ipv6": client.ipv6,
}
if client.log_level:
profile["log-level"] = client.log_level
if client_type == "mihomo":
if client.mixed_port is not None:
profile["mixed-port"] = client.mixed_port
if client.socks_port is not None:
profile["socks-port"] = client.socks_port
profile["allow-lan"] = client.allow_lan
proxies: list[dict[str, Any]] = []
source_proxy_names: dict[str, list[str]] = {}
seen: set[str] = set()
for snapshot in snapshots:
names: list[str] = []
for proxy in snapshot.document.proxies:
candidate = dict(proxy)
name = str(candidate.get("name", "")).strip()
if not name:
continue
original = name
index = 2
while name in seen:
name = f"{original} #{index}"
index += 1
seen.add(name)
candidate["name"] = name
proxies.append(candidate)
names.append(name)
source_proxy_names[snapshot.name] = names
profile["proxies"] = proxies
profile["proxy-groups"] = _build_bundle_groups(app_config, client, snapshots, source_proxy_names)
profile["rules"] = build_inline_rules(app_config, client)
resolved_profile = resolve_profile(
app_config=app_config,
client_type=client_type,
selected_source_names=[snapshot.name for snapshot in snapshots],
)
profile = _build_profile_header(resolved_profile=resolved_profile)
proxy_nodes, source_proxy_names = build_bundle_proxy_inventory(snapshots)
profile["proxies"] = [node.to_proxy_dict() for node in proxy_nodes]
profile["proxy-groups"] = build_bundle_groups(
resolved_profile=resolved_profile,
snapshots=snapshots,
source_proxy_names=source_proxy_names,
)
profile["rules"] = iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=False,
inline_file_payloads=True,
)
return profile
def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientConfig, selected_source_names: list[str]) -> list[dict[str, Any]]:
groups: list[dict[str, Any]] = []
source_auto_names: list[str] = []
for source_name in selected_source_names:
display_name = app_config.sources[source_name].display_name or source_name
group_name = f"{display_name} 自动"
source_auto_names.append(group_name)
groups.append(
{
"name": group_name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"use": [source_name],
}
)
if client_type == "mihomo":
mixed_auto = {
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"include-all-providers": True,
}
manual = {
"name": client.manual_policy,
"type": "select",
"proxies": [client.direct_policy],
"include-all-providers": True,
}
else:
mixed_auto = {
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"include-all": True,
}
manual = {
"name": client.manual_policy,
"type": "select",
"proxies": [client.direct_policy],
"include-all": True,
}
groups.append(mixed_auto)
region_names = [region.name for region in app_config.regions.values()]
selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]]
groups.append(
{
"name": client.source_policy,
"type": "select",
"proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy],
}
)
groups.append(manual)
groups.append(
{
"name": client.main_policy,
"type": "select",
"proxies": [
client.source_policy,
client.mixed_auto_policy,
*selector_names,
client.manual_policy,
client.direct_policy,
],
}
)
groups.extend(
_build_custom_policy_groups(
app_config=app_config,
client=client,
source_auto_names=source_auto_names,
selector_names=selector_names,
)
)
for region in app_config.regions.values():
group = {
"name": region.name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"filter": region.filter,
"tolerance": region.tolerance,
}
if client_type == "mihomo":
group["include-all-providers"] = True
else:
group["include-all"] = True
groups.append(group)
for selector in app_config.selector_groups:
if selector.filter:
groups.append(
_build_thin_filter_group(
client_type=client_type,
client=client,
group=selector,
selected_source_names=selected_source_names,
)
)
else:
groups.append(
{
"name": selector.name,
"type": selector.type,
"proxies": _expand_proxy_tokens(
selector.proxies,
client=client,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
)
return groups
def _build_bundle_groups(
app_config: AppConfig,
client: ClientConfig,
snapshots: list[SourceSnapshot],
source_proxy_names: dict[str, list[str]],
) -> list[dict[str, Any]]:
groups: list[dict[str, Any]] = []
source_auto_names: list[str] = []
all_proxy_names = [name for names in source_proxy_names.values() for name in names]
for snapshot in snapshots:
group_name = f"{snapshot.display_name} 自动"
source_auto_names.append(group_name)
groups.append(
{
"name": group_name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy],
}
)
groups.append(
{
"name": client.mixed_auto_policy,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"proxies": all_proxy_names or [client.direct_policy],
}
)
region_names = [region.name for region in app_config.regions.values()]
selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]]
groups.append(
{
"name": client.source_policy,
"type": "select",
"proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy],
}
)
groups.append(
{
"name": client.manual_policy,
"type": "select",
"proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy],
}
)
groups.append(
{
"name": client.main_policy,
"type": "select",
"proxies": [
client.source_policy,
client.mixed_auto_policy,
*selector_names,
client.manual_policy,
client.direct_policy,
],
}
)
groups.extend(
_build_custom_policy_groups(
app_config=app_config,
client=client,
source_auto_names=source_auto_names,
selector_names=selector_names,
)
)
for region in app_config.regions.values():
matched = [name for name in all_proxy_names if re.search(region.filter, name)]
groups.append(
{
"name": region.name,
"type": "url-test",
"url": str(client.test_url),
"interval": client.test_interval,
"tolerance": region.tolerance,
"proxies": matched or [client.direct_policy],
}
)
for selector in app_config.selector_groups:
if selector.filter:
groups.append(
_build_bundle_filter_group(
client=client,
group=selector,
all_proxy_names=all_proxy_names,
)
)
else:
groups.append(
{
"name": selector.name,
"type": selector.type,
"proxies": _expand_proxy_tokens(
selector.proxies,
client=client,
source_auto_names=source_auto_names,
selector_names=selector_names,
),
}
)
return groups

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
from app.models import ProxyNode, SourceSnapshot
def source_auto_group_name(display_name: str) -> str:
return f"{display_name} 自动"
def proxy_dict_to_node(proxy: dict[str, Any]) -> ProxyNode | None:
name = str(proxy.get("name", "")).strip()
proxy_type = str(proxy.get("type", "")).strip()
if not name or not proxy_type:
return None
attrs = dict(proxy)
attrs.pop("name", None)
attrs.pop("type", None)
server = attrs.pop("server", None)
port = attrs.pop("port", None)
udp = bool(attrs.pop("udp", True))
return ProxyNode(
name=name,
type=proxy_type,
server=server,
port=port,
udp=udp,
attrs=attrs,
)
def dedupe_proxy_nodes(nodes: list[ProxyNode]) -> list[ProxyNode]:
seen: set[str] = set()
deduped: list[ProxyNode] = []
for node in nodes:
original = node.name
name = original
index = 2
while name in seen:
name = f"{original} #{index}"
index += 1
seen.add(name)
deduped.append(node.model_copy(update={"name": name}))
return deduped
def build_bundle_proxy_inventory(
snapshots: list[SourceSnapshot],
) -> tuple[list[ProxyNode], dict[str, list[str]]]:
all_nodes: list[ProxyNode] = []
source_nodes: dict[str, list[ProxyNode]] = {}
for snapshot in snapshots:
nodes: list[ProxyNode] = []
for proxy in snapshot.document.proxies:
node = proxy_dict_to_node(proxy)
if node is not None:
nodes.append(node)
source_nodes[snapshot.name] = nodes
all_nodes.extend(nodes)
deduped_all = dedupe_proxy_nodes(all_nodes)
source_proxy_names: dict[str, list[str]] = {}
cursor = 0
for snapshot in snapshots:
original_nodes = source_nodes[snapshot.name]
count = len(original_nodes)
source_proxy_names[snapshot.name] = [node.name for node in deduped_all[cursor : cursor + count]]
cursor += count
return deduped_all, source_proxy_names

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from pathlib import Path
import re
import yaml
from app.config import get_settings
from app.models import ClientConfig, ResolvedProfile, RuleConfig
_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$")
def resolve_policy(policy: str, client: ClientConfig) -> str:
return (
policy.replace("{{ main_policy }}", client.main_policy)
.replace("{{main_policy}}", client.main_policy)
.replace("{{ direct_policy }}", client.direct_policy)
.replace("{{direct_policy}}", client.direct_policy)
)
def load_rule_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def load_rule_payload(path: Path) -> list[str]:
if path.suffix.lower() in {".yaml", ".yml"}:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
payload = data.get("payload", [])
if not isinstance(payload, list):
raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'")
return [str(item).strip() for item in payload if str(item).strip()]
lines: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
lines.append(stripped)
return lines
def _render_payload_line(payload_line: str, behavior: str) -> str:
if "," in payload_line:
return payload_line
if behavior == "classical":
if _CIDR_PATTERN.fullmatch(payload_line):
prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR"
return f"{prefix},{payload_line}"
return f"DOMAIN-SUFFIX,{payload_line}"
if behavior == "ipcidr":
return f"IP-CIDR,{payload_line}"
if behavior == "domain":
return f"DOMAIN-SUFFIX,{payload_line}"
return payload_line
def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str:
parts = [part.strip() for part in rendered_line.split(",")]
if parts and parts[-1] == "no-resolve":
parts.insert(len(parts) - 1, target)
return ",".join(parts)
line = f"{rendered_line},{target}"
if append_no_resolve:
line += ",no-resolve"
return line
def iter_resolved_rule_lines(
*,
resolved_profile: ResolvedProfile,
include_rule_set_references: bool,
inline_file_payloads: bool,
) -> list[str]:
settings = get_settings()
lines: list[str] = []
for rule_name, rule in resolved_profile.rules.items():
target = resolve_policy(rule.policy, resolved_profile.client)
for payload_line in rule.payload:
lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve))
if not rule.file:
continue
if include_rule_set_references:
ref_line = f"RULE-SET,{rule_name},{target}"
if rule.no_resolve:
ref_line += ",no-resolve"
lines.append(ref_line)
if not inline_file_payloads:
continue
path = (settings.rules_dir / rule.file).resolve()
if not path.is_file() or settings.rules_dir.resolve() not in path.parents:
raise FileNotFoundError(f"Rule file missing: {rule.file}")
for payload_line in load_rule_payload(path):
lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve))
lines.append(f"MATCH,{resolved_profile.client.main_policy}")
return lines
def build_rule_provider_entries(
*,
resolved_profile: ResolvedProfile,
base_url: str,
public_path: str,
) -> dict[str, dict]:
providers: dict[str, dict] = {}
for name, rule in resolved_profile.rules.items():
if not rule.file:
continue
providers[name] = {
"behavior": rule.behavior,
"format": rule.format,
"url": f"{base_url}/{public_path}/rules/{name}.yaml",
"interval": rule.interval,
}
return providers

View File

@@ -1,130 +1,69 @@
from __future__ import annotations
from pathlib import Path
import re
import yaml
from app.config import get_settings
from app.models import AppConfig, ClientConfig
def resolve_policy(policy: str, client: ClientConfig) -> str:
return (
policy.replace("{{ main_policy }}", client.main_policy)
.replace("{{main_policy}}", client.main_policy)
.replace("{{ direct_policy }}", client.direct_policy)
.replace("{{direct_policy}}", client.direct_policy)
)
_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$")
def load_rule_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def load_rule_payload(path: Path) -> list[str]:
if path.suffix.lower() in {".yaml", ".yml"}:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
payload = data.get("payload", [])
if not isinstance(payload, list):
raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'")
return [str(item).strip() for item in payload if str(item).strip()]
lines: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
lines.append(stripped)
return lines
def _render_payload_line(payload_line: str, behavior: str) -> str:
if "," in payload_line:
return payload_line
if behavior == "classical":
if _CIDR_PATTERN.fullmatch(payload_line):
prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR"
return f"{prefix},{payload_line}"
return f"DOMAIN-SUFFIX,{payload_line}"
if behavior == "ipcidr":
return f"IP-CIDR,{payload_line}"
if behavior == "domain":
return f"DOMAIN-SUFFIX,{payload_line}"
return payload_line
def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str:
parts = [part.strip() for part in rendered_line.split(",")]
if parts and parts[-1] == "no-resolve":
parts.insert(len(parts) - 1, target)
line = ",".join(parts)
else:
line = f"{rendered_line},{target}"
if append_no_resolve:
line += ",no-resolve"
return line
def _resolve_rule_lines(rule_name: str, app_config: AppConfig, client: ClientConfig) -> list[str]:
rule = app_config.rules[rule_name]
target = resolve_policy(rule.policy, client)
lines: list[str] = []
for payload_line in rule.payload:
rendered = _render_payload_line(payload_line, rule.behavior)
lines.append(_attach_policy(rendered, target, rule.no_resolve))
if rule.file:
line = f"RULE-SET,{rule_name},{target}"
if rule.no_resolve:
line += ",no-resolve"
lines.append(line)
return lines
from app.services.profile_resolver import resolve_profile
from app.services.rule_resolver import (
build_rule_provider_entries as build_rule_provider_entries_for_profile,
iter_resolved_rule_lines,
load_rule_payload,
load_rule_text,
resolve_policy,
)
def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str):
providers: dict[str, dict] = {}
for name, rule in app_config.rules.items():
if not rule.file:
continue
entry = {
"behavior": rule.behavior,
"format": rule.format,
"url": f"{base_url}/{public_path}/rules/{name}.yaml",
"interval": rule.interval,
}
providers[name] = entry
return providers
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
return build_rule_provider_entries_for_profile(
resolved_profile=resolved_profile,
base_url=base_url,
public_path=public_path,
)
def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]:
refs: list[str] = []
for name in app_config.rules:
refs.extend(_resolve_rule_lines(name, app_config, client))
refs.append(f"MATCH,{client.main_policy}")
return refs
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
return iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=True,
inline_file_payloads=False,
)
def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]:
settings = get_settings()
lines: list[str] = []
for name, rule in app_config.rules.items():
target = resolve_policy(rule.policy, client)
for payload_line in rule.payload:
rendered = _render_payload_line(payload_line, rule.behavior)
lines.append(_attach_policy(rendered, target, rule.no_resolve))
if not rule.file:
continue
path = (settings.rules_dir / rule.file).resolve()
if not path.is_file() or settings.rules_dir.resolve() not in path.parents:
raise FileNotFoundError(f"Rule file missing: {rule.file}")
for payload_line in load_rule_payload(path):
rendered = _render_payload_line(payload_line, rule.behavior)
lines.append(_attach_policy(rendered, target, rule.no_resolve))
lines.append(f"MATCH,{client.main_policy}")
return lines
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
return iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=False,
inline_file_payloads=True,
)
def _find_client_type(app_config: AppConfig, client: ClientConfig) -> str:
for client_type, candidate in app_config.clients.items():
if candidate == client:
return client_type
raise KeyError("client config not found in app config")
__all__ = [
"build_inline_rules",
"build_rule_provider_entries",
"build_rule_set_references",
"load_rule_payload",
"load_rule_text",
"resolve_policy",
]

1
config/app.yaml Normal file
View File

@@ -0,0 +1 @@
public_path: ${PUBLIC_PATH}

33
config/clients.yaml Normal file
View File

@@ -0,0 +1,33 @@
clients:
mihomo:
title: HomeLab Mihomo
provider_interval: 21600
rule_interval: 86400
test_url: https://www.gstatic.com/generate_204
test_interval: 300
main_policy: 🚀 节点选择
source_policy: ☁️ 机场选择
mixed_auto_policy: ♻️ 自动选择
manual_policy: 🚀 手动切换
direct_policy: DIRECT
mode: rule
allow_lan: true
ipv6: true
mixed_port: 7890
socks_port: 7891
log_level: info
stash:
title: HomeLab Stash
provider_interval: 21600
rule_interval: 86400
test_url: https://www.gstatic.com/generate_204
test_interval: 300
main_policy: 🚀 节点选择
source_policy: ☁️ 机场选择
mixed_auto_policy: ♻️ 自动选择
manual_policy: 🚀 手动切换
direct_policy: DIRECT
mode: rule
ipv6: true
log_level: info

180
config/policy-groups.yaml Normal file
View File

@@ -0,0 +1,180 @@
selector_groups:
- name: "🎥 奈飞节点"
type: select
filter: "(?i)(nf|奈飞|解锁|netflix|media)"
policy_groups:
- name: "📲 电报消息"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "💬 Ai平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇺🇲 美国节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "📹 油管视频"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "🎥 奈飞视频"
type: select
proxies:
- "🎥 奈飞节点"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "🌍 国外媒体"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "📢 谷歌"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软Bing"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软云盘"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软服务"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🍎 苹果服务"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎮 游戏平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎮 PT平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎯 全球直连"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- name: "🛑 广告拦截"
type: select
proxies:
- REJECT
- "{{ direct_policy }}"
- name: "🍃 应用净化"
type: select
proxies:
- REJECT
- "{{ direct_policy }}"
- name: "🐟 漏网之鱼"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "{{ selector_groups }}"
- "{{ manual_policy }}"

25
config/regions.yaml Normal file
View File

@@ -0,0 +1,25 @@
regions:
hk:
name: "🇭🇰 香港节点"
filter: "(?i)(港|hk|hong kong|hongkong)"
tolerance: 50
tw:
name: "🇨🇳 台湾节点"
filter: "(?i)(台|新北|彰化|tw|taiwan)"
tolerance: 50
sg:
name: "🇸🇬 狮城节点"
filter: "(?i)(新加坡|坡|狮城|sg|singapore)"
tolerance: 50
jp:
name: "🇯🇵 日本节点"
filter: "(?i)(日本|东京|大阪|埼玉|jp|japan)"
tolerance: 50
us:
name: "🇺🇲 美国节点"
filter: "(?i)(美|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|us|united states)"
tolerance: 150
kr:
name: "🇰🇷 韩国节点"
filter: "(?i)(kr|korea|kor|首尔|韩|韓)"
tolerance: 50

210
config/rules.yaml Normal file
View File

@@ -0,0 +1,210 @@
rules:
custom-proxy:
behavior: classical
format: text
policy: "{{ main_policy }}"
payload:
- DOMAIN-KEYWORD,cloudflare
- DOMAIN-KEYWORD,hetzner
- DOMAIN-KEYWORD,hdkylin
- DOMAIN-KEYWORD,steamusercontent
- DOMAIN-KEYWORD,steamcontent
- DOMAIN-KEYWORD,nintendo
- DOMAIN-KEYWORD,vscode
- DOMAIN-KEYWORD,btschool
local-network:
file: acl4ssr/LocalAreaNetwork.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
unban:
file: acl4ssr/UnBan.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
reject:
file: acl4ssr/BanAD.yaml
behavior: classical
format: yaml
interval: 86400
policy: "🛑 广告拦截"
app-purify:
file: acl4ssr/BanProgramAD.yaml
behavior: classical
format: yaml
interval: 86400
policy: "🍃 应用净化"
google:
file: acl4ssr/Google.yaml
behavior: classical
format: yaml
policy: "📢 谷歌"
interval: 86400
google-cn:
file: acl4ssr/GoogleCN.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
steam-cn:
file: acl4ssr/SteamCN.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
microsoft-bing:
file: acl4ssr/Bing.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软Bing"
interval: 86400
microsoft-onedrive:
file: acl4ssr/OneDrive.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软云盘"
interval: 86400
microsoft:
file: acl4ssr/Microsoft.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软服务"
interval: 86400
apple:
file: acl4ssr/Apple.yaml
behavior: classical
format: yaml
policy: "🍎 苹果服务"
interval: 86400
telegram:
file: acl4ssr/Telegram.yaml
behavior: classical
format: yaml
policy: "📲 电报消息"
interval: 86400
ai:
file: acl4ssr/AI.yaml
behavior: classical
format: yaml
policy: "💬 Ai平台"
interval: 86400
openai:
file: acl4ssr/OpenAi.yaml
behavior: classical
format: yaml
policy: "💬 Ai平台"
interval: 86400
youtube:
file: acl4ssr/YouTube.yaml
behavior: classical
format: yaml
policy: "📹 油管视频"
interval: 86400
netflix:
file: acl4ssr/Netflix.yaml
behavior: classical
format: yaml
policy: "🎥 奈飞视频"
interval: 86400
proxy-media:
file: acl4ssr/ProxyMedia.yaml
behavior: classical
format: yaml
policy: "🌍 国外媒体"
interval: 86400
games-epic:
file: acl4ssr/Epic.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-origin:
file: acl4ssr/Origin.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-sony:
file: acl4ssr/Sony.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-steam:
file: acl4ssr/Steam.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-nintendo:
file: acl4ssr/Nintendo.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
pt:
file: acl4ssr/PrivateTracker.yaml
behavior: classical
format: yaml
policy: "🎮 PT平台"
interval: 86400
cn-domain:
file: acl4ssr/ChinaDomain.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
cn-company-ip:
file: acl4ssr/ChinaCompanyIp.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
download:
file: acl4ssr/Download.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
proxy-gfw:
file: acl4ssr/ProxyGFWlist.yaml
behavior: classical
format: yaml
policy: "{{ main_policy }}"
interval: 86400
geoip-cn:
behavior: classical
format: text
policy: "🎯 全球直连"
payload:
- GEOIP,CN

View File

@@ -1,483 +1,28 @@
public_path: ${PUBLIC_PATH}
airport-a:
enabled: true
display_name: A
kind: auto
url: ${AIRPORT_A_URL}
prefix: "[A] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
sources:
airport-a:
enabled: true
display_name: A
kind: auto
url: ${AIRPORT_A_URL}
prefix: "[A] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
airport-b:
enabled: true
display_name: B
kind: auto
url: ${AIRPORT_B_URL}
headers:
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
prefix: "[B] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
airport-b:
enabled: true
display_name: B
kind: auto
url: ${AIRPORT_B_URL}
headers:
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
prefix: "[B] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
airport-c:
enabled: true
display_name: C
kind: auto
url: ${AIRPORT_C_URL}
prefix: "[C] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
regions:
hk:
name: "🇭🇰 香港节点"
filter: "(?i)(港|hk|hong kong|hongkong)"
tolerance: 50
tw:
name: "🇨🇳 台湾节点"
filter: "(?i)(台|新北|彰化|tw|taiwan)"
tolerance: 50
sg:
name: "🇸🇬 狮城节点"
filter: "(?i)(新加坡|坡|狮城|sg|singapore)"
tolerance: 50
jp:
name: "🇯🇵 日本节点"
filter: "(?i)(日本|东京|大阪|埼玉|jp|japan)"
tolerance: 50
us:
name: "🇺🇲 美国节点"
filter: "(?i)(美|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|us|united states)"
tolerance: 150
kr:
name: "🇰🇷 韩国节点"
filter: "(?i)(kr|korea|kor|首尔|韩|韓)"
tolerance: 50
selector_groups:
- name: "🎥 奈飞节点"
type: select
filter: "(?i)(nf|奈飞|解锁|netflix|media)"
policy_groups:
- name: "📲 电报消息"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "💬 Ai平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇺🇲 美国节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "📹 油管视频"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "🎥 奈飞视频"
type: select
proxies:
- "🎥 奈飞节点"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇸🇬 狮城节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "🌍 国外媒体"
type: select
proxies:
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- "{{ direct_policy }}"
- name: "📢 谷歌"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软Bing"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软云盘"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "Ⓜ️ 微软服务"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🍎 苹果服务"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎮 游戏平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇺🇲 美国节点"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎮 PT平台"
type: select
proxies:
- "{{ main_policy }}"
- "{{ direct_policy }}"
- "🇭🇰 香港节点"
- "🇨🇳 台湾节点"
- "🇸🇬 狮城节点"
- "🇯🇵 日本节点"
- "🇺🇲 美国节点"
- "🇰🇷 韩国节点"
- "{{ manual_policy }}"
- name: "🎯 全球直连"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- name: "🛑 广告拦截"
type: select
proxies:
- REJECT
- "{{ direct_policy }}"
- name: "🍃 应用净化"
type: select
proxies:
- REJECT
- "{{ direct_policy }}"
- name: "🐟 漏网之鱼"
type: select
proxies:
- "{{ direct_policy }}"
- "{{ main_policy }}"
- "{{ mixed_auto_policy }}"
- "{{ selector_groups }}"
- "{{ manual_policy }}"
rules:
custom-proxy:
behavior: classical
format: text
policy: "{{ main_policy }}"
payload:
- DOMAIN-KEYWORD,cloudflare
- DOMAIN-KEYWORD,hetzner
- DOMAIN-KEYWORD,hdkylin
- DOMAIN-KEYWORD,steamusercontent
- DOMAIN-KEYWORD,steamcontent
- DOMAIN-KEYWORD,nintendo
- DOMAIN-KEYWORD,vscode
- DOMAIN-KEYWORD,btschool
local-network:
file: acl4ssr/LocalAreaNetwork.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
unban:
file: acl4ssr/UnBan.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
reject:
file: acl4ssr/BanAD.yaml
behavior: classical
format: yaml
interval: 86400
policy: "🛑 广告拦截"
app-purify:
file: acl4ssr/BanProgramAD.yaml
behavior: classical
format: yaml
interval: 86400
policy: "🍃 应用净化"
google:
file: acl4ssr/Google.yaml
behavior: classical
format: yaml
policy: "📢 谷歌"
interval: 86400
google-cn:
file: acl4ssr/GoogleCN.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
steam-cn:
file: acl4ssr/SteamCN.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
microsoft-bing:
file: acl4ssr/Bing.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软Bing"
interval: 86400
microsoft-onedrive:
file: acl4ssr/OneDrive.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软云盘"
interval: 86400
microsoft:
file: acl4ssr/Microsoft.yaml
behavior: classical
format: yaml
policy: "Ⓜ️ 微软服务"
interval: 86400
apple:
file: acl4ssr/Apple.yaml
behavior: classical
format: yaml
policy: "🍎 苹果服务"
interval: 86400
telegram:
file: acl4ssr/Telegram.yaml
behavior: classical
format: yaml
policy: "📲 电报消息"
interval: 86400
ai:
file: acl4ssr/AI.yaml
behavior: classical
format: yaml
policy: "💬 Ai平台"
interval: 86400
openai:
file: acl4ssr/OpenAi.yaml
behavior: classical
format: yaml
policy: "💬 Ai平台"
interval: 86400
youtube:
file: acl4ssr/YouTube.yaml
behavior: classical
format: yaml
policy: "📹 油管视频"
interval: 86400
netflix:
file: acl4ssr/Netflix.yaml
behavior: classical
format: yaml
policy: "🎥 奈飞视频"
interval: 86400
proxy-media:
file: acl4ssr/ProxyMedia.yaml
behavior: classical
format: yaml
policy: "🌍 国外媒体"
interval: 86400
games-epic:
file: acl4ssr/Epic.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-origin:
file: acl4ssr/Origin.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-sony:
file: acl4ssr/Sony.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-steam:
file: acl4ssr/Steam.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
games-nintendo:
file: acl4ssr/Nintendo.yaml
behavior: classical
format: yaml
policy: "🎮 游戏平台"
interval: 86400
pt:
file: acl4ssr/PrivateTracker.yaml
behavior: classical
format: yaml
policy: "🎮 PT平台"
interval: 86400
cn-domain:
file: acl4ssr/ChinaDomain.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
cn-company-ip:
file: acl4ssr/ChinaCompanyIp.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
download:
file: acl4ssr/Download.yaml
behavior: classical
format: yaml
policy: "🎯 全球直连"
interval: 86400
proxy-gfw:
file: acl4ssr/ProxyGFWlist.yaml
behavior: classical
format: yaml
policy: "{{ main_policy }}"
interval: 86400
geoip-cn:
behavior: classical
format: text
policy: "🎯 全球直连"
payload:
- GEOIP,CN
clients:
mihomo:
title: HomeLab Mihomo
provider_interval: 21600
rule_interval: 86400
test_url: https://www.gstatic.com/generate_204
test_interval: 300
main_policy: 🚀 节点选择
source_policy: ☁️ 机场选择
mixed_auto_policy: ♻️ 自动选择
manual_policy: 🚀 手动切换
direct_policy: DIRECT
mode: rule
allow_lan: true
ipv6: true
mixed_port: 7890
socks_port: 7891
log_level: info
stash:
title: HomeLab Stash
provider_interval: 21600
rule_interval: 86400
test_url: https://www.gstatic.com/generate_204
test_interval: 300
main_policy: 🚀 节点选择
source_policy: ☁️ 机场选择
mixed_auto_policy: ♻️ 自动选择
manual_policy: 🚀 手动切换
direct_policy: DIRECT
mode: rule
ipv6: true
log_level: info
airport-c:
enabled: true
display_name: C
kind: auto
url: ${AIRPORT_C_URL}
prefix: "[C] "
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"

View File

@@ -10,5 +10,5 @@ services:
- .env
volumes:
- ./config:/app/config:ro
- ./data:/app/data
- ${APP_DATA_DIR:-../sub-provider-data}:/app/data
- ./output:/app/output

View File

@@ -0,0 +1,686 @@
# sub-provider 分阶段重构方案
## 1. 文档目标
这份文档不是重新定义“最终想做成什么”,而是基于当前 `sub-provider` 已有实现,给出一版可逐步落地、每阶段都能交付可运行结果的重构路径。
当前项目已经具备以下基础能力:
* 读取 `config/sources.yaml`
* 拉取和缓存上游订阅
* 解析 Clash YAML / base64 URI / 明文 URI 订阅
* 合并节点并生成 bundle YAML
* 生成 thin client 配置
* 加载本地规则文件并拼接 `rules`
* 透传第一个源的 `Subscription-Userinfo`
因此重构的目标不是“从零搭系统”,而是把现有静态配置驱动的生成器,逐步改造成:
* 内部模块化更清晰
* 配置存储可演进到数据库
* 对外仍以完整 YAML 为主
* 保留现有已验证能力
* 后续可平滑加管理页和快照系统
---
## 2. 总体原则
### 原则 1对外接口尽量稳定
在重构前几阶段,尽量不打断现有输出能力:
* `/bundle/{client}.yaml`
* `/clients/{client}.yaml`
* `/providers/{name}.yaml`
* `/providers/merged.yaml`
即使最终主路线偏向 bundle也不建议在早期重构中直接删除 thin/provider 能力。
### 原则 2先拆“配置来源”再拆“表现层”
当前项目的核心问题不是生成逻辑完全不可用,而是配置全部堆在 `sources.yaml` 里。
优先级应是:
1. 把配置从单一 YAML 拆到更清晰的数据结构
2. 再引入数据库承载配置
3. 最后再补管理页面
不要一上来把大量时间花在页面上。
### 原则 3规则正文继续文件化
规则文件仍然保存在项目目录中,继续使用 Git 管理。
数据库只存:
* 规则模块元数据
* 规则启用关系
* 规则顺序
* 规则绑定到哪个策略组
不把 ACL4SSR 规则逐条拆进数据库。
### 原则 4保留动态选源能力
当前项目支持通过 `?sources=` 在请求级动态组合源,这个能力实际很有价值。
重构后不建议退化成只能在 profile 中静态绑定源。
建议模型上同时支持:
* profile 默认源集合
* 请求级 `sources` override
---
## 3. 对现有项目的基线判断
### 3.1 现状优点
当前代码结构虽然还偏轻量,但核心职责已经初步分开:
* `subscriptions.py` 负责源抓取、解析、节点转换
* `profiles.py` 负责 thin / bundle 配置组装
* `rules.py` 负责规则文件加载和规则输出
* `bundle_cache.py` / `fetch_cache.py` 负责磁盘缓存
这意味着重构时应该优先“抽象和替换内部数据来源”,而不是推倒服务层。
### 3.2 现状主要问题
当前主要限制有:
* `sources.yaml` 同时承载源配置、地区分组、业务策略组、规则绑定、客户端配置
* 配置模型偏静态,不利于面板管理
* bundle 缓存仍是 TTL 模式,不是内容指纹模式
* 没有配置快照、生成快照、审计能力
* 当前没有数据库层,也没有迁移体系
### 3.3 结论
最稳的路线不是“大重写”,而是:
1. 保持现有生成骨架
2. 把配置与渲染逻辑进一步解耦
3. 再把配置落进数据库
4. 最后加面板和快照
---
## 4. 目标架构
推荐演进成如下结构:
```text
请求
-> API 路由
-> Profile 解析
-> Source 选择
-> Source 拉取 / 缓存
-> 节点解析 / 标准化 / 处理
-> 策略组装配
-> 规则模块解析
-> 完整 YAML 渲染
-> 结果缓存 / 快照
-> 返回响应头与 YAML
```
配置来源则逐步从:
```text
sources.yaml
```
演进为:
```text
数据库配置 + 本地规则资产文件 + 少量默认 YAML 模板
```
---
## 5. 分阶段路线
建议拆成五个阶段,每个阶段结束都能保留一个稳定可运行版本。
### 阶段 0重构准备与基线冻结
目标:
* 不改行为,先明确现有能力边界
* 为后续重构建立可回归的基线
本阶段任务:
1. 盘点当前公开接口、查询参数、响应头语义
2. 记录当前 `sources.yaml` 中各配置块的职责
3. 为核心生成路径补最小测试
4. 输出一份“当前行为基线”文档
验收标准:
* 至少覆盖以下场景:
* 单源 provider
* 多源 merged provider
* bundle 输出
* thin 输出
* 第一个源配额头透传
* 有一组固定输入可验证当前输出结构不被意外破坏
建议产物:
* `tests/` 下最小回归测试
* `docs/当前行为基线.md`
说明:
这一阶段很重要。没有基线,后面数据库化后很难判断是“重构带来的结构变化”,还是“偷偷改了行为”。
---
### 阶段 1配置解耦但仍以 YAML 为配置源
目标:
* 先把“一个巨大的 `sources.yaml`”拆成多个清晰模块
* 暂时不引入数据库
* 不改变线上运行方式
本阶段任务:
1. 拆分当前 `sources.yaml`
* `config/sources.yaml`
* `config/clients.yaml`
* `config/rule-bindings.yaml`
* `config/policy-groups.yaml`
* `config/regions.yaml`
2. 新增统一配置加载层,把多个 YAML 聚合成当前运行所需的内部配置对象
3. 把当前 `AppConfig` 进一步拆开,减少“大一统模型”
4.`main.py` 不再直接依赖单个全局 `app_config`
5. 把“配置加载”和“配置解释”职责从“服务逻辑”中剥离
建议目录演进:
```text
app/
config.py
main.py
models/
source.py
client.py
profile.py
rule.py
group.py
services/
config_loader.py
subscriptions.py
profiles.py
rules.py
```
验收标准:
* 对外接口路径和行为保持不变
* `bundle``thin` 输出内容结构基本一致
* 已不再依赖单个巨型 YAML 文件
这一阶段收益:
* 先把系统结构理顺
* 后续接数据库时,只需要替换配置仓储层,不需要大改生成器本身
---
### 阶段 2引入数据库配置层保留 YAML 兼容导入
目标:
* 让配置从静态文件迁移到数据库
* 但仍支持从 YAML 导入初始数据
本阶段任务:
1. 引入 SQLAlchemy 与 Alembic
2. 增加 `DATABASE_URL`
3. 建立第一批核心表
4. 实现配置仓储层
5. 提供 YAML 初始化导入脚本
推荐第一批表:
* `sources`
* `profiles`
* `profile_sources`
* `rule_modules`
* `profile_rule_modules`
* `policy_groups`
* `profile_overrides`
推荐暂不引入的表:
* `generated_artifacts`
* `sync_logs`
* `rule_module_versions`
原因:
先把“当前运行配置”迁过去,比一开始就把快照、审计、版本全部做全更稳。
#### 推荐字段设计
`sources`
* `id`
* `key`
* `name`
* `enabled`
* `kind`
* `url`
* `display_name`
* `headers_json`
* `include_regex`
* `exclude_regex`
* `prefix`
* `suffix`
* `cache_ttl_seconds`
* `created_at`
* `updated_at`
`profiles`
* `id`
* `key`
* `name`
* `client_type`
* `description`
* `enabled`
* `allow_lan`
* `ipv6`
* `mixed_port`
* `socks_port`
* `mode`
* `log_level`
* `main_policy`
* `source_policy`
* `mixed_auto_policy`
* `manual_policy`
* `direct_policy`
* `test_url`
* `test_interval`
* `provider_interval`
* `rule_interval`
* `created_at`
* `updated_at`
`profile_sources`
* `id`
* `profile_id`
* `source_id`
* `order_index`
* `enabled`
`rule_modules`
* `id`
* `key`
* `name`
* `description`
* `file_path`
* `behavior`
* `format`
* `default_policy`
* `default_no_resolve`
* `default_payload_json`
* `category`
* `created_at`
* `updated_at`
`profile_rule_modules`
* `id`
* `profile_id`
* `rule_module_id`
* `enabled`
* `order_index`
* `policy_override`
* `payload_override_json`
* `no_resolve_override`
`policy_groups`
* `id`
* `profile_id`
* `name`
* `group_kind`
* `type`
* `order_index`
* `filter_regex`
* `proxies_json`
* `url`
* `interval`
* `tolerance`
* `enabled`
这里的 `group_kind` 建议至少支持:
* `static`
* `filter`
* `generated`
这比单纯用一个 `config_json` 更适合当前项目,因为现有组装里同时存在静态组和按 regex 选节点的组。
#### 仓储层建议
新增仓储抽象,例如:
* `SourceRepository`
* `ProfileRepository`
* `RuleModuleRepository`
* `PolicyGroupRepository`
让生成服务只依赖仓储接口,而不是依赖数据库实现细节。
验收标准:
* 使用数据库也能生成与阶段 1 基本一致的 bundle/thin 配置
* 可以从原 YAML 导入初始配置
* 切换配置来源时,渲染层无需大改
---
### 阶段 3渲染流水线模块化与输出主路线收敛
目标:
* 把“源处理、规则处理、组装渲染”彻底做成稳定流水线
* 明确 bundle 是主输出路线
* thin/provider 进入兼容保留状态
本阶段任务:
1. 拆分服务层职责:
* `source_fetcher.py`
* `source_parser.py`
* `proxy_processor.py`
* `policy_group_builder.py`
* `rule_resolver.py`
* `profile_renderer.py`
2. 引入统一内部节点模型
3. 引入统一内部“已解析 profile”模型
4. 将当前 `build_bundle_profile()` 的逻辑拆成多步处理
5. 清理当前 token 展开逻辑,明确保留哪些模板 token
#### 内部模型建议
不要把节点模型收得太死。建议:
```python
class ProxyNode(BaseModel):
name: str
type: str
server: str | None = None
port: int | None = None
udp: bool = True
tags: list[str] = Field(default_factory=list)
attrs: dict[str, Any] = Field(default_factory=dict)
```
其中:
* 通用字段单独保留
* 协议特有字段放 `attrs`
* 最终输出时再按协议合并回 YAML 结构
这样比单纯的 `raw` 更可控。
#### 规则系统建议
这一阶段不要急着把所有规则文件改成 `{{ target_group }}` 模板格式。
先保留当前模式:
* 文件内容仍是 payload
* policy 在 profile 绑定关系上决定
原因:
* 当前 thin 模式的 `rule-providers` 仍依赖 payload 文件输出
* 先保留兼容性,后面再决定是否升级规则文件模板化
验收标准:
* bundle 渲染主路径清晰可测
* 服务层之间依赖方向清楚
* 配置仓储和渲染器分离
---
### 阶段 4管理接口与最小面板
目标:
* 提供最小配置管理能力
* 不做复杂前后端分离
本阶段任务:
1. 增加管理 API
* source CRUD
* profile CRUD
* rule module 查看与 profile 绑定管理
* policy group 查看与编辑
2. 增加预览接口
3. 增加最小页面:
* sources
* profiles
* rule bindings
* preview
4. 提供 profile 级 bundle 下载入口
建议接口:
* `GET /api/sources`
* `POST /api/sources`
* `PUT /api/sources/{id}`
* `GET /api/profiles`
* `POST /api/profiles`
* `PUT /api/profiles/{id}`
* `GET /api/profiles/{id}/rules`
* `PUT /api/profiles/{id}/rules`
* `GET /api/profiles/{id}/groups`
* `PUT /api/profiles/{id}/groups`
* `POST /api/profiles/{id}/preview`
* `GET /profiles/{key}/bundle.yaml`
面板技术建议:
* Jinja2
* HTMX 或最少量原生 JS
不建议此时做:
* React/Vue 前后端分离
* 复杂权限系统
* 在线全文规则编辑器
验收标准:
* 不改代码即可新增一个 source
* 不改 YAML 文件即可调整 profile 规则顺序
* 页面可直接预览 bundle 输出
---
### 阶段 5快照、缓存升级与可追踪性
目标:
* 从“能生成”升级到“可追踪、可复现、可排查”
本阶段任务:
1. 增加 `generated_artifacts`
2. 增加内容指纹缓存
3. 增加源同步状态记录
4. 增加简单 diff 能力
5. 增加错误展示
推荐新增表:
`generated_artifacts`
* `id`
* `profile_id`
* `request_sources_json`
* `content`
* `content_hash`
* `source_hash`
* `rules_hash`
* `profile_hash`
* `headers_json`
* `generated_at`
`source_sync_logs`
* `id`
* `source_id`
* `status`
* `error_message`
* `response_headers_json`
* `content_hash`
* `created_at`
#### 缓存策略建议
当前项目 bundle 缓存是 TTL 模式。后续应逐步改为:
* fetch cache: TTL
* parsed snapshot cache: 源内容 hash
* bundle cache: `source_hash + rules_hash + profile_hash + request_sources`
这样才能真正做到:
* 配置没变就稳定复用
* 配置变了就精准失效
验收标准:
* 能查看某个 profile 最近几次生成记录
* 能知道本次 bundle 为什么重新生成
* 能追踪某个源最近一次同步是否失败
---
## 6. 推荐实施顺序
如果按投入产出比排序,建议实际开发顺序如下:
1. 阶段 0
2. 阶段 1
3. 阶段 2
4. 阶段 3
5. 阶段 4
6. 阶段 5
其中真正的“第一版可交付里程碑”建议定在阶段 2 结束时。
因为到了阶段 2已经具备
* 数据库存储配置
* 可继续生成完整 YAML
* 配置不再绑死在单文件 YAML 中
这时即使还没有面板,系统内核已经完成了最关键升级。
---
## 7. 每阶段风险点
### 阶段 1 风险
风险:
* 拆 YAML 时容易出现字段兼容问题
控制方式:
* 先保留兼容加载层
* 老配置格式在一段时间内继续可读
### 阶段 2 风险
风险:
* 数据建模过度,导致表过多、实现过重
控制方式:
* 第一批只建运行必需表
* 快照与日志延后
### 阶段 3 风险
风险:
* 过早重写规则系统,导致 thin 模式兼容性下降
控制方式:
* 先保留 payload 模式
* 规则模板化放后续增量处理
### 阶段 4 风险
风险:
* 过早投入页面开发,拖慢主流程
控制方式:
* 先 API 后页面
* 页面只做最小可用
### 阶段 5 风险
风险:
* 快照和缓存逻辑做得太复杂,维护成本上升
控制方式:
* 先做内容哈希与生成记录
* diff 与审计能力逐步补
---
## 8. 明确不建议的做法
1. 不建议一开始就删除当前 thin/provider 能力
2. 不建议一开始就把所有规则文件模板化
3. 不建议把 profile 的源集合只做成一个 `source_ids_json`
4. 不建议把策略组全塞进一个不透明 `config_json`
5. 不建议先做复杂前端再补业务内核
6. 不建议先做快照系统再做数据库配置层
---
## 9. 建议的第一批实际改动
如果下一步开始真正动代码,建议先做这几件事:
1. 新建阶段 0 的测试和基线文档
2. 拆分 `sources.yaml`,完成阶段 1
3. 引入 SQLAlchemy/Alembic 和最小表结构
4. 增加 YAML -> DB 导入脚本
5. 让 bundle 渲染从数据库读取 profile / source / rules 配置
这五步完成后,再去做页面,整体节奏会稳很多。
---
## 10. 一句话结论
`sub-provider` 的重构最稳路线是:
> 先做配置解耦,再做数据库配置层,再做渲染流水线固化,最后补管理页面和快照系统。
这样每一阶段都能保留一个可运行版本,也最符合当前项目已经有代码基础的现实情况。

976
docs/改造方案.md Normal file
View File

@@ -0,0 +1,976 @@
下面这份你可以直接丢给 Codex。
我把我们刚刚聊出来的**最终拍板结论**和**完整实现方案**都整理进去了。
---
# sub-provider 重构方案(最终版,完整 YAML 路线)
## 1. 最终结论
本项目后续重构,采用以下最终选择:
### 对外输出
* **统一输出单个完整 YAML**
* 不强依赖客户端支持 `rule-providers` / `proxy-providers`
* 目标是优先保证:
* 兼容性
* 可调试性
* 易分发
* 易维护
### 对内实现
* 内部仍然采用**模块化**设计
* 在 sub-provider 内部完成:
* 源拉取与缓存
* 节点解析与标准化
* 节点筛选、重命名、去重
* 规则模块组合
* 策略组装配
* DNS / TUN / 通用配置拼装
* 最终完整 YAML 渲染
### 规则存储方式
* **规则正文继续放项目文件**
* 采用类似 ACL4SSR 的维护方式:
* 一个类别的规则放一个文件
* 规则文件便于 Git 管理、diff、回滚、手工维护
* 不把 ACL4SSR 大量规则逐条入库
### 规则组合与面板配置
* **规则的组合关系、启用状态、绑定的策略组、顺序、参数等放 SQLite**
* 面板配置的是:
* 选哪些规则模块
* 每个模块绑定哪个策略组
* 模块顺序
* 模块参数
* profile 配置
* 源配置
* 少量 prepend / append 自定义规则
### 数据库选择
* **默认数据库SQLite**
* 原因:
* 当前项目是单服务、轻量配置中心、生成器型应用
* SQLite 部署和迁移成本最低
* 最适合当前阶段快速落地
* 代码层要保留未来切 PostgreSQL 的能力:
* 使用 SQLAlchemy
* 使用 Alembic
*`DATABASE_URL` 配置数据库连接
* 当前不推荐 Mongo 作为主库
* 当前不优先推荐 MySQL
---
## 2. 目标
将现有 Python sub-provider 项目重构为一个:
* 可面板管理
* 配置解耦
* 规则模块化
* 输出完整 YAML
* 可缓存
* 可预览
* 可追踪生成结果
的配置生成系统。
---
## 3. 项目边界
### 本阶段要做
1. 面板化管理订阅源
2. 规则模块化
3. 规则组合关系数据库化
4. 支持多个输出 Profile
5. 服务端拼装生成完整 YAML
6. 做缓存和快照
7. 支持预览和下载
### 本阶段不做
1. 不强制对外输出 provider 模式
2. 不做“在线全文编辑 ACL4SSR 大规则文件”为主交互方式
3. 不做复杂多用户权限系统
4. 不做重型前后端分离
5. 不把所有规则全文放 SQLite
6. 不引入 Mongo 做主存储
---
## 4. 架构原则
### 原则 1外部简单内部灵活
客户端只拿一个完整 YAML。
服务端内部怎么拆、怎么缓存、怎么组合,都由 sub-provider 负责。
### 原则 2规则内容文件化组合关系数据库化
规则正文属于“资产”,继续放文件。
面板管理的是“配置和组合关系”,放 SQLite。
### 原则 390% 的配置修改通过“选模块 + 改参数”完成
不要把面板做成“大文本框配置编辑器”。
### 原则 4优先可维护而不是一开始就追求炫技
先把结构跑顺,再考虑高级模式。
---
## 5. 总体架构
```text
输入源
-> 拉取缓存
-> 原始内容缓存
-> 解析节点
-> 节点标准化
-> 节点处理(过滤/去重/重命名/分类)
-> 策略组生成
-> 规则模块加载
-> 规则模块参数渲染
-> 规则按顺序拼接
-> 插入 prepend/append 自定义规则
-> 渲染完整 YAML
-> 结果缓存 / 生成快照
-> 提供下载 / 预览 / HEAD 信息
```
---
## 6. 推荐技术栈
### 后端
* FastAPI
### 数据库
* SQLite默认
* SQLAlchemy ORM
* Alembic migration
### 模板
* Jinja2
### 前端
* 优先服务端渲染
* 可用 Jinja2 + HTMX 或简单模板页
* 先不要求 React/Vue 前后端分离
### 缓存
* 轻量场景下可先用:
* SQLite 表
* 本地文件缓存
* 不必一上来引入 Redis
---
## 7. 目录结构建议
```text
app/
api/
routes_sources.py
routes_profiles.py
routes_rules.py
routes_preview.py
core/
config.py
database.py
cache.py
db/
base.py
session.py
models/
source.py
profile.py
rule_module.py
policy_group.py
artifact.py
schemas/
source.py
profile.py
rule_module.py
services/
source_fetcher.py
source_parser.py
proxy_normalizer.py
proxy_processor.py
policy_group_builder.py
rule_loader.py
rule_renderer.py
profile_renderer.py
artifact_service.py
templates/
base.html
sources.html
profiles.html
rules.html
preview.html
main.py
rules/
modules/
lan.list
private.list
cn_domain.list
cn_ip.list
apple.list
microsoft.list
github.list
telegram.list
openai.list
streaming.list
ads.list
final_proxy.list
final_direct.list
presets/
minimal.yaml
daily.yaml
router.yaml
group_templates/
basic.yaml
ai.yaml
streaming.yaml
data/
app.db
cache/
raw_sources/
parsed_sources/
generated/
artifacts/
migrations/
tests/
```
---
## 8. 规则系统设计
## 8.1 规则正文:继续放文件
规则正文文件继续按 ACL4SSR 风格维护。
例如:
* `rules/modules/apple.list`
* `rules/modules/openai.list`
* `rules/modules/telegram.list`
* `rules/modules/cn_domain.list`
* `rules/modules/final_proxy.list`
### 推荐格式
规则文件使用模板变量,不直接写死策略组:
```text
DOMAIN-SUFFIX,openai.com,{{ target_group }}
DOMAIN-SUFFIX,chatgpt.com,{{ target_group }}
DOMAIN-SUFFIX,oaistatic.com,{{ target_group }}
DOMAIN-SUFFIX,auth0.openai.com,{{ target_group }}
```
这样同一个规则模块可复用到不同策略组。
---
## 8.2 规则模块:放 SQLite
SQLite 里存规则模块元数据,而不是规则全文。
### 表:`rule_modules`
字段建议:
* `id`
* `key`
* `name`
* `description`
* `file_path`
* `category`
* `default_enabled`
* `default_order`
* `default_target_policy`
* `params_schema_json`
* `created_at`
* `updated_at`
### 示例
```json
{
"key": "openai",
"name": "OpenAI",
"file_path": "rules/modules/openai.list",
"category": "service",
"default_enabled": true,
"default_order": 80,
"default_target_policy": "🤖 AI",
"params_schema_json": {
"target_group": {
"type": "string",
"default": "🤖 AI"
}
}
}
```
---
## 8.3 Profile 中的规则组合:放 SQLite
### 表:`profile_rule_modules`
字段建议:
* `id`
* `profile_id`
* `module_id`
* `enabled`
* `order_index`
* `target_policy`
* `params_json`
含义:
* 这个 profile 是否启用这个模块
* 顺序是什么
* 最终绑定到哪个策略组
* 参数是什么
---
## 8.4 规则预设
可选支持“预设”。
例如:
* `minimal`
* `daily`
* `router`
但预设只作为**初始化模板**,最终仍落到具体 profile 配置里。
不要求上线初期就把预设做得很复杂。
---
## 9. 策略组系统设计
策略组不要硬编码死在一个大 YAML 模板里。
也要做成可配置对象。
### 表:`policy_groups`
字段建议:
* `id`
* `profile_id`
* `name`
* `type`
* `order_index`
* `config_json`
### `config_json` 示例
```json
{
"proxies": ["DIRECT", "REJECT", "香港节点", "日本节点"],
"include_all_nodes": false,
"filter": "HK|Hong Kong"
}
```
### 支持的策略组类型
* `select`
* `url-test`
* `fallback`
* `load-balance`
### 建议的默认组
* `🚀 节点选择`
* `🤖 AI`
* `📺 流媒体`
* `🍎 苹果服务`
* `📲 Telegram`
* `🌍 国外网站`
* `🇨🇳 国内网站`
---
## 10. 源管理设计
### 表:`sources`
字段建议:
* `id`
* `name`
* `type`
值可为:
* `base64`
* `url`
* `link`
* `static`
* `content`
* `headers_json`
* `enabled`
* `priority`
* `update_interval_sec`
* `rename_rules_json`
* `filter_rules_json`
* `dedupe_policy`
* `last_sync_at`
* `last_sync_status`
* `last_error`
* `created_at`
* `updated_at`
### 说明
这里的源是原始输入源,不是给客户端的 provider。
---
## 11. Profile 设计
Profile 是最终对外输出的配置单元。
例如:
* iPhone 配置
* Windows 配置
* OpenWrt 配置
* Apple TV 配置
* 精简版配置
* 完整版配置
### 表:`profiles`
字段建议:
* `id`
* `name`
* `description`
* `source_ids_json`
* `dns_template`
* `tun_enabled`
* `udp_enabled`
* `append_subscription_info`
* `expose_head_info`
* `enabled`
* `created_at`
* `updated_at`
---
## 12. 自定义覆盖设计
为了兼容高级用户需求,支持少量覆盖,但不做全文编辑器。
### 表:`profile_overrides`
字段建议:
* `id`
* `profile_id`
* `custom_rules_prepend`
* `custom_rules_append`
* `custom_proxy_groups`
* `custom_dns_patch`
* `custom_yaml_patch`
### 原则
只提供:
* prepend rules
* append rules
* 少量 patch
不鼓励直接在面板里手写全部 YAML。
---
## 13. 缓存设计
## 13.1 原始源缓存
缓存拉回来的原始内容。
目的:
* 避免频繁请求订阅源
* 降低上游压力
* 出问题可复现
---
## 13.2 解析结果缓存
把原始内容解析为统一节点模型后缓存。
目的:
* 多 profile 复用解析结果
* 避免重复解析
---
## 13.3 最终 YAML 缓存
同一个 profile 在以下内容不变时,直接返回缓存:
* 订阅源内容未变
* profile 配置未变
* 规则模块未变
* 策略组未变
缓存键建议基于 hash
* 源快照 hash
* 规则快照 hash
* profile 配置 hash
---
## 14. 生成快照设计
### 表:`generated_artifacts`
字段建议:
* `id`
* `profile_id`
* `version`
* `content`
* `content_hash`
* `source_snapshot_json`
* `rule_snapshot_json`
* `generated_at`
### 用途
* 预览
* diff
* 回滚
* 调试
* 追踪“为什么这次生成结果变了”
---
## 15. 内部统一节点模型
建议在解析层统一成一个内部对象。
### Python 草案
```python
from pydantic import BaseModel
from typing import Any
class ProxyNode(BaseModel):
name: str
type: str
server: str
port: int
udp: bool = True
tags: list[str] = []
raw: dict[str, Any] = {}
```
对不同类型协议ss、vmess、vless、trojan、hysteria 等)额外字段可放在 `raw` 里。
---
## 16. 生成流水线
## 第 1 步:拉取源
* 读取启用的 sources
* 获取原始订阅内容
* 更新缓存
* 记录同步状态
## 第 2 步:解析源
* 解析 base64
* 解析单链接
* 解析远程订阅
* 标准化成统一节点模型
## 第 3 步:节点处理
* 去重
* 重命名
* 按国家/协议/标签分类
* 过滤无效节点
* 应用 profile 的筛选逻辑
## 第 4 步:生成策略组
* 读取 profile 绑定的 policy groups
* 根据节点分类与过滤规则生成 `proxy-groups`
## 第 5 步:加载规则模块
* 读取 profile 启用的规则模块
* 按顺序加载规则文件
* 渲染模板变量(如 `target_group`
## 第 6 步:组装规则
* 拼接所有规则模块
* 插入 prepend rules
* 插入 append rules
* 保证 `MATCH` 规则最后输出
## 第 7 步:生成完整 YAML
输出包含:
* mixed-port / socks-port / redir-port / tproxy-port按需
* mode
* log-level
* dns
* tun
* proxies
* proxy-groups
* rules
## 第 8 步:缓存与快照
* 计算 hash
* 命中缓存则复用
* 否则生成新 artifact
---
## 17. 输出设计
### 对外输出接口
* `/profiles/{id}/clash.yaml`
* `/profiles/{id}/download`
* `/profiles/{id}/preview`
* `/profiles/{id}/head`
### 输出原则
* 默认返回完整 YAML
* 对客户端透明
* 客户端无需理解内部模块化结构
### 可选响应头
支持将源站订阅信息透传到最终响应头,例如:
* `subscription-userinfo`
* 其他流量额度相关头
如果多个源混合:
* 默认只透传第一个主源的额度信息
* 或后续设计聚合策略,但不是当前必做
---
## 18. API 草案
## 源管理
* `GET /api/sources`
* `POST /api/sources`
* `PUT /api/sources/{id}`
* `DELETE /api/sources/{id}`
* `POST /api/sources/{id}/test`
* `POST /api/sources/{id}/sync`
## 规则模块
* `GET /api/rule-modules`
* `GET /api/rule-modules/{id}`
* `PUT /api/rule-modules/{id}`
## Profile
* `GET /api/profiles`
* `POST /api/profiles`
* `PUT /api/profiles/{id}`
* `DELETE /api/profiles/{id}`
## Profile 规则配置
* `GET /api/profiles/{id}/rules`
* `PUT /api/profiles/{id}/rules`
## Profile 策略组配置
* `GET /api/profiles/{id}/policy-groups`
* `PUT /api/profiles/{id}/policy-groups`
## 预览与生成
* `POST /api/profiles/{id}/preview`
* `POST /api/profiles/{id}/generate`
* `GET /api/profiles/{id}/artifacts`
* `GET /api/artifacts/{id}`
---
## 19. 面板设计原则
### 普通模式
允许:
* 配源
* 选 profile
* 勾选规则模块
* 选择规则模块绑定的策略组
* 调模块顺序
* 加 prepend / append 规则
* 预览生成结果
### 高级模式
允许:
* 编辑少量覆盖配置
* 自定义组模板
* 导入自定义规则模块
### 不建议
* 直接在线编辑整份 ACL4SSR 规则大文件作为主要操作方式
---
## 20. 示例规则模块
## `rules/modules/openai.list`
```text
DOMAIN-SUFFIX,openai.com,{{ target_group }}
DOMAIN-SUFFIX,chatgpt.com,{{ target_group }}
DOMAIN-SUFFIX,oaistatic.com,{{ target_group }}
DOMAIN-SUFFIX,auth0.openai.com,{{ target_group }}
```
## `rules/modules/apple.list`
```text
DOMAIN-SUFFIX,apple.com,{{ target_group }}
DOMAIN-SUFFIX,icloud.com,{{ target_group }}
DOMAIN-SUFFIX,itunes.apple.com,{{ target_group }}
DOMAIN-SUFFIX,apps.apple.com,{{ target_group }}
```
---
## 21. 示例最终输出 YAML 骨架
```yaml
mixed-port: 7890
allow-lan: true
mode: rule
log-level: info
dns:
enable: true
ipv6: false
nameserver:
- 223.5.5.5
- 119.29.29.29
proxies:
- name: 香港节点 01
type: ss
server: 1.2.3.4
port: 443
cipher: aes-128-gcm
password: xxxx
proxy-groups:
- name: 🚀 节点选择
type: select
proxies:
- 香港节点 01
- 日本节点 01
- DIRECT
- name: 🤖 AI
type: select
proxies:
- 🚀 节点选择
- 香港节点 01
- 日本节点 01
rules:
- DOMAIN-SUFFIX,openai.com,🤖 AI
- DOMAIN-SUFFIX,chatgpt.com,🤖 AI
- DOMAIN-SUFFIX,apple.com,🍎 苹果服务
- GEOIP,CN,DIRECT
- MATCH,🚀 节点选择
```
---
## 22. 数据库配置要求
### 默认
```env
DATABASE_URL=sqlite:///./data/app.db
```
### 未来切 PostgreSQL
```env
DATABASE_URL=postgresql+psycopg://user:pass@postgres:5432/subprovider
```
### 要求
* 所有数据库访问都走 SQLAlchemy
* migration 走 Alembic
* 不写死 SQLite 特性到业务逻辑中
---
## 23. 开发阶段建议
## 第一阶段:解耦与最小可用
目标:
* 先跑通结构
任务:
1. 引入 SQLAlchemy + Alembic
2. 建 sources / profiles / rule_modules / profile_rule_modules / policy_groups / artifacts 表
3. 将现有规则整理为 `rules/modules/*.list`
4. 将现有单体生成逻辑拆成 service
5. 支持生成一个完整 YAML
6. 提供最简预览页面
---
## 第二阶段:面板化
目标:
* 基础可用
任务:
1. 源管理页面
2. Profile 管理页面
3. 规则模块勾选和排序页面
4. 策略组配置页面
5. 预览和下载页面
---
## 第三阶段:增强能力
目标:
* 更稳定、更可追踪
任务:
1. 快照与 diff
2. 生成缓存优化
3. 同步日志和错误展示
4. 自定义规则导入
5. 自定义组模板
---
## 24. 明确不推荐的方向
1. 不推荐把 ACL4SSR 规则逐条入 SQLite
2. 不推荐面板主交互是“编辑一万条规则文本”
3. 不推荐一开始就做 rule-provider / proxy-provider 对外发布
4. 不推荐 Mongo 作为主配置库
5. 不推荐先做复杂权限系统
6. 不推荐先做重前端再补业务逻辑
---
## 25. 给 Codex 的明确执行要求
请按以下要求重构项目:
1. 保留对外输出为单个完整 YAML
2. 将内部生成逻辑改为模块化流水线
3. 规则正文继续使用文件存储
4. 使用 SQLite 存储:
* 源配置
* profile 配置
* 规则模块元数据
* profile 与规则模块的绑定关系
* 策略组配置
* 生成快照
5. 使用 SQLAlchemy + Alembic数据库 URL 可配置
6. 代码结构按 service 分层,避免单文件堆逻辑
7. 首先实现最小可运行版本:
* 能配置 source
* 能配置 profile
* 能选择规则模块
* 能选择目标策略组
* 能生成并预览完整 YAML
8. 不要实现对外 provider 模式作为默认路线
9. 不要将规则正文全文入库
10. 不要将面板设计成规则全文编辑器
---
## 26. 最终一句话总结
本项目的最终路线是:
> **内部模块化、缓存化、可配置化**
> **对外输出完整单文件 YAML**
> **规则正文继续文件维护**
> **规则组合关系和面板配置放 SQLite**
这条路线最符合当前项目阶段,也最稳。
---
如果你要,我下一条可以继续给你一份**更像任务单的 Codex 执行拆分版**,我会按“先改哪些文件、再改哪些文件、每步验收什么”来写。

View File

@@ -3,3 +3,5 @@ uvicorn[standard]>=0.30,<1.0
httpx>=0.27,<1.0
PyYAML>=6.0,<7.0
pydantic-settings>=2.3,<3.0
SQLAlchemy>=2.0,<3.0
alembic>=1.13,<2.0