Browse Source

完成单个订阅到数据库

mrh 1 year ago
parent
commit
ce0cf2ce2f

+ 0 - 0
.clinerules


+ 45 - 0
.clinerules-code

@@ -0,0 +1,45 @@
+# 编程规范
+为了保持程序的通用性、扩展性和兼容性,代码设计应遵循以下原则:
+
+1. 模块化设计:将功能分解为独立的模块或类,每个模块或类应专注于单一职责。避免将多个不相关的业务逻辑集中在一个类或函数中。
+
+2. 高内聚低耦合:确保每个模块或类内部的元素紧密相关(高内聚),同时减少模块或类之间的依赖关系(低耦合)。这样可以使代码更易于维护和扩展。
+
+3. 单一职责原则:每个函数或方法应只执行一个最小化的任务。如果一个函数或方法包含多个步骤,应考虑将其分解为多个更小的函数或方法。
+
+4. 按需创建:根据业务需求决定是否创建新的文件、类或函数。如果现有类或函数无法满足新的需求,应考虑创建新的类或函数,而不是在现有代码中添加额外的逻辑。
+
+5. 继承与组合:合理使用继承和组合来增强代码的复用性和扩展性。优先使用组合而非继承,以避免过度复杂的继承层次。
+
+6. 注释与文档:为每个模块、类和函数添加适当的注释,说明其功能、输入输出以及使用场景。这有助于其他开发者理解和使用你的代码。
+
+7. 接口与抽象:定义清晰的接口和抽象基类,以便在不同实现之间保持兼容性。通过接口或抽象类来定义通用的行为,具体的实现可以在子类中完成。
+
+8. 可扩展性:在设计时考虑未来的扩展需求,避免硬编码和过度依赖特定实现。使用配置文件、依赖注入等方式来提高代码的灵活性。
+
+9. 兼容性:在修改或扩展代码时,确保新代码与旧代码兼容,避免破坏现有功能。可以通过版本控制、接口隔离等方式来管理兼容性问题。
+
+- 当前环境是 python 3.12 ,务必要保持最新的接口来开发,例如 Fastapi 不再使用 app.event ,而是使用 lifespan 。pydantic.BaseModel 不再支持 dict() ,而是用 model_dump()
+
+重要:由于你是在 aider 开发环境中,如果你要编写任何文件的代码,都不能省略已有代码,必须完整写完
+
+# 项目说明:
+- 这是一个基于 Fastapi + Vue3 的代理池管理。通过订阅链接,获取服务商提供的代理池数据,让 mihomo 工具启动本地代理池。
+
+## 后端
+python 搜索路径 `./backend`
+* Api
+
+启动web服务后, 默认配置下会开启 http://127.0.0.1:5010 的api接口服务:
+
+| api | method | Description | params|
+| ----| ---- | ---- | ----|
+| / | GET | api介绍 | None |
+| /get | GET | 随机获取一个代理| 可选参数: `?type=https` 过滤支持https的代理|
+| /pop | GET | 获取并删除一个代理| 可选参数: `?type=https` 过滤支持https的代理|
+| /all | GET | 获取所有代理 |可选参数: `?type=https` 过滤支持https的代理|
+| /count | GET | 查看代理数量 |None|
+| /delete | GET | 删除代理  |`?proxy=host:ip`|
+| /add | GET | 添加代理  |`?proxy=host:ip`|
+| /subscribe | PUT | 添加订阅链接 |None|
+

+ 2 - 1
.gitignore

@@ -1,4 +1,5 @@
 .aider*
 .aider*
 .env
 .env
+.venv
 __pycache__
 __pycache__
-output
+output

+ 0 - 5
.vscode/settings.json

@@ -1,5 +0,0 @@
-{
-    "python.analysis.extraPaths": [
-        "./backend",
-    ]
-}

+ 2 - 1
backend/config/app_yaml.py

@@ -3,6 +3,7 @@ from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 from typing import List
 from typing import List
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlmodel import SQLModel
 import yaml
 import yaml
 from config.settings import settings
 from config.settings import settings
 
 
@@ -31,7 +32,7 @@ class BaseYaml(BaseModel):
     def load(cls, file_path: Path=settings.APP_CONFIG) -> 'BaseYaml':
     def load(cls, file_path: Path=settings.APP_CONFIG) -> 'BaseYaml':
         """加载配置文件"""
         """加载配置文件"""
 
 
-class Subscription(BaseModel):
+class Subscription(SQLModel):
     url: str
     url: str
     file_path: str
     file_path: str
     updated_at: datetime
     updated_at: datetime

+ 15 - 0
backend/database/engine.py

@@ -0,0 +1,15 @@
+from typing import Iterator
+from sqlmodel import SQLModel, create_engine, Session
+from config.settings import settings
+
+sqlite_file_name = "database.db"
+sqlite_url = f"sqlite:///" + str(settings.OUTPUT_DIR / sqlite_file_name)
+
+engine = create_engine(sqlite_url, echo=False)
+
+def get_session() -> Iterator[Session]:
+    with Session(engine) as session:
+        yield session
+
+def create_db_and_tables():
+    SQLModel.metadata.create_all(engine)

+ 0 - 0
backend/models/config.py → backend/database/models/config.py


+ 94 - 0
backend/database/models/subscription.py

@@ -0,0 +1,94 @@
+from datetime import datetime
+from typing import List, Optional
+from sqlmodel import SQLModel, Field,Session,select,Relationship
+from sqlalchemy.engine import Engine
+from sqlalchemy.dialects.postgresql import JSON
+from pydantic import BaseModel
+import yaml
+from database.engine import get_session,create_engine,engine
+
+class SubscriptFile(SQLModel, table=True):
+    id: Optional[int] = Field(default=None, primary_key=True)
+    url: str = Field(index=True)
+    file_path: str = Field()
+    updated_at: datetime = Field(default_factory=datetime.now)
+    error: int = Field(default=0)
+    detail: dict = Field(default={}, sa_type=JSON)
+
+    mihomo_meta: List["MihomoMeta"] = Relationship(back_populates="subscript_file")
+
+
+class MihomoMeta(SQLModel, table=True):
+    id: Optional[int] = Field(default=None, primary_key=True)
+    provider_name: str
+    proxy_name: str
+    mixed_port: Optional[int]
+    external_controller: Optional[str]
+    temp_file_path: Optional[str]
+    pid: Optional[int]
+    running: Optional[bool] = False
+    updated_at: datetime = Field(default_factory=datetime.now)
+
+    subscript_file_id: Optional[int] = Field(default=None, foreign_key="subscriptfile.id")
+    subscript_file: SubscriptFile | None = Relationship(back_populates="mihomo_meta")
+    
+class SubscriptionManager:
+    def __init__(self, db:Engine=None):
+        self.engine:Engine = db or engine
+
+    def add_subscription_meta(self, sub_model: SubscriptFile, overwrite:bool=False):
+        with Session(self.engine) as session:
+            exist_sub = session.exec(select(SubscriptFile).where(SubscriptFile.url == sub_model.url)).first()
+            if exist_sub and not overwrite:
+                return exist_sub
+
+            name, groups = self._check_valid(sub_model)
+            
+            # 如果存在且需要覆盖,则删除旧的 MihomoMeta 记录
+            if exist_sub and overwrite:
+                session.exec(select(MihomoMeta).where(MihomoMeta.subscript_file_id == exist_sub.id)).delete()
+                session.delete(exist_sub)
+                session.commit()
+
+            # 添加新的 SubscriptFile
+            session.add(sub_model)
+            session.commit()
+            session.refresh(sub_model)
+
+            # 添加 MihomoMeta 记录
+            for group in groups:
+                miho = MihomoMeta(
+                    provider_name=name,
+                    proxy_name=group["name"],
+                    subscript_file_id=sub_model.id  # 使用 sub_model.id
+                )
+                session.add(miho)
+            
+            session.commit()
+            session.refresh(sub_model)
+            return sub_model
+        
+    def get_subscription_meta(self) -> List[SubscriptFile]:
+        with Session(self.engine) as session:
+            return session.exec(select(SubscriptFile)).all()
+    
+    def _check_valid(self, sub_model:SubscriptFile):
+        with open(sub_model.file_path, "r",encoding='utf-8') as f:
+            sub_yaml = yaml.safe_load(f)
+        groups = sub_yaml.get("proxy-groups", [])
+        if not groups:
+            raise ValueError("subscription file is not valid")
+        name = groups[0].get("name", "")
+        if not name:
+            raise ValueError("subscription file is not valid")
+        return name, groups
+            # ret.append(
+        #     SubscriptionResponse(
+        #         file_name=Path(sub.file_path).name,
+        #         provider_name=name,
+        #         updated_at=sub.updated_at,
+        #         proxies=sub_yaml.get("proxies", []),
+
+        #     )
+        # )
+        # return ret

+ 4 - 7
backend/main.py

@@ -9,9 +9,9 @@ import yaml
 import asyncio
 import asyncio
 import httpx
 import httpx
 from config.settings import settings
 from config.settings import settings
-from routers import configs, subscriptions
+from routers.subscriptions import router
 from utils.mihomo_service import download_mihomo
 from utils.mihomo_service import download_mihomo
-from routers.mihomo import POOL
+from database.engine import create_db_and_tables
 from aiomultiprocess import Pool
 from aiomultiprocess import Pool
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
@@ -19,18 +19,15 @@ async def lifespan(app: FastAPI):
     # 初始化目录结构
     # 初始化目录结构
     settings.PATH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
     settings.PATH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
     settings.PATH_SUBSCRIPTION_DIR.mkdir(parents=True, exist_ok=True)
     settings.PATH_SUBSCRIPTION_DIR.mkdir(parents=True, exist_ok=True)
+    create_db_and_tables()
     # 检查并下载mihomo
     # 检查并下载mihomo
     await download_mihomo()
     await download_mihomo()
-    POOL = Pool()
     yield
     yield
-    if POOL is not None:
-        await POOL.close()
 
 
 app = FastAPI(lifespan=lifespan)
 app = FastAPI(lifespan=lifespan)
 
 
 # 注册路由
 # 注册路由
-app.include_router(configs.router, prefix="/configs", tags=["配置管理"])
-app.include_router(subscriptions.router, prefix="/subscriptions", tags=["订阅管理"])
+app.include_router(router, prefix="/subscriptions", tags=["订阅管理"])
 
 
 # 添加健康检查路由
 # 添加健康检查路由
 @app.get("/health")
 @app.get("/health")

+ 16 - 27
backend/routers/subscriptions.py

@@ -9,8 +9,10 @@ import httpx
 import yaml
 import yaml
 from config.logu import logger, get_logger
 from config.logu import logger, get_logger
 from config.settings import settings
 from config.settings import settings
-from config.app_yaml import app_yaml, Subscription
 from utils.sub import async_get_sub
 from utils.sub import async_get_sub
+from database.engine import engine,get_session
+from sqlmodel import Session
+from database.models.subscription import SubscriptionManager,SubscriptFile
 router = APIRouter()
 router = APIRouter()
 
 
 
 
@@ -22,7 +24,7 @@ class SubscriptionResponse(BaseModel):
     provider_name: str
     provider_name: str
     updated_at: datetime
     updated_at: datetime
     proxies: List[Dict]
     proxies: List[Dict]
-async def process_url(url: str, save_path) -> Subscription:
+async def process_url(url: str, save_path) -> SubscriptFile:
     """处理单个URL的异步任务"""
     """处理单个URL的异步任务"""
     try:
     try:
         save_path_res = await async_get_sub(
         save_path_res = await async_get_sub(
@@ -30,7 +32,7 @@ async def process_url(url: str, save_path) -> Subscription:
             save_path,
             save_path,
             timeout=5
             timeout=5
         )
         )
-        return Subscription(
+        return SubscriptFile(
             url=url, 
             url=url, 
             file_path=str(save_path_res), 
             file_path=str(save_path_res), 
             updated_at=datetime.now(), 
             updated_at=datetime.now(), 
@@ -39,7 +41,7 @@ async def process_url(url: str, save_path) -> Subscription:
         )
         )
     except Exception as e:
     except Exception as e:
         logger.error(f"更新订阅失败: {url}, 错误: {str(e)}")
         logger.error(f"更新订阅失败: {url}, 错误: {str(e)}")
-        return Subscription(
+        return SubscriptFile(
             url=url, 
             url=url, 
             file_path="", 
             file_path="", 
             updated_at=datetime.now(), 
             updated_at=datetime.now(), 
@@ -48,7 +50,7 @@ async def process_url(url: str, save_path) -> Subscription:
         )
         )
 
 
 @router.post("/")
 @router.post("/")
-async def add_subscriptions(sub: SubscriptionBatchRequest):
+async def add_subscriptions(sub: SubscriptionBatchRequest) -> List[SubscriptFile]:
     """批量更新订阅链接"""
     """批量更新订阅链接"""
     logger.info(f"开始批量更新订阅: {sub.urls}")
     logger.info(f"开始批量更新订阅: {sub.urls}")
     # 初始化任务列表
     # 初始化任务列表
@@ -59,29 +61,16 @@ async def add_subscriptions(sub: SubscriptionBatchRequest):
         # 将每个URL的异步任务添加到任务列表中
         # 将每个URL的异步任务添加到任务列表中
         tasks.append(process_url(url, save_path))
         tasks.append(process_url(url, save_path))
     results = await asyncio.gather(*tasks)
     results = await asyncio.gather(*tasks)
-    app_yaml.subscriptions = results
-    app_yaml.save()
-    return results
+    db_results = []
+    db = SubscriptionManager()
+    for result in results:
+         db_results.append(
+             db.add_subscription_meta(result)
+         )
+    return db_results
 
 
 @router.get("/")
 @router.get("/")
 async def list_subscriptions() ->List[SubscriptionResponse]:
 async def list_subscriptions() ->List[SubscriptionResponse]:
     ret = []
     ret = []
-    for sub in app_yaml.subscriptions:
-        with open(sub.file_path, "r",encoding='utf-8') as f:
-            sub_yaml = yaml.safe_load(f)
-        groups = sub_yaml.get("proxy-groups", [])
-        if not groups:
-            continue
-        name = groups[0].get("name", "")
-        if not name:
-            continue
-        ret.append(
-            SubscriptionResponse(
-                file_name=Path(sub.file_path).name,
-                provider_name=name,
-                updated_at=sub.updated_at,
-                proxies=sub_yaml.get("proxies", []),
-
-            )
-        )
-    return ret
+    db = SubscriptionManager()
+    db_sub_models = db.get_subscription_meta()