dev
This commit is contained in:
28
domain-api/.env.example
Normal file
28
domain-api/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
API_PREFIX=/api/v1
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8100
|
||||
|
||||
# 允许访问 Web 后台的来源,多个地址用英文逗号分隔
|
||||
CORS_ORIGINS=http://127.0.0.1:3200,http://localhost:3200
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=domain
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# domainCheck 桌面版或 Worker 所在目录
|
||||
DOMAIN_ROOT=/opt/domaincheck/domainCheck
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
|
||||
# windows-local 或 linux-systemd
|
||||
WORKER_MODE=linux-systemd
|
||||
WORKER_SERVICE_NAME=domaincheck-worker
|
||||
API_SERVICE_NAME=domaincheck-api
|
||||
80
domain-api/README.md
Normal file
80
domain-api/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# domain-api
|
||||
|
||||
`domainCheck` 轻量 Web 管理后台后端。
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 提供健康检查、登录、概览、系统设置、域名导入、检测控制、筛选、导出、日志诊断接口
|
||||
- 已接入真实 PostgreSQL / Redis / `domainCheck` 配置
|
||||
- 已支持 `windows-local` 与 `linux-systemd` 两种 Worker 控制模式
|
||||
- 已支持导入任务化、导出任务记录、基础运行状态探测
|
||||
|
||||
## Windows 启动
|
||||
|
||||
在工作区根目录执行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\start_domain_api.ps1
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\stop_domain_api.ps1
|
||||
```
|
||||
|
||||
日志默认输出到:
|
||||
|
||||
- `domain-api/runtime/logs/domain-api.stdout.log`
|
||||
- `domain-api/runtime/logs/domain-api.stderr.log`
|
||||
|
||||
## Linux / systemd
|
||||
|
||||
当后端迁到 Linux 时,建议:
|
||||
|
||||
- `domain-api` 独立为一个 systemd 服务
|
||||
- `domainCheck/detect_worker.py` 独立为一个 systemd 服务
|
||||
- `.env` 仍由 `domainCheck/.env` 统一提供数据库和 Redis 配置
|
||||
|
||||
参考模板:
|
||||
|
||||
- `deploy/systemd/domain-api.service`
|
||||
- `deploy/systemd/domain-worker.service`
|
||||
|
||||
更完整的上线步骤见:
|
||||
|
||||
- `deploy/linux/README.md`
|
||||
- `../docs/05_domainCheck_Linux部署清单.md`
|
||||
|
||||
启用前请按实际路径修改:
|
||||
|
||||
- `WorkingDirectory`
|
||||
- `ExecStart`
|
||||
- `User/Group`
|
||||
|
||||
同时在环境中设置:
|
||||
|
||||
```bash
|
||||
WORKER_MODE=linux-systemd
|
||||
WORKER_SERVICE_NAME=domaincheck-worker
|
||||
API_SERVICE_NAME=domaincheck-api
|
||||
```
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
- `API_PREFIX`
|
||||
- `API_HOST`
|
||||
- `API_PORT`
|
||||
- `WORKER_MODE`
|
||||
- `WORKER_SERVICE_NAME`
|
||||
- `API_SERVICE_NAME`
|
||||
|
||||
环境变量样板可参考:
|
||||
|
||||
- `.env.example`
|
||||
|
||||
默认情况下:
|
||||
|
||||
- `WORKER_MODE=windows-local`
|
||||
- Web 前端地址:`http://127.0.0.1:3200`
|
||||
- API 地址:`http://127.0.0.1:8100`
|
||||
1
domain-api/app/__init__.py
Normal file
1
domain-api/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
domain-api/app/api/__init__.py
Normal file
1
domain-api/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
domain-api/app/api/routes/__init__.py
Normal file
1
domain-api/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
23
domain-api/app/api/routes/auth.py
Normal file
23
domain-api/app/api/routes/auth.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.common import ApiResponse
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=ApiResponse)
|
||||
def login(payload: LoginRequest) -> ApiResponse:
|
||||
if payload.username != settings.admin_username or payload.password != settings.admin_password:
|
||||
return ApiResponse(code=1, message="账号或密码错误", data=None)
|
||||
token = f"domain-web-token-{payload.username}"
|
||||
return ApiResponse(
|
||||
data={
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"username": payload.username,
|
||||
"display_name": "管理员" if payload.username == "admin" else payload.username,
|
||||
},
|
||||
}
|
||||
)
|
||||
11
domain-api/app/api/routes/dashboard.py
Normal file
11
domain-api/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.dashboard import fetch_overview
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/dashboard/overview", response_model=ApiResponse)
|
||||
def overview() -> ApiResponse:
|
||||
return ApiResponse(data=fetch_overview())
|
||||
40
domain-api/app/api/routes/detect.py
Normal file
40
domain-api/app/api/routes/detect.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.worker_control_service import start_worker, stop_worker
|
||||
|
||||
router = APIRouter(tags=["detect"])
|
||||
|
||||
|
||||
@router.get("/detect/status", response_model=ApiResponse)
|
||||
def detect_status() -> ApiResponse:
|
||||
return ApiResponse(data=get_detect_status())
|
||||
|
||||
|
||||
@router.post("/detect/start", response_model=ApiResponse)
|
||||
def start_detect() -> ApiResponse:
|
||||
ok, message = start_worker()
|
||||
return ApiResponse(
|
||||
code=0 if ok else 1,
|
||||
message=message,
|
||||
data={
|
||||
"action": "start",
|
||||
"poll_after_seconds": 2,
|
||||
"refresh_status": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/detect/stop", response_model=ApiResponse)
|
||||
def stop_detect() -> ApiResponse:
|
||||
ok, message = stop_worker()
|
||||
return ApiResponse(
|
||||
code=0 if ok else 1,
|
||||
message=message,
|
||||
data={
|
||||
"action": "stop",
|
||||
"poll_after_seconds": 2,
|
||||
"refresh_status": True,
|
||||
},
|
||||
)
|
||||
50
domain-api/app/api/routes/domains.py
Normal file
50
domain-api/app/api/routes/domains.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.domains_service import batch_update_domains, domain_filter_options, fetch_domains
|
||||
|
||||
router = APIRouter(tags=["domains"])
|
||||
|
||||
|
||||
@router.get("/domains/filters", response_model=ApiResponse)
|
||||
def domain_filters() -> ApiResponse:
|
||||
return ApiResponse(data=domain_filter_options())
|
||||
|
||||
|
||||
@router.get("/domains", response_model=ApiResponse)
|
||||
def domain_list(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
domain_keyword: str | None = Query(default=None),
|
||||
register_status: int | None = Query(default=None),
|
||||
detect_status: int | None = Query(default=None),
|
||||
use_status: int | None = Query(default=None),
|
||||
review_status: int | None = Query(default=None),
|
||||
has_beian: int | None = Query(default=None),
|
||||
beian_year: int | None = Query(default=None),
|
||||
snapshot_year: str | None = Query(default=None),
|
||||
website_url: str | None = Query(default=None),
|
||||
backlink_gt_10: bool | None = Query(default=None),
|
||||
) -> ApiResponse:
|
||||
return ApiResponse(
|
||||
data=fetch_domains(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
domain_keyword=domain_keyword,
|
||||
register_status=register_status,
|
||||
detect_status=detect_status,
|
||||
use_status=use_status,
|
||||
review_status=review_status,
|
||||
has_beian=has_beian,
|
||||
beian_year=beian_year,
|
||||
snapshot_year=snapshot_year,
|
||||
website_url=website_url,
|
||||
backlink_gt_10=backlink_gt_10,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/domains/batch-update", response_model=ApiResponse)
|
||||
def domain_batch_update(payload: dict) -> ApiResponse:
|
||||
result = batch_update_domains(payload.get("domain_ids", []), payload.get("updates", {}))
|
||||
return ApiResponse(message=f"成功更新 {result['updated_count']} 个域名", data=result)
|
||||
29
domain-api/app/api/routes/exports.py
Normal file
29
domain-api/app/api/routes/exports.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.export_service import create_export_file, list_exports
|
||||
from app.core.files import exports_root
|
||||
|
||||
router = APIRouter(tags=["exports"])
|
||||
|
||||
|
||||
@router.get("/exports", response_model=ApiResponse)
|
||||
def export_list() -> ApiResponse:
|
||||
return ApiResponse(data=list_exports())
|
||||
|
||||
|
||||
@router.post("/exports/run", response_model=ApiResponse)
|
||||
def export_run(payload: dict) -> ApiResponse:
|
||||
record = create_export_file(payload)
|
||||
return ApiResponse(message="导出文件已生成", data=record)
|
||||
|
||||
|
||||
@router.get("/exports/download/{filename}")
|
||||
def export_download(filename: str):
|
||||
path = exports_root() / Path(filename).name
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
return FileResponse(path, filename=path.name)
|
||||
30
domain-api/app/api/routes/imports.py
Normal file
30
domain-api/app/api/routes/imports.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.import_task_service import create_import_task, list_import_tasks, retry_import_task
|
||||
from app.services.imports_service import get_import_summary
|
||||
|
||||
router = APIRouter(tags=["imports"])
|
||||
|
||||
|
||||
@router.get("/imports/summary", response_model=ApiResponse)
|
||||
def imports_summary() -> ApiResponse:
|
||||
return ApiResponse(data=get_import_summary())
|
||||
|
||||
|
||||
@router.get("/imports/tasks", response_model=ApiResponse)
|
||||
def import_tasks() -> ApiResponse:
|
||||
return ApiResponse(data=list_import_tasks())
|
||||
|
||||
|
||||
@router.post("/imports/upload", response_model=ApiResponse)
|
||||
async def upload_import(file: UploadFile = File(...)) -> ApiResponse:
|
||||
content = await file.read()
|
||||
task = create_import_task(content, file.filename or "domains.txt")
|
||||
return ApiResponse(message="导入任务已创建", data=task)
|
||||
|
||||
|
||||
@router.post("/imports/tasks/{task_id}/retry", response_model=ApiResponse)
|
||||
def import_task_retry(task_id: str) -> ApiResponse:
|
||||
task = retry_import_task(task_id)
|
||||
return ApiResponse(message="导入任务已重新加入队列", data=task)
|
||||
18
domain-api/app/api/routes/logs.py
Normal file
18
domain-api/app/api/routes/logs.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.logs_service import build_diagnostic_bundle, latest_logs as get_latest_logs
|
||||
|
||||
router = APIRouter(tags=["logs"])
|
||||
|
||||
|
||||
@router.get("/logs/latest", response_model=ApiResponse)
|
||||
def latest_logs() -> ApiResponse:
|
||||
return ApiResponse(data=get_latest_logs())
|
||||
|
||||
|
||||
@router.get("/logs/bundle")
|
||||
def download_logs_bundle() -> FileResponse:
|
||||
path, filename = build_diagnostic_bundle()
|
||||
return FileResponse(path=path, filename=filename, media_type="application/zip")
|
||||
23
domain-api/app/api/routes/runtime.py
Normal file
23
domain-api/app/api/routes/runtime.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.runtime_control_service import runtime_action
|
||||
from app.services.runtime_status_service import get_runtime_preflight, get_runtime_status
|
||||
|
||||
router = APIRouter(tags=["runtime"])
|
||||
|
||||
|
||||
@router.get("/runtime/status", response_model=ApiResponse)
|
||||
def runtime_status() -> ApiResponse:
|
||||
return ApiResponse(data=get_runtime_status())
|
||||
|
||||
|
||||
@router.get("/runtime/preflight", response_model=ApiResponse)
|
||||
def runtime_preflight() -> ApiResponse:
|
||||
return ApiResponse(data=get_runtime_preflight())
|
||||
|
||||
|
||||
@router.post("/runtime/actions/{action}", response_model=ApiResponse)
|
||||
def runtime_action_trigger(action: str) -> ApiResponse:
|
||||
ok, message, data = runtime_action(action)
|
||||
return ApiResponse(code=0 if ok else 1, message=message, data=data)
|
||||
69
domain-api/app/api/routes/settings.py
Normal file
69
domain-api/app/api/routes/settings.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.files import settings_backup_root
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.settings_service import (
|
||||
backup_current_settings,
|
||||
export_settings_snapshot,
|
||||
get_settings_payload,
|
||||
import_settings_snapshot,
|
||||
list_settings_backups,
|
||||
update_settings_payload,
|
||||
validate_settings_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/settings", response_model=ApiResponse)
|
||||
def get_settings() -> ApiResponse:
|
||||
return ApiResponse(data=get_settings_payload())
|
||||
|
||||
|
||||
@router.put("/settings", response_model=ApiResponse)
|
||||
def update_settings(payload: dict) -> ApiResponse:
|
||||
return ApiResponse(message="settings updated", data=update_settings_payload(payload))
|
||||
|
||||
|
||||
@router.get("/settings/export", response_model=ApiResponse)
|
||||
def export_settings() -> ApiResponse:
|
||||
return ApiResponse(message="settings exported", data=export_settings_snapshot())
|
||||
|
||||
|
||||
@router.post("/settings/import", response_model=ApiResponse)
|
||||
def import_settings(payload: dict) -> ApiResponse:
|
||||
try:
|
||||
settings_payload = import_settings_snapshot(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ApiResponse(message="settings imported", data=settings_payload)
|
||||
|
||||
|
||||
@router.post("/settings/validate-import", response_model=ApiResponse)
|
||||
def validate_import_settings(payload: dict) -> ApiResponse:
|
||||
try:
|
||||
validate_settings_payload(payload.get("settings", payload))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ApiResponse(message="settings payload valid", data={"valid": True})
|
||||
|
||||
|
||||
@router.post("/settings/backup", response_model=ApiResponse)
|
||||
def create_settings_backup() -> ApiResponse:
|
||||
return ApiResponse(message="settings backed up", data=backup_current_settings("manual"))
|
||||
|
||||
|
||||
@router.get("/settings/backups", response_model=ApiResponse)
|
||||
def get_settings_backups() -> ApiResponse:
|
||||
return ApiResponse(data=list_settings_backups())
|
||||
|
||||
|
||||
@router.get("/settings/backups/download/{filename}")
|
||||
def download_settings_backup(filename: str):
|
||||
path = settings_backup_root() / Path(filename).name
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
return FileResponse(path, filename=path.name)
|
||||
1
domain-api/app/core/__init__.py
Normal file
1
domain-api/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
51
domain-api/app/core/config.py
Normal file
51
domain-api/app/core/config.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
WORKSPACE_DIR = BASE_DIR.parent
|
||||
DOMAINCHECK_DIR = WORKSPACE_DIR / "domainCheck"
|
||||
API_ENV_FILE = BASE_DIR / ".env"
|
||||
ENV_FILE = DOMAINCHECK_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_prefix: str = "/api/v1"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8100
|
||||
cors_origins: list[str] = ["http://127.0.0.1:3200", "http://localhost:3200"]
|
||||
db_host: str = "127.0.0.1"
|
||||
db_port: int = 5432
|
||||
db_database: str = "domain"
|
||||
db_user: str = "postgres"
|
||||
db_password: str = "postgres"
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_db: int = 0
|
||||
domain_root: str = str(DOMAINCHECK_DIR)
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin"
|
||||
worker_mode: str = "windows-local"
|
||||
worker_service_name: str = "domaincheck-worker"
|
||||
api_service_name: str = "domaincheck-api"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
if value.strip().startswith("["):
|
||||
return value
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(str(API_ENV_FILE), str(ENV_FILE)),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
20
domain-api/app/core/db.py
Normal file
20
domain-api/app/core/db.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg2
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
conn = psycopg2.connect(
|
||||
host=settings.db_host,
|
||||
port=settings.db_port,
|
||||
dbname=settings.db_database,
|
||||
user=settings.db_user,
|
||||
password=settings.db_password,
|
||||
)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
107
domain-api/app/core/files.py
Normal file
107
domain-api/app/core/files.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def domain_root() -> Path:
|
||||
return Path(settings.domain_root)
|
||||
|
||||
|
||||
def read_json(relative_path: str, default: dict | list | None = None):
|
||||
path = domain_root() / relative_path
|
||||
if not path.exists():
|
||||
return {} if default is None else default
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def write_json(relative_path: str, payload) -> None:
|
||||
path = domain_root() / relative_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]:
|
||||
path = domain_root() / relative_path
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read().splitlines()[-max_lines:]
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
path = Path(__file__).resolve().parents[2] / "runtime"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def read_runtime_json(filename: str, default: dict | list | None = None):
|
||||
path = runtime_root() / filename
|
||||
if not path.exists():
|
||||
return {} if default is None else default
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def write_runtime_json(filename: str, payload) -> None:
|
||||
path = runtime_root() / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def exports_root() -> Path:
|
||||
path = runtime_root() / "exports"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def import_root() -> Path:
|
||||
path = runtime_root() / "imports"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def settings_backup_root() -> Path:
|
||||
path = runtime_root() / "settings_backups"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def load_export_records() -> list[dict]:
|
||||
path = runtime_root() / "export_tasks.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def save_export_record(record: dict) -> None:
|
||||
records = load_export_records()
|
||||
records.insert(0, record)
|
||||
path = runtime_root() / "export_tasks.json"
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def load_import_records() -> list[dict]:
|
||||
path = runtime_root() / "import_tasks.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def save_import_records(records: list[dict]) -> None:
|
||||
path = runtime_root() / "import_tasks.json"
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(records[:200], handle, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
def timestamp_filename(prefix: str, ext: str) -> str:
|
||||
return f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
|
||||
17
domain-api/app/core/redis_client.py
Normal file
17
domain-api/app/core/redis_client.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import redis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def get_redis() -> redis.Redis:
|
||||
return redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
password=settings.redis_password or None,
|
||||
db=settings.redis_db,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
)
|
||||
43
domain-api/app/main.py
Normal file
43
domain-api/app/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, dashboard, settings as settings_routes, imports, detect, domains, exports, logs, runtime
|
||||
from app.core.config import settings as app_settings
|
||||
|
||||
app = FastAPI(
|
||||
title="domainCheck API",
|
||||
version="0.1.0",
|
||||
description="domainCheck 轻量 Web 管理后台后端骨架",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=app_settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "domain-api",
|
||||
"version": "0.1.0",
|
||||
"api_prefix": app_settings.api_prefix,
|
||||
"worker_mode": app_settings.worker_mode,
|
||||
"api_host": app_settings.api_host,
|
||||
"api_port": app_settings.api_port,
|
||||
}
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(dashboard.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(imports.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(detect.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(domains.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(exports.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(logs.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(runtime.router, prefix=app_settings.api_prefix)
|
||||
1
domain-api/app/schemas/__init__.py
Normal file
1
domain-api/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
domain-api/app/schemas/auth.py
Normal file
6
domain-api/app/schemas/auth.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
8
domain-api/app/schemas/common.py
Normal file
8
domain-api/app/schemas/common.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
code: int = 0
|
||||
message: str = "ok"
|
||||
data: Any = None
|
||||
1
domain-api/app/services/__init__.py
Normal file
1
domain-api/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
27
domain-api/app/services/dashboard.py
Normal file
27
domain-api/app/services/dashboard.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
|
||||
|
||||
def fetch_overview() -> dict:
|
||||
queries = {
|
||||
"domains_total": "select count(*) from domains",
|
||||
"pending_total": "select count(*) from domains where detect_status = 0",
|
||||
"completed_total": "select count(*) from domains where detect_status = 1",
|
||||
"running_total": "select count(*) from domains where detect_status = 2",
|
||||
"blacklist_total": "select count(*) from domains where detect_status = 3",
|
||||
"failed_total": "select count(*) from domains where detect_status = 4",
|
||||
"sensitive_words_total": "select count(*) from sensitive_words",
|
||||
}
|
||||
result: dict[str, int | str] = {}
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for key, query in queries.items():
|
||||
cur.execute(query)
|
||||
result[key] = cur.fetchone()[0]
|
||||
runtime = get_runtime_status()
|
||||
result["worker_status"] = "online" if runtime["worker"]["running"] else "offline"
|
||||
result["api_status"] = "online"
|
||||
result["worker_mode"] = runtime["worker"]["mode"]
|
||||
return result
|
||||
65
domain-api/app/services/detect_service.py
Normal file
65
domain-api/app/services/detect_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.files import tail_lines
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.settings_service import get_settings_payload
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
|
||||
def get_detect_status() -> dict:
|
||||
queries = {
|
||||
"pending": "select count(*) from domains where detect_status = 0",
|
||||
"completed": "select count(*) from domains where detect_status = 1",
|
||||
"running": "select count(*) from domains where detect_status = 2",
|
||||
"blacklisted": "select count(*) from domains where detect_status = 3",
|
||||
"failed": "select count(*) from domains where detect_status = 4",
|
||||
}
|
||||
progress: dict[str, int] = {}
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for key, query in queries.items():
|
||||
cur.execute(query)
|
||||
progress[key] = cur.fetchone()[0]
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "detect_worker.log"
|
||||
if not worker_log.exists():
|
||||
worker_log = Path(__file__).resolve().parents[3] / "domainCheck" / "logs" / "detect_worker.log"
|
||||
|
||||
worker_online = False
|
||||
last_log_time = None
|
||||
if worker_log.exists():
|
||||
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
|
||||
last_log_time = modified.isoformat()
|
||||
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
|
||||
|
||||
recent_lines = tail_lines("detect_worker.log", max_lines=80)
|
||||
recent_proxy_warning = next(
|
||||
(line for line in reversed(recent_lines) if "代理" in line or "Redis订阅失败" in line),
|
||||
"",
|
||||
)
|
||||
runtime = detect_worker_runtime()
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_online = worker_online or runtime.get("running", False)
|
||||
|
||||
return {
|
||||
"worker_online": worker_online,
|
||||
"worker_mode": runtime.get("mode", "windows-local"),
|
||||
"worker_service_name": runtime_settings.get("worker_service_name", ""),
|
||||
"api_service_name": runtime_settings.get("api_service_name", ""),
|
||||
"worker_process_count": runtime.get("process_count", 0),
|
||||
"worker_latest_start_time": runtime.get("latest_start_time", ""),
|
||||
"worker_runtime_message": runtime.get("message", ""),
|
||||
"thread_count": settings_payload["thread_count"],
|
||||
"proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False),
|
||||
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
|
||||
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
|
||||
"available_proxy_count": 0,
|
||||
"last_worker_log_time": last_log_time,
|
||||
"progress": progress,
|
||||
"recent_warning": recent_proxy_warning,
|
||||
}
|
||||
277
domain-api/app/services/domains_service.py
Normal file
277
domain-api/app/services/domains_service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
|
||||
DETECT_STATUS_LABELS = {
|
||||
0: "待检测",
|
||||
1: "检测完成",
|
||||
2: "检测中",
|
||||
3: "黑名单",
|
||||
4: "检测失败",
|
||||
}
|
||||
|
||||
REGISTER_STATUS_LABELS = {
|
||||
0: "待检测",
|
||||
2: "可注册",
|
||||
3: "已注册",
|
||||
4: "宽限期",
|
||||
5: "赎回期",
|
||||
6: "删除期",
|
||||
7: "clientHold",
|
||||
8: "serverHold",
|
||||
9: "状态未知",
|
||||
10: "检测失败",
|
||||
}
|
||||
|
||||
USE_STATUS_LABELS = {
|
||||
0: "未使用",
|
||||
1: "已经使用",
|
||||
2: "已经卖出",
|
||||
3: "已经预定",
|
||||
}
|
||||
|
||||
REVIEW_STATUS_LABELS = {
|
||||
0: "无需复核",
|
||||
1: "待人工复核",
|
||||
2: "人工通过",
|
||||
3: "人工拒绝",
|
||||
}
|
||||
|
||||
BEIAN_STATUS_LABELS = {
|
||||
1: "未检测",
|
||||
2: "有备案",
|
||||
3: "无备案",
|
||||
}
|
||||
|
||||
|
||||
def _build_domain_query_parts(filters: dict | None = None) -> tuple[str, str, list[object]]:
|
||||
filters = filters or {}
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
|
||||
if filters.get("domain_keyword"):
|
||||
conditions.append("d.domain ilike %s")
|
||||
params.append(f"%{str(filters['domain_keyword']).strip()}%")
|
||||
if filters.get("register_status") is not None:
|
||||
conditions.append("d.register_status = %s")
|
||||
params.append(filters["register_status"])
|
||||
if filters.get("detect_status") is not None:
|
||||
conditions.append("d.detect_status = %s")
|
||||
params.append(filters["detect_status"])
|
||||
if filters.get("use_status") is not None:
|
||||
conditions.append("d.use_status = %s")
|
||||
params.append(filters["use_status"])
|
||||
if filters.get("review_status") is not None:
|
||||
conditions.append("d.review_status = %s")
|
||||
params.append(filters["review_status"])
|
||||
if filters.get("has_beian") is not None:
|
||||
conditions.append("d.has_beian = %s")
|
||||
params.append(filters["has_beian"])
|
||||
if filters.get("beian_year"):
|
||||
conditions.append("d.beian_year = %s")
|
||||
params.append(int(filters["beian_year"]))
|
||||
if filters.get("snapshot_year"):
|
||||
conditions.append("coalesce(d.snapshot_years, '') like %s")
|
||||
params.append(f"%{str(filters['snapshot_year']).strip()}%")
|
||||
if filters.get("website_url"):
|
||||
conditions.append("coalesce(d.website_url, '') ilike %s")
|
||||
params.append(f"%{str(filters['website_url']).strip()}%")
|
||||
if filters.get("backlink_gt_10"):
|
||||
conditions.append("coalesce(dd.backlink_count_gt_10, false) = true")
|
||||
|
||||
from_clause = """
|
||||
from domains d
|
||||
left join domain_detections dd on dd.domain_id = d.id
|
||||
"""
|
||||
where_clause = f"where {' and '.join(conditions)}" if conditions else ""
|
||||
return from_clause, where_clause, params
|
||||
|
||||
|
||||
def fetch_domains(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
domain_keyword: str | None = None,
|
||||
register_status: int | None = None,
|
||||
detect_status: int | None = None,
|
||||
has_beian: int | None = None,
|
||||
use_status: int | None = None,
|
||||
review_status: int | None = None,
|
||||
beian_year: int | None = None,
|
||||
snapshot_year: str | None = None,
|
||||
website_url: str | None = None,
|
||||
backlink_gt_10: bool | None = None,
|
||||
) -> dict:
|
||||
offset = (page - 1) * page_size
|
||||
filters = {
|
||||
"domain_keyword": domain_keyword,
|
||||
"register_status": register_status,
|
||||
"detect_status": detect_status,
|
||||
"has_beian": has_beian,
|
||||
"use_status": use_status,
|
||||
"review_status": review_status,
|
||||
"beian_year": beian_year,
|
||||
"snapshot_year": snapshot_year,
|
||||
"website_url": website_url,
|
||||
"backlink_gt_10": backlink_gt_10,
|
||||
}
|
||||
from_clause, where_clause, params = _build_domain_query_parts(filters)
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"select count(*) {from_clause} {where_clause}", tuple(params))
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
f"""
|
||||
select
|
||||
d.id,
|
||||
d.domain,
|
||||
d.register_status,
|
||||
d.use_status,
|
||||
d.detect_status,
|
||||
d.review_status,
|
||||
d.has_beian,
|
||||
d.website_url,
|
||||
d.beian_year,
|
||||
d.snapshot_years,
|
||||
d.backlink_count,
|
||||
d.detect_time,
|
||||
coalesce(dd.backlink_count_gt_10, false) as backlink_gt_10
|
||||
{from_clause}
|
||||
{where_clause}
|
||||
order by d.id desc
|
||||
limit %s offset %s
|
||||
""",
|
||||
tuple(params + [page_size, offset]),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": row[0],
|
||||
"domain": row[1],
|
||||
"register_status": REGISTER_STATUS_LABELS.get(row[2], str(row[2])),
|
||||
"register_status_code": row[2],
|
||||
"use_status": USE_STATUS_LABELS.get(row[3], str(row[3])),
|
||||
"use_status_code": row[3],
|
||||
"detect_status": DETECT_STATUS_LABELS.get(row[4], str(row[4])),
|
||||
"detect_status_code": row[4],
|
||||
"review_status": REVIEW_STATUS_LABELS.get(row[5], str(row[5])),
|
||||
"review_status_code": row[5],
|
||||
"has_beian": BEIAN_STATUS_LABELS.get(row[6], str(row[6])),
|
||||
"has_beian_code": row[6],
|
||||
"website_url": row[7] or "",
|
||||
"beian_year": row[8],
|
||||
"snapshot_years": row[9] or "",
|
||||
"backlink_count": row[10],
|
||||
"detect_time": row[11].isoformat() if row[11] else None,
|
||||
"backlink_gt_10": row[12],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"pages": ceil(total / page_size) if page_size else 1,
|
||||
}
|
||||
|
||||
|
||||
def domain_filter_options() -> dict:
|
||||
return {
|
||||
"register_status": [
|
||||
{"label": label, "value": value}
|
||||
for value, label in REGISTER_STATUS_LABELS.items()
|
||||
if value in (2, 3, 4, 5, 6, 7, 8, 10)
|
||||
],
|
||||
"detect_status": [
|
||||
{"label": label, "value": value}
|
||||
for value, label in DETECT_STATUS_LABELS.items()
|
||||
],
|
||||
"use_status": [
|
||||
{"label": label, "value": value}
|
||||
for value, label in USE_STATUS_LABELS.items()
|
||||
],
|
||||
"review_status": [
|
||||
{"label": label, "value": value}
|
||||
for value, label in REVIEW_STATUS_LABELS.items()
|
||||
],
|
||||
"has_beian": [
|
||||
{"label": "未检测", "value": 1},
|
||||
{"label": "有备案", "value": 2},
|
||||
{"label": "无备案", "value": 3},
|
||||
],
|
||||
"supports_backlink_gt_10": True,
|
||||
"supports_txt_export": True,
|
||||
"supports_excel_export": True,
|
||||
"supports_multi_page_export": True,
|
||||
}
|
||||
|
||||
|
||||
def batch_update_domains(domain_ids: list[int], updates: dict) -> dict:
|
||||
if not domain_ids:
|
||||
raise ValueError("未选择需要更新的域名")
|
||||
|
||||
allowed_fields = {
|
||||
"review_status",
|
||||
"expire_date",
|
||||
"has_beian",
|
||||
"beian_year",
|
||||
"snapshot_years",
|
||||
"company_type",
|
||||
"detect_time",
|
||||
"website_url",
|
||||
"backlink_count",
|
||||
}
|
||||
payload = {key: value for key, value in updates.items() if key in allowed_fields and value not in (None, "", "skip")}
|
||||
if not payload:
|
||||
raise ValueError("没有可更新的字段")
|
||||
|
||||
updated_count = 0
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for domain_id in domain_ids:
|
||||
set_parts: list[str] = []
|
||||
params: list[object] = []
|
||||
|
||||
for field, value in payload.items():
|
||||
if field == "backlink_count":
|
||||
set_parts.append("backlink_count = %s")
|
||||
params.append(int(value))
|
||||
else:
|
||||
set_parts.append(f"{field} = %s")
|
||||
params.append(value)
|
||||
|
||||
params.append(domain_id)
|
||||
cur.execute(
|
||||
f"update domains set {', '.join(set_parts)}, update_time = now() where id = %s",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
if "backlink_count" in payload:
|
||||
backlink_gt_10 = int(payload["backlink_count"]) > 10
|
||||
cur.execute("select id from domain_detections where domain_id = %s", (domain_id,))
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"update domain_detections set backlink_count_gt_10 = %s, update_time = now() where domain_id = %s",
|
||||
(backlink_gt_10, domain_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
insert into domain_detections (domain_id, backlink_count_gt_10, create_time, update_time)
|
||||
values (%s, %s, now(), now())
|
||||
""",
|
||||
(domain_id, backlink_gt_10),
|
||||
)
|
||||
|
||||
updated_count += 1
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"updated_count": updated_count,
|
||||
"fields": sorted(payload.keys()),
|
||||
}
|
||||
172
domain-api/app/services/export_service.py
Normal file
172
domain-api/app/services/export_service.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.files import exports_root, load_export_records, save_export_record, timestamp_filename
|
||||
from app.services.domains_service import (
|
||||
BEIAN_STATUS_LABELS,
|
||||
DETECT_STATUS_LABELS,
|
||||
REGISTER_STATUS_LABELS,
|
||||
REVIEW_STATUS_LABELS,
|
||||
USE_STATUS_LABELS,
|
||||
_build_domain_query_parts,
|
||||
)
|
||||
|
||||
|
||||
EXPORT_HEADERS = [
|
||||
("domain", "域名"),
|
||||
("register_status", "注册状态"),
|
||||
("use_status", "使用状态"),
|
||||
("detect_status", "检测状态"),
|
||||
("review_status", "复核状态"),
|
||||
("has_beian", "备案状态"),
|
||||
("website_url", "首页网址"),
|
||||
("beian_year", "备案年份"),
|
||||
("snapshot_years", "快照年份"),
|
||||
("backlink_count", "友链数"),
|
||||
("backlink_gt_10", "友链>10"),
|
||||
("detect_time", "检测时间"),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_payload(payload: dict) -> dict:
|
||||
data = dict(payload or {})
|
||||
data["page"] = int(data.get("page", 1) or 1)
|
||||
data["page_size"] = int(data.get("page_size", 100) or 100)
|
||||
data["page_count"] = int(data.get("page_count", 1) or 1)
|
||||
data["scope"] = data.get("scope", "page")
|
||||
data["type"] = data.get("type", "txt")
|
||||
return data
|
||||
|
||||
|
||||
def _query_export_rows(payload: dict) -> list[dict]:
|
||||
data = _normalize_payload(payload)
|
||||
from_clause, where_clause, params = _build_domain_query_parts(data)
|
||||
|
||||
limit_offset = ""
|
||||
if data["scope"] == "page":
|
||||
offset = (data["page"] - 1) * data["page_size"]
|
||||
limit_offset = " limit %s offset %s"
|
||||
params.extend([data["page_size"], offset])
|
||||
elif data["scope"] == "pages":
|
||||
offset = (data["page"] - 1) * data["page_size"]
|
||||
limit_value = data["page_size"] * max(data["page_count"], 1)
|
||||
limit_offset = " limit %s offset %s"
|
||||
params.extend([limit_value, offset])
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
select
|
||||
d.domain,
|
||||
d.register_status,
|
||||
d.use_status,
|
||||
d.detect_status,
|
||||
d.review_status,
|
||||
d.has_beian,
|
||||
d.website_url,
|
||||
d.beian_year,
|
||||
d.snapshot_years,
|
||||
d.backlink_count,
|
||||
coalesce(dd.backlink_count_gt_10, false) as backlink_gt_10,
|
||||
d.detect_time
|
||||
{from_clause}
|
||||
{where_clause}
|
||||
order by d.id desc
|
||||
{limit_offset}
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
result: list[dict] = []
|
||||
for row in rows:
|
||||
result.append(
|
||||
{
|
||||
"domain": row[0],
|
||||
"register_status": REGISTER_STATUS_LABELS.get(row[1], str(row[1])),
|
||||
"use_status": USE_STATUS_LABELS.get(row[2], str(row[2])),
|
||||
"detect_status": DETECT_STATUS_LABELS.get(row[3], str(row[3])),
|
||||
"review_status": REVIEW_STATUS_LABELS.get(row[4], str(row[4])),
|
||||
"has_beian": BEIAN_STATUS_LABELS.get(row[5], str(row[5])),
|
||||
"website_url": row[6] or "",
|
||||
"beian_year": row[7] or "",
|
||||
"snapshot_years": row[8] or "",
|
||||
"backlink_count": row[9] or 0,
|
||||
"backlink_gt_10": "是" if row[10] else "否",
|
||||
"detect_time": row[11].isoformat(sep=" ", timespec="seconds") if row[11] else "",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _write_txt(path, rows: list[dict]) -> None:
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for row in rows:
|
||||
handle.write(f"{row['domain']}\n")
|
||||
|
||||
|
||||
def _write_csv(path, rows: list[dict]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow([label for _, label in EXPORT_HEADERS])
|
||||
for row in rows:
|
||||
writer.writerow([row[key] for key, _ in EXPORT_HEADERS])
|
||||
|
||||
|
||||
def _write_xlsx(path, rows: list[dict]) -> None:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "domains"
|
||||
sheet.append([label for _, label in EXPORT_HEADERS])
|
||||
for row in rows:
|
||||
sheet.append([row[key] for key, _ in EXPORT_HEADERS])
|
||||
workbook.save(path)
|
||||
|
||||
|
||||
def create_export_file(payload: dict) -> dict:
|
||||
data = _normalize_payload(payload)
|
||||
rows = _query_export_rows(data)
|
||||
|
||||
ext = data["type"] if data["type"] in {"txt", "csv", "xlsx"} else "txt"
|
||||
filename = timestamp_filename("domain_export", ext)
|
||||
output_path = exports_root() / filename
|
||||
|
||||
if ext == "txt":
|
||||
_write_txt(output_path, rows)
|
||||
elif ext == "csv":
|
||||
_write_csv(output_path, rows)
|
||||
else:
|
||||
_write_xlsx(output_path, rows)
|
||||
|
||||
created_at = datetime.fromtimestamp(output_path.stat().st_mtime)
|
||||
record = {
|
||||
"filename": filename,
|
||||
"type": ext,
|
||||
"scope": data["scope"],
|
||||
"page": data["page"],
|
||||
"page_size": data["page_size"],
|
||||
"page_count": data["page_count"],
|
||||
"count": len(rows),
|
||||
"created_at": created_at.isoformat(sep=" ", timespec="seconds"),
|
||||
"download_path": f"/api/v1/exports/download/{filename}",
|
||||
}
|
||||
save_export_record(record)
|
||||
return record
|
||||
|
||||
|
||||
def list_exports() -> list[dict]:
|
||||
records = load_export_records()
|
||||
normalized: list[dict] = []
|
||||
for record in records:
|
||||
item = dict(record)
|
||||
created_at = item.get("created_at")
|
||||
if isinstance(created_at, (int, float)):
|
||||
item["created_at"] = datetime.fromtimestamp(created_at).isoformat(sep=" ", timespec="seconds")
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
114
domain-api/app/services/import_task_service.py
Normal file
114
domain-api/app/services/import_task_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.files import import_root, load_import_records, save_import_records
|
||||
from app.services.import_worker_service import import_domains_from_path
|
||||
|
||||
|
||||
_IMPORT_TASK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(sep=" ", timespec="seconds")
|
||||
|
||||
|
||||
def list_import_tasks() -> list[dict]:
|
||||
return load_import_records()
|
||||
|
||||
|
||||
def _save_tasks(tasks: list[dict]) -> None:
|
||||
save_import_records(tasks)
|
||||
|
||||
|
||||
def _update_task(task_id: str, **patch: object) -> dict | None:
|
||||
with _IMPORT_TASK_LOCK:
|
||||
tasks = load_import_records()
|
||||
target = next((item for item in tasks if item["task_id"] == task_id), None)
|
||||
if not target:
|
||||
return None
|
||||
target.update(patch)
|
||||
target["updated_at"] = _now()
|
||||
_save_tasks(tasks)
|
||||
return dict(target)
|
||||
|
||||
|
||||
def _run_import_task(task_id: str, file_path: str, source_type: int = 7) -> None:
|
||||
_update_task(task_id, status="running", started_at=_now(), message="导入任务开始执行")
|
||||
try:
|
||||
result = import_domains_from_path(Path(file_path), source_type=source_type)
|
||||
stats = result.get("stats", {})
|
||||
_update_task(
|
||||
task_id,
|
||||
status="completed",
|
||||
completed_at=_now(),
|
||||
result=result,
|
||||
message=(
|
||||
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)}"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
_update_task(
|
||||
task_id,
|
||||
status="failed",
|
||||
completed_at=_now(),
|
||||
message=f"导入失败:{exc}",
|
||||
)
|
||||
|
||||
|
||||
def create_import_task(content: bytes, filename: str, source_type: int = 7) -> dict:
|
||||
task_id = uuid4().hex
|
||||
safe_name = Path(filename).name or "domains.txt"
|
||||
target = import_root() / f"{task_id}_{safe_name}"
|
||||
target.write_bytes(content)
|
||||
|
||||
record = {
|
||||
"task_id": task_id,
|
||||
"filename": safe_name,
|
||||
"stored_path": str(target),
|
||||
"source_type": source_type,
|
||||
"status": "queued",
|
||||
"message": "文件已接收,等待处理",
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"started_at": "",
|
||||
"completed_at": "",
|
||||
"result": None,
|
||||
}
|
||||
|
||||
with _IMPORT_TASK_LOCK:
|
||||
tasks = load_import_records()
|
||||
tasks.insert(0, record)
|
||||
_save_tasks(tasks)
|
||||
|
||||
worker = threading.Thread(target=_run_import_task, args=(task_id, str(target), source_type), daemon=True)
|
||||
worker.start()
|
||||
return record
|
||||
|
||||
|
||||
def retry_import_task(task_id: str) -> dict:
|
||||
with _IMPORT_TASK_LOCK:
|
||||
tasks = load_import_records()
|
||||
target = next((item for item in tasks if item["task_id"] == task_id), None)
|
||||
if not target:
|
||||
raise ValueError("导入任务不存在")
|
||||
if target.get("status") == "running":
|
||||
raise ValueError("导入任务正在运行,不能重复执行")
|
||||
target["status"] = "queued"
|
||||
target["message"] = "任务已重新加入队列"
|
||||
target["started_at"] = ""
|
||||
target["completed_at"] = ""
|
||||
target["updated_at"] = _now()
|
||||
target["result"] = None
|
||||
_save_tasks(tasks)
|
||||
stored_path = target["stored_path"]
|
||||
source_type = int(target.get("source_type", 7))
|
||||
record = dict(target)
|
||||
|
||||
worker = threading.Thread(target=_run_import_task, args=(task_id, stored_path, source_type), daemon=True)
|
||||
worker.start()
|
||||
return record
|
||||
103
domain-api/app/services/import_worker_service.py
Normal file
103
domain-api/app/services/import_worker_service.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.files import import_root
|
||||
|
||||
|
||||
DOMAIN_PATTERN = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(com|net)$", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str | None:
|
||||
candidate = value.strip().lower()
|
||||
candidate = re.sub(r"^https?://", "", candidate)
|
||||
candidate = candidate.split("/")[0].strip(".")
|
||||
if candidate.startswith("www."):
|
||||
candidate = candidate[4:]
|
||||
if not DOMAIN_PATTERN.match(candidate):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||
raw_lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
total = 0
|
||||
normalized_rows: list[tuple[str, str]] = []
|
||||
invalid = 0
|
||||
|
||||
for line in raw_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
total += 1
|
||||
normalized = normalize_domain(line)
|
||||
if not normalized:
|
||||
invalid += 1
|
||||
continue
|
||||
tld = normalized.rsplit(".", 1)[-1]
|
||||
normalized_rows.append((normalized, tld))
|
||||
|
||||
domains = [row[0] for row in normalized_rows]
|
||||
existing_set: set[str] = set()
|
||||
inserted = 0
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if domains:
|
||||
cur.execute("select domain from domains where domain = any(%s)", (domains,))
|
||||
existing_set = {row[0] for row in cur.fetchall()}
|
||||
|
||||
for domain, tld in normalized_rows:
|
||||
if domain in existing_set:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
insert into domains (
|
||||
domain, tld, source_type, use_status, detect_status, register_status,
|
||||
has_beian, company_type, website_url, beian_year, snapshot_years,
|
||||
expire_date, create_time, update_time, review_status, detect_time,
|
||||
backlink_count, jucha_status, juziseo_status
|
||||
) values (
|
||||
%s, %s, %s, 0, 0, 0,
|
||||
1, null, null, null, null,
|
||||
null, now(), now(), 0, null,
|
||||
0, 0, 0
|
||||
)
|
||||
returning id
|
||||
""",
|
||||
(domain, tld, source_type),
|
||||
)
|
||||
domain_id = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
insert into detect_tasks (domain_id, task_type, status, priority, retry_count, create_time, update_time)
|
||||
values (%s, 1, 1, 5, 0, now(), now())
|
||||
""",
|
||||
(domain_id,),
|
||||
)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
|
||||
exists = len(existing_set)
|
||||
valid = len(normalized_rows)
|
||||
stats = {
|
||||
"total": total,
|
||||
"valid": valid,
|
||||
"added": inserted,
|
||||
"exists": exists,
|
||||
"invalid": invalid,
|
||||
"failed": max(valid - exists - inserted, 0),
|
||||
}
|
||||
return {
|
||||
"filename": file_path.name,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
def import_domains_from_upload(content: bytes, filename: str, source_type: int = 7) -> dict:
|
||||
safe_name = Path(filename).name or "domains.txt"
|
||||
target = import_root() / safe_name
|
||||
target.write_bytes(content)
|
||||
return import_domains_from_path(target, source_type=source_type)
|
||||
25
domain-api/app/services/imports_service.py
Normal file
25
domain-api/app/services/imports_service.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.files import load_import_records
|
||||
|
||||
|
||||
def get_import_summary() -> dict:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select count(*) from domains")
|
||||
domains_total = cur.fetchone()[0]
|
||||
cur.execute("select count(*) from detect_tasks")
|
||||
tasks_total = cur.fetchone()[0]
|
||||
cur.execute("select max(create_time) from domains")
|
||||
last_import_time = cur.fetchone()[0]
|
||||
tasks = load_import_records()
|
||||
running_tasks = sum(1 for item in tasks if item.get("status") in {"queued", "running"})
|
||||
|
||||
return {
|
||||
"domains_total": domains_total,
|
||||
"detect_tasks_total": tasks_total,
|
||||
"last_import_time": last_import_time.isoformat() if last_import_time else None,
|
||||
"import_task_total": len(tasks),
|
||||
"running_import_tasks": running_tasks,
|
||||
}
|
||||
86
domain-api/app/services/logs_service.py
Normal file
86
domain-api/app/services/logs_service.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
from app.core.files import runtime_root, tail_lines
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
from app.services.settings_service import get_settings_payload
|
||||
|
||||
|
||||
def _tail_api_runtime_log(filename: str, max_lines: int = 80) -> list[str]:
|
||||
path = Path(__file__).resolve().parents[2] / "runtime" / "logs" / filename
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read().splitlines()[-max_lines:]
|
||||
|
||||
|
||||
def latest_logs() -> dict:
|
||||
worker_lines = tail_lines("detect_worker.log", max_lines=80)
|
||||
desktop_lines = tail_lines("logs/app.log", max_lines=60)
|
||||
api_stdout_lines = _tail_api_runtime_log("domain-api.stdout.log", max_lines=60)
|
||||
api_stderr_lines = _tail_api_runtime_log("domain-api.stderr.log", max_lines=60)
|
||||
api_lines = api_stderr_lines + api_stdout_lines + desktop_lines
|
||||
|
||||
summary = "未发现显著异常"
|
||||
level = "info"
|
||||
if any("Redis订阅失败" in line for line in worker_lines):
|
||||
summary = "检测端存在 Redis 订阅读超时重连,需要后续继续优化订阅策略。"
|
||||
level = "warning"
|
||||
elif any("无可用代理" in line for line in worker_lines):
|
||||
summary = "代理池存在无可用代理情况,检测端当前可能回落直连或等待代理。"
|
||||
level = "warning"
|
||||
elif any("Traceback" in line or "ERROR:" in line for line in api_lines):
|
||||
summary = "API 运行日志中发现异常堆栈,请优先检查 domain-api stderr 日志。"
|
||||
level = "warning"
|
||||
|
||||
return {
|
||||
"worker": worker_lines,
|
||||
"api": api_lines,
|
||||
"diagnostics": {
|
||||
"summary": summary,
|
||||
"level": level,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_diagnostic_bundle() -> tuple[Path, str]:
|
||||
payload = latest_logs()
|
||||
generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
bundle_dir = runtime_root() / "diagnostics"
|
||||
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = bundle_dir / f"diagnostic_bundle_{timestamp}.zip"
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
detect_status = get_detect_status()
|
||||
runtime_status = get_runtime_status()
|
||||
|
||||
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||
archive.writestr(
|
||||
"summary.json",
|
||||
json.dumps(
|
||||
{
|
||||
"generated_at": generated_at,
|
||||
"diagnostics": payload["diagnostics"],
|
||||
"runtime_status": runtime_status,
|
||||
"detect_status": detect_status,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
archive.writestr(
|
||||
"settings_snapshot.json",
|
||||
json.dumps(settings_payload, ensure_ascii=False, indent=2, default=str),
|
||||
)
|
||||
archive.writestr("logs/worker.log", "\n".join(payload["worker"]))
|
||||
archive.writestr("logs/api.log", "\n".join(payload["api"]))
|
||||
|
||||
return zip_path, zip_path.name
|
||||
65
domain-api/app/services/runtime_control_service.py
Normal file
65
domain-api/app/services/runtime_control_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.worker_control_service import start_worker, stop_worker
|
||||
|
||||
|
||||
def _workspace_root() -> Path:
|
||||
return Path(settings.domain_root).parent
|
||||
|
||||
|
||||
def _run_shell(command: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def restart_api() -> tuple[bool, str]:
|
||||
runtime = get_runtime_settings()
|
||||
api_service_name = runtime.get("api_service_name", settings.api_service_name)
|
||||
worker_mode = runtime.get("worker_mode", settings.worker_mode)
|
||||
|
||||
if worker_mode == "linux-systemd":
|
||||
result = _run_shell(["systemctl", "restart", api_service_name], timeout=30)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "重启 Linux API 失败").strip()
|
||||
return True, f"Linux API 重启命令已发送: {api_service_name}"
|
||||
|
||||
if os.name != "nt":
|
||||
return False, "当前仅实现 Windows 本地 API 重启,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||
|
||||
workspace = _workspace_root()
|
||||
stop_script = workspace / "stop_domain_api.ps1"
|
||||
start_script = workspace / "start_domain_api.ps1"
|
||||
if not stop_script.exists() or not start_script.exists():
|
||||
return False, "未找到 API 启停脚本"
|
||||
|
||||
command = (
|
||||
"Start-Process powershell "
|
||||
"-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', "
|
||||
f"\"Start-Sleep -Seconds 2; & '{stop_script}'; Start-Sleep -Seconds 1; & '{start_script}'\""
|
||||
)
|
||||
result = _run_shell(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], timeout=20)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "重启 API 失败").strip()
|
||||
return True, "API 重启命令已发送"
|
||||
|
||||
|
||||
def runtime_action(action: str) -> tuple[bool, str, dict]:
|
||||
if action == "start_worker":
|
||||
ok, message = start_worker()
|
||||
return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True}
|
||||
if action == "stop_worker":
|
||||
ok, message = stop_worker()
|
||||
return ok, message, {"action": action, "poll_after_seconds": 2, "refresh_runtime": True}
|
||||
if action == "restart_api":
|
||||
ok, message = restart_api()
|
||||
return ok, message, {"action": action, "poll_after_seconds": 4, "refresh_runtime": True}
|
||||
return False, f"不支持的运行时动作: {action}", {
|
||||
"action": action,
|
||||
"poll_after_seconds": 0,
|
||||
"refresh_runtime": False,
|
||||
}
|
||||
28
domain-api/app/services/runtime_settings_service.py
Normal file
28
domain-api/app/services/runtime_settings_service.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.files import read_runtime_json, write_runtime_json
|
||||
|
||||
|
||||
DEFAULT_RUNTIME_SETTINGS = {
|
||||
"worker_mode": settings.worker_mode,
|
||||
"worker_service_name": settings.worker_service_name,
|
||||
"api_service_name": settings.api_service_name,
|
||||
}
|
||||
|
||||
|
||||
def get_runtime_settings() -> dict:
|
||||
stored = read_runtime_json("runtime_settings.json", default={})
|
||||
result = dict(DEFAULT_RUNTIME_SETTINGS)
|
||||
result.update(stored or {})
|
||||
return result
|
||||
|
||||
|
||||
def update_runtime_settings(payload: dict) -> dict:
|
||||
current = get_runtime_settings()
|
||||
merged = dict(current)
|
||||
for key in DEFAULT_RUNTIME_SETTINGS:
|
||||
if key in payload and payload[key] is not None:
|
||||
merged[key] = payload[key]
|
||||
write_runtime_json("runtime_settings.json", merged)
|
||||
return merged
|
||||
113
domain-api/app/services/runtime_status_service.py
Normal file
113
domain-api/app/services/runtime_status_service.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.redis_client import get_redis
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
|
||||
def _runtime_log_path(filename: str) -> str:
|
||||
path = Path(__file__).resolve().parents[2] / "runtime" / "logs" / filename
|
||||
return str(path)
|
||||
|
||||
|
||||
def get_runtime_status() -> dict:
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_runtime = detect_worker_runtime()
|
||||
api_pid = os.getpid()
|
||||
|
||||
return {
|
||||
"api": {
|
||||
"service": "domain-api",
|
||||
"version": "0.1.0",
|
||||
"api_prefix": settings.api_prefix,
|
||||
"pid": api_pid,
|
||||
"host": settings.api_host,
|
||||
"port": settings.api_port,
|
||||
"mode": runtime_settings.get("worker_mode", "windows-local"),
|
||||
"service_name": runtime_settings.get("api_service_name", settings.api_service_name),
|
||||
"health_url": f"http://127.0.0.1:{settings.api_port}/health",
|
||||
"stdout_log": _runtime_log_path("domain-api.stdout.log"),
|
||||
"stderr_log": _runtime_log_path("domain-api.stderr.log"),
|
||||
},
|
||||
"worker": {
|
||||
"mode": worker_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||
"service_name": runtime_settings.get("worker_service_name", settings.worker_service_name),
|
||||
"running": worker_runtime.get("running", False),
|
||||
"process_count": worker_runtime.get("process_count", 0),
|
||||
"latest_start_time": worker_runtime.get("latest_start_time", ""),
|
||||
"message": worker_runtime.get("message", ""),
|
||||
"log_path": str(Path(settings.domain_root) / "detect_worker.log"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_runtime_preflight() -> dict:
|
||||
runtime_settings = get_runtime_settings()
|
||||
checks: list[dict[str, object]] = []
|
||||
|
||||
domain_root = Path(settings.domain_root)
|
||||
checks.append(
|
||||
{
|
||||
"key": "domain_root",
|
||||
"label": "domainCheck 目录",
|
||||
"ok": domain_root.exists(),
|
||||
"message": str(domain_root),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select 1")
|
||||
cur.fetchone()
|
||||
checks.append({"key": "database", "label": "PostgreSQL", "ok": True, "message": f"{settings.db_host}:{settings.db_port}/{settings.db_database}"})
|
||||
except Exception as exc:
|
||||
checks.append({"key": "database", "label": "PostgreSQL", "ok": False, "message": str(exc)})
|
||||
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
redis_client.ping()
|
||||
checks.append({"key": "redis", "label": "Redis", "ok": True, "message": f"{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"})
|
||||
except Exception as exc:
|
||||
checks.append({"key": "redis", "label": "Redis", "ok": False, "message": str(exc)})
|
||||
|
||||
worker_mode = runtime_settings.get("worker_mode", "windows-local")
|
||||
checks.append({"key": "worker_mode", "label": "运行模式", "ok": True, "message": worker_mode})
|
||||
|
||||
if worker_mode == "linux-systemd":
|
||||
checks.append(
|
||||
{
|
||||
"key": "worker_service_name",
|
||||
"label": "Worker service 名",
|
||||
"ok": bool(runtime_settings.get("worker_service_name")),
|
||||
"message": runtime_settings.get("worker_service_name", ""),
|
||||
}
|
||||
)
|
||||
checks.append(
|
||||
{
|
||||
"key": "api_service_name",
|
||||
"label": "API service 名",
|
||||
"ok": bool(runtime_settings.get("api_service_name")),
|
||||
"message": runtime_settings.get("api_service_name", ""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"key": "windows_scripts",
|
||||
"label": "Windows 启停脚本",
|
||||
"ok": (Path(settings.domain_root).parent / "start_domain_api.ps1").exists() and (Path(settings.domain_root).parent / "stop_domain_api.ps1").exists(),
|
||||
"message": "start_domain_api.ps1 / stop_domain_api.ps1",
|
||||
}
|
||||
)
|
||||
|
||||
overall_ok = all(bool(item["ok"]) for item in checks)
|
||||
return {
|
||||
"ok": overall_ok,
|
||||
"checks": checks,
|
||||
}
|
||||
172
domain-api/app/services/settings_service.py
Normal file
172
domain-api/app/services/settings_service.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.files import read_json, settings_backup_root, write_json
|
||||
from app.core.redis_client import get_redis
|
||||
from app.services.runtime_settings_service import get_runtime_settings, update_runtime_settings
|
||||
|
||||
|
||||
REDIS_KEYS = {
|
||||
"detect_options": "domain_tool:detect_options",
|
||||
"proxy_config": "domain_tool:proxy_config",
|
||||
"thread_count": "domain_tool:thread_count",
|
||||
}
|
||||
|
||||
DETECT_OPTION_KEYS = {
|
||||
"detect_register",
|
||||
"detect_wayback",
|
||||
"detect_chinaz",
|
||||
"detect_aizhan",
|
||||
"detect_baidu_site",
|
||||
"detect_360_site",
|
||||
"detect_jucha",
|
||||
"detect_juziseo",
|
||||
}
|
||||
|
||||
|
||||
def get_settings_payload() -> dict:
|
||||
detect_options = read_json("detect_options.json", default={})
|
||||
proxy_config = read_json("proxy_config.json", default={})
|
||||
thread_count = read_json("thread_count.json", default={"thread_count": "2"})
|
||||
|
||||
redis_client = get_redis()
|
||||
try:
|
||||
if redis_detect_options := redis_client.get(REDIS_KEYS["detect_options"]):
|
||||
detect_options = json.loads(redis_detect_options)
|
||||
if redis_proxy_config := redis_client.get(REDIS_KEYS["proxy_config"]):
|
||||
proxy_config = json.loads(redis_proxy_config)
|
||||
if redis_thread_count := redis_client.get(REDIS_KEYS["thread_count"]):
|
||||
thread_count = {"thread_count": str(redis_thread_count)}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"detect_options": detect_options,
|
||||
"proxy_config": proxy_config,
|
||||
"thread_count": int(thread_count.get("thread_count", 2)),
|
||||
"runtime_settings": get_runtime_settings(),
|
||||
}
|
||||
|
||||
|
||||
def update_settings_payload(payload: dict) -> dict:
|
||||
current = get_settings_payload()
|
||||
detect_options = payload.get("detect_options", current["detect_options"])
|
||||
proxy_config = payload.get("proxy_config", current["proxy_config"])
|
||||
thread_count = int(payload.get("thread_count", current["thread_count"]))
|
||||
runtime_settings = update_runtime_settings(payload.get("runtime_settings", current["runtime_settings"]))
|
||||
|
||||
write_json("detect_options.json", detect_options)
|
||||
write_json("proxy_config.json", proxy_config)
|
||||
write_json("thread_count.json", {"thread_count": str(thread_count)})
|
||||
|
||||
redis_client = get_redis()
|
||||
try:
|
||||
redis_client.set(REDIS_KEYS["detect_options"], json.dumps(detect_options, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:detect_options:update", json.dumps(detect_options, ensure_ascii=False))
|
||||
redis_client.set(REDIS_KEYS["proxy_config"], json.dumps(proxy_config, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:proxy_config:update", json.dumps(proxy_config, ensure_ascii=False))
|
||||
redis_client.set(REDIS_KEYS["thread_count"], thread_count)
|
||||
redis_client.publish("domain_tool:thread_count:update", str(thread_count))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"detect_options": detect_options,
|
||||
"proxy_config": proxy_config,
|
||||
"thread_count": thread_count,
|
||||
"runtime_settings": runtime_settings,
|
||||
}
|
||||
|
||||
|
||||
def export_settings_snapshot() -> dict:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"exported_at": datetime.now().isoformat(),
|
||||
"source": "domain-api",
|
||||
"settings": get_settings_payload(),
|
||||
}
|
||||
|
||||
|
||||
def import_settings_snapshot(payload: dict) -> dict:
|
||||
settings_payload = payload.get("settings", payload)
|
||||
validate_settings_payload(settings_payload)
|
||||
backup_current_settings("import")
|
||||
return update_settings_payload(settings_payload)
|
||||
|
||||
|
||||
def validate_settings_payload(payload: dict) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("invalid settings payload")
|
||||
|
||||
if "thread_count" in payload:
|
||||
try:
|
||||
thread_count = int(payload["thread_count"])
|
||||
except Exception as exc:
|
||||
raise ValueError("thread_count must be an integer") from exc
|
||||
if thread_count < 1 or thread_count > 256:
|
||||
raise ValueError("thread_count out of range")
|
||||
|
||||
if "detect_options" in payload:
|
||||
detect_options = payload["detect_options"]
|
||||
if not isinstance(detect_options, dict):
|
||||
raise ValueError("detect_options must be an object")
|
||||
detect_order = detect_options.get("detect_order", [])
|
||||
if detect_order and not isinstance(detect_order, list):
|
||||
raise ValueError("detect_order must be an array")
|
||||
if isinstance(detect_order, list):
|
||||
unknown_keys = [item for item in detect_order if item not in DETECT_OPTION_KEYS]
|
||||
if unknown_keys:
|
||||
raise ValueError(f"unknown detect option keys: {', '.join(unknown_keys)}")
|
||||
|
||||
if "proxy_config" in payload:
|
||||
proxy_config = payload["proxy_config"]
|
||||
if not isinstance(proxy_config, dict):
|
||||
raise ValueError("proxy_config must be an object")
|
||||
proxy_urls = proxy_config.get("proxy_urls", [])
|
||||
if proxy_urls and not isinstance(proxy_urls, list):
|
||||
raise ValueError("proxy_urls must be an array")
|
||||
|
||||
if "runtime_settings" in payload:
|
||||
runtime_settings = payload["runtime_settings"]
|
||||
if not isinstance(runtime_settings, dict):
|
||||
raise ValueError("runtime_settings must be an object")
|
||||
worker_mode = runtime_settings.get("worker_mode")
|
||||
if worker_mode and worker_mode not in {"windows-local", "linux-systemd"}:
|
||||
raise ValueError("worker_mode must be windows-local or linux-systemd")
|
||||
|
||||
|
||||
def backup_current_settings(reason: str = "manual") -> dict:
|
||||
snapshot = export_settings_snapshot()
|
||||
snapshot["backup_reason"] = reason
|
||||
filename = f"settings_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
path = settings_backup_root() / filename
|
||||
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return {
|
||||
"filename": filename,
|
||||
"path": str(path),
|
||||
}
|
||||
|
||||
|
||||
def list_settings_backups(limit: int = 20) -> list[dict]:
|
||||
root = settings_backup_root()
|
||||
files = sorted(root.glob("settings_backup_*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
|
||||
result: list[dict] = []
|
||||
for item in files[:limit]:
|
||||
backup_reason = ""
|
||||
try:
|
||||
payload = json.loads(item.read_text(encoding="utf-8"))
|
||||
backup_reason = str(payload.get("backup_reason", ""))
|
||||
except Exception:
|
||||
backup_reason = ""
|
||||
result.append(
|
||||
{
|
||||
"filename": item.name,
|
||||
"path": str(item),
|
||||
"size": item.stat().st_size,
|
||||
"modified_at": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
|
||||
"backup_reason": backup_reason,
|
||||
}
|
||||
)
|
||||
return result
|
||||
188
domain-api/app/services/worker_control_service.py
Normal file
188
domain-api/app/services/worker_control_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
|
||||
|
||||
def _domain_root() -> Path:
|
||||
return Path(settings.domain_root)
|
||||
|
||||
|
||||
def _runtime_config() -> dict:
|
||||
return get_runtime_settings()
|
||||
|
||||
|
||||
def _run_powershell(command: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _run_shell(command: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _windows_runtime() -> dict:
|
||||
command = """
|
||||
$targets = Get-CimInstance Win32_Process -Filter "name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*detect_worker.py*' } |
|
||||
Select-Object ProcessId, CommandLine
|
||||
if (-not $targets) {
|
||||
Write-Output '{"running":false,"process_count":0,"latest_start_time":"","mode":"windows-local"}'
|
||||
exit 0
|
||||
}
|
||||
$latest = $null
|
||||
foreach ($item in $targets) {
|
||||
try {
|
||||
$proc = Get-Process -Id $item.ProcessId -ErrorAction Stop
|
||||
if (-not $latest -or $proc.StartTime -gt $latest.StartTime) {
|
||||
$latest = $proc
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$payload = @{
|
||||
running = $true
|
||||
process_count = @($targets).Count
|
||||
latest_start_time = if ($latest) { $latest.StartTime.ToString('yyyy-MM-dd HH:mm:ss') } else { '' }
|
||||
mode = 'windows-local'
|
||||
} | ConvertTo-Json -Compress
|
||||
Write-Output $payload
|
||||
"""
|
||||
result = _run_powershell(command)
|
||||
output = (result.stdout or "").strip()
|
||||
if result.returncode != 0 or not output:
|
||||
return {
|
||||
"mode": "windows-local",
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": (result.stderr or result.stdout or "worker runtime probe failed").strip(),
|
||||
}
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"mode": "windows-local",
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": output,
|
||||
}
|
||||
payload.setdefault("message", "")
|
||||
return payload
|
||||
|
||||
|
||||
def _linux_runtime() -> dict:
|
||||
runtime = _runtime_config()
|
||||
service_name = runtime["worker_service_name"]
|
||||
result = _run_shell(["systemctl", "show", service_name, "--no-page", "--property=ActiveState,SubState,MainPID"])
|
||||
output = (result.stdout or result.stderr or "").strip()
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"mode": "linux-systemd",
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": output or f"systemd service {service_name} not available",
|
||||
}
|
||||
|
||||
data: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
data[key] = value
|
||||
main_pid = int(data.get("MainPID", "0") or 0)
|
||||
active_state = data.get("ActiveState", "")
|
||||
sub_state = data.get("SubState", "")
|
||||
return {
|
||||
"mode": "linux-systemd",
|
||||
"running": active_state == "active",
|
||||
"process_count": 1 if main_pid > 0 else 0,
|
||||
"latest_start_time": "",
|
||||
"message": f"{active_state}/{sub_state}" if active_state else "",
|
||||
}
|
||||
|
||||
|
||||
def detect_worker_runtime() -> dict:
|
||||
runtime = _runtime_config()
|
||||
worker_mode = runtime["worker_mode"]
|
||||
if worker_mode == "linux-systemd":
|
||||
return _linux_runtime()
|
||||
if os.name == "nt":
|
||||
return _windows_runtime()
|
||||
return {
|
||||
"mode": worker_mode,
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": f"unsupported worker_mode: {worker_mode}",
|
||||
}
|
||||
|
||||
|
||||
def start_worker() -> tuple[bool, str]:
|
||||
runtime = _runtime_config()
|
||||
worker_mode = runtime["worker_mode"]
|
||||
service_name = runtime["worker_service_name"]
|
||||
if worker_mode == "linux-systemd":
|
||||
result = _run_shell(["systemctl", "start", service_name], timeout=30)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "启动 Linux Worker 失败").strip()
|
||||
return True, f"Linux Worker 启动命令已发送: {service_name}"
|
||||
|
||||
if os.name != "nt":
|
||||
return False, "当前仅实现 Windows 本地 Worker 启动,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||
|
||||
script_path = _domain_root() / "start_worker.ps1"
|
||||
if not script_path.exists():
|
||||
return False, f"未找到启动脚本: {script_path}"
|
||||
|
||||
command = (
|
||||
"Start-Process powershell "
|
||||
f"-ArgumentList '-ExecutionPolicy Bypass -File \"{script_path}\"' "
|
||||
f"-WorkingDirectory '{_domain_root()}'"
|
||||
)
|
||||
result = _run_powershell(command)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "启动检测端失败").strip()
|
||||
return True, "检测端启动命令已发送"
|
||||
|
||||
|
||||
def stop_worker() -> tuple[bool, str]:
|
||||
runtime = _runtime_config()
|
||||
worker_mode = runtime["worker_mode"]
|
||||
service_name = runtime["worker_service_name"]
|
||||
if worker_mode == "linux-systemd":
|
||||
result = _run_shell(["systemctl", "stop", service_name], timeout=30)
|
||||
if result.returncode != 0:
|
||||
return False, (result.stderr or result.stdout or "停止 Linux Worker 失败").strip()
|
||||
return True, f"Linux Worker 停止命令已发送: {service_name}"
|
||||
|
||||
if os.name != "nt":
|
||||
return False, "当前仅实现 Windows 本地 Worker 停止,Linux 请将 worker_mode 设为 linux-systemd。"
|
||||
|
||||
command = """
|
||||
$targets = Get-CimInstance Win32_Process -Filter "name='python.exe'" |
|
||||
Where-Object { $_.CommandLine -like '*detect_worker.py*' } |
|
||||
Select-Object -ExpandProperty ProcessId
|
||||
if (-not $targets) {
|
||||
Write-Output 'NO_PROCESS'
|
||||
exit 0
|
||||
}
|
||||
$targets | ForEach-Object { Stop-Process -Id $_ -Force }
|
||||
Write-Output ('STOPPED:' + (($targets | Measure-Object).Count))
|
||||
"""
|
||||
result = _run_powershell(command)
|
||||
output = (result.stdout or result.stderr or "").strip()
|
||||
if result.returncode != 0:
|
||||
return False, output or "停止检测端失败"
|
||||
if "NO_PROCESS" in output:
|
||||
return True, "当前没有运行中的检测端进程"
|
||||
return True, output or "检测端已停止"
|
||||
179
domain-api/deploy/linux/README.md
Normal file
179
domain-api/deploy/linux/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# domainCheck Linux 部署说明
|
||||
|
||||
本文档用于将 `domain-api` 与 `domainCheck Worker` 部署到 Linux,并由 `systemd` 托管。
|
||||
|
||||
## 一、建议目录结构
|
||||
|
||||
```text
|
||||
/opt/domaincheck
|
||||
├── domain-api
|
||||
├── domain-web
|
||||
└── domainCheck
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `domain-api` 提供 Web 后台接口
|
||||
- `domain-web` 为前端静态文件项目
|
||||
- `domainCheck` 保留当前检测核心与 Worker
|
||||
|
||||
## 二、准备 Python 环境
|
||||
|
||||
建议使用 Python 3.11。
|
||||
|
||||
```bash
|
||||
cd /opt/domaincheck/domainCheck
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
cd /opt/domaincheck/domain-api
|
||||
pip install fastapi uvicorn pydantic-settings psycopg2-binary redis openpyxl python-multipart
|
||||
```
|
||||
|
||||
## 三、准备配置文件
|
||||
|
||||
### 1. domainCheck/.env
|
||||
|
||||
`domain-api` 默认会读取 `/opt/domaincheck/domainCheck/.env`。
|
||||
|
||||
至少确认下面这些配置正确:
|
||||
|
||||
```env
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=domain
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
```
|
||||
|
||||
### 2. 可选的 domain-api 环境变量
|
||||
|
||||
可以参考 `.env.example`。
|
||||
|
||||
常用项:
|
||||
|
||||
- `API_HOST`
|
||||
- `API_PORT`
|
||||
- `WORKER_MODE`
|
||||
- `WORKER_SERVICE_NAME`
|
||||
- `API_SERVICE_NAME`
|
||||
- `ADMIN_USERNAME`
|
||||
- `ADMIN_PASSWORD`
|
||||
|
||||
## 四、部署 systemd
|
||||
|
||||
模板文件:
|
||||
|
||||
- `deploy/systemd/domain-api.service`
|
||||
- `deploy/systemd/domain-worker.service`
|
||||
|
||||
复制到系统目录:
|
||||
|
||||
```bash
|
||||
sudo cp /opt/domaincheck/domain-api/deploy/systemd/domain-api.service /etc/systemd/system/domaincheck-api.service
|
||||
sudo cp /opt/domaincheck/domain-api/deploy/systemd/domain-worker.service /etc/systemd/system/domaincheck-worker.service
|
||||
```
|
||||
|
||||
然后按实际机器修改:
|
||||
|
||||
- `WorkingDirectory`
|
||||
- `ExecStart`
|
||||
- `User`
|
||||
- `Group`
|
||||
- `Environment`
|
||||
|
||||
## 五、启动顺序
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable domaincheck-api
|
||||
sudo systemctl enable domaincheck-worker
|
||||
sudo systemctl start domaincheck-api
|
||||
sudo systemctl start domaincheck-worker
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
sudo systemctl status domaincheck-api
|
||||
sudo systemctl status domaincheck-worker
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
journalctl -u domaincheck-api -n 200 --no-pager
|
||||
journalctl -u domaincheck-worker -n 200 --no-pager
|
||||
```
|
||||
|
||||
## 六、联调检查
|
||||
|
||||
### 1. API 健康检查
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8100/health
|
||||
```
|
||||
|
||||
期望看到:
|
||||
|
||||
- `status=ok`
|
||||
- `worker_mode=linux-systemd`
|
||||
|
||||
### 2. Web 后台系统设置
|
||||
|
||||
在 Web 后台里确认:
|
||||
|
||||
- `Worker 运行模式 = linux-systemd`
|
||||
- `Worker 服务名 = domaincheck-worker`
|
||||
- `API 服务名 = domaincheck-api`
|
||||
|
||||
### 3. 运行中心
|
||||
|
||||
进入 `运行中心`,确认:
|
||||
|
||||
- API 在线
|
||||
- Worker 在线
|
||||
- 进程数大于 0
|
||||
- 最近启动时间正常
|
||||
|
||||
### 4. 运行 API 自测脚本
|
||||
|
||||
```bash
|
||||
cd /opt/domaincheck/domain-api
|
||||
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100
|
||||
```
|
||||
|
||||
如果同时希望把 Web 首页一起纳入检查:
|
||||
|
||||
```bash
|
||||
python deploy/linux/smoke_test.py --base-url http://127.0.0.1:8100 --web-url http://127.0.0.1
|
||||
```
|
||||
|
||||
## 七、上线建议
|
||||
|
||||
- 先保持 `Windows 桌面版 + Web/Linux` 并行一段时间
|
||||
- 先让 Web 后台接管设置、导入、筛选、导出、日志
|
||||
- 再让 Linux Worker 接管主检测任务
|
||||
- 确认稳定后,再逐步淡出桌面检测端
|
||||
|
||||
## 八、联调诊断采集
|
||||
|
||||
如需导出一份联调诊断包,可执行:
|
||||
|
||||
```bash
|
||||
cd /opt/domaincheck/domain-api/deploy/linux
|
||||
bash collect_diagnostics.sh /opt/domaincheck
|
||||
```
|
||||
|
||||
会输出:
|
||||
|
||||
- `diagnostics_dir=...`
|
||||
- `diagnostics_archive=...`
|
||||
|
||||
把生成的归档包发回即可继续排障。
|
||||
60
domain-api/deploy/linux/collect_diagnostics.sh
Normal file
60
domain-api/deploy/linux/collect_diagnostics.sh
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="${1:-/opt/domaincheck}"
|
||||
OUT_DIR="${2:-$BASE_DIR/diagnostics}"
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
BUNDLE_DIR="$OUT_DIR/diag_$STAMP"
|
||||
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
|
||||
write_cmd() {
|
||||
local name="$1"
|
||||
shift
|
||||
{
|
||||
echo "# command: $*"
|
||||
echo
|
||||
"$@"
|
||||
} > "$BUNDLE_DIR/$name.txt" 2>&1 || true
|
||||
}
|
||||
|
||||
copy_if_exists() {
|
||||
local src="$1"
|
||||
local dest="$2"
|
||||
if [ -f "$src" ]; then
|
||||
cp "$src" "$dest"
|
||||
fi
|
||||
}
|
||||
|
||||
write_cmd "systemctl_api" systemctl status domaincheck-api --no-pager
|
||||
write_cmd "systemctl_worker" systemctl status domaincheck-worker --no-pager
|
||||
write_cmd "journal_api" journalctl -u domaincheck-api -n 200 --no-pager
|
||||
write_cmd "journal_worker" journalctl -u domaincheck-worker -n 200 --no-pager
|
||||
write_cmd "api_health_curl" curl -sS http://127.0.0.1:8100/health
|
||||
write_cmd "api_preflight_curl" curl -sS http://127.0.0.1:8100/api/v1/runtime/preflight
|
||||
write_cmd "api_runtime_curl" curl -sS http://127.0.0.1:8100/api/v1/runtime/status
|
||||
write_cmd "ps_processes" ps -ef
|
||||
write_cmd "ss_listen" ss -lntp
|
||||
write_cmd "df_h" df -h
|
||||
write_cmd "free_h" free -h
|
||||
|
||||
copy_if_exists "$BASE_DIR/domainCheck/.env" "$BUNDLE_DIR/domainCheck.env"
|
||||
copy_if_exists "$BASE_DIR/domain-api/.env" "$BUNDLE_DIR/domain-api.env"
|
||||
|
||||
if [ -d "$BASE_DIR/domain-api/runtime" ]; then
|
||||
cp -r "$BASE_DIR/domain-api/runtime" "$BUNDLE_DIR/domain-api-runtime"
|
||||
fi
|
||||
|
||||
if [ -f "$BASE_DIR/domainCheck/logs/app.log" ]; then
|
||||
tail -n 300 "$BASE_DIR/domainCheck/logs/app.log" > "$BUNDLE_DIR/app_tail.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -f "$BASE_DIR/domainCheck/detect_worker.log" ]; then
|
||||
tail -n 500 "$BASE_DIR/domainCheck/detect_worker.log" > "$BUNDLE_DIR/detect_worker_tail.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
ARCHIVE="$OUT_DIR/diag_$STAMP.tar.gz"
|
||||
tar -czf "$ARCHIVE" -C "$OUT_DIR" "diag_$STAMP"
|
||||
|
||||
echo "diagnostics_dir=$BUNDLE_DIR"
|
||||
echo "diagnostics_archive=$ARCHIVE"
|
||||
103
domain-api/deploy/linux/smoke_test.py
Normal file
103
domain-api/deploy/linux/smoke_test.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _fetch_json(session: requests.Session, url: str, timeout: int = 15) -> tuple[bool, str, dict | None]:
|
||||
try:
|
||||
response = session.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return True, f"{response.status_code}", response.json()
|
||||
except Exception as exc:
|
||||
return False, str(exc), None
|
||||
|
||||
|
||||
def _fetch_text(session: requests.Session, url: str, timeout: int = 15) -> tuple[bool, str]:
|
||||
try:
|
||||
response = session.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return True, f"{response.status_code}"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(description="domainCheck Web/API smoke test")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8100", help="domain-api base URL")
|
||||
parser.add_argument("--web-url", default="", help="optional domain-web URL, e.g. http://127.0.0.1:3200")
|
||||
parser.add_argument("--output", default="", help="optional JSON report output path")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = args.base_url.rstrip("/")
|
||||
session = requests.Session()
|
||||
|
||||
checks: list[dict[str, object]] = []
|
||||
|
||||
endpoints = [
|
||||
("health", f"{base_url}/health"),
|
||||
("runtime_status", f"{base_url}/api/v1/runtime/status"),
|
||||
("runtime_preflight", f"{base_url}/api/v1/runtime/preflight"),
|
||||
("dashboard_overview", f"{base_url}/api/v1/dashboard/overview"),
|
||||
("settings_export", f"{base_url}/api/v1/settings/export"),
|
||||
("settings_backups", f"{base_url}/api/v1/settings/backups"),
|
||||
("detect_status", f"{base_url}/api/v1/detect/status"),
|
||||
("imports_summary", f"{base_url}/api/v1/imports/summary"),
|
||||
("exports", f"{base_url}/api/v1/exports"),
|
||||
("logs_latest", f"{base_url}/api/v1/logs/latest"),
|
||||
]
|
||||
|
||||
for name, url in endpoints:
|
||||
ok, message, payload = _fetch_json(session, url)
|
||||
checks.append(
|
||||
{
|
||||
"name": name,
|
||||
"url": url,
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"payload_excerpt": payload if ok and name in {"health", "runtime_preflight", "settings_export"} else None,
|
||||
}
|
||||
)
|
||||
|
||||
if args.web_url:
|
||||
web_url = args.web_url.rstrip("/")
|
||||
ok, message = _fetch_text(session, web_url)
|
||||
checks.append(
|
||||
{
|
||||
"name": "web_home",
|
||||
"url": web_url,
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"payload_excerpt": None,
|
||||
}
|
||||
)
|
||||
|
||||
passed = all(bool(item["ok"]) for item in checks)
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"base_url": base_url,
|
||||
"web_url": args.web_url.rstrip("/") if args.web_url else "",
|
||||
"ok": passed,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as handle:
|
||||
json.dump(report, handle, ensure_ascii=False, indent=2)
|
||||
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
18
domain-api/deploy/systemd/domain-api.service
Normal file
18
domain-api/deploy/systemd/domain-api.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=domainCheck API
|
||||
After=network.target redis.service postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/domaincheck/domain-api
|
||||
Environment="WORKER_MODE=linux-systemd"
|
||||
Environment="API_HOST=0.0.0.0"
|
||||
Environment="API_PORT=8100"
|
||||
ExecStart=/opt/domaincheck/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
domain-api/deploy/systemd/domain-worker.service
Normal file
16
domain-api/deploy/systemd/domain-worker.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=domainCheck Worker
|
||||
After=network.target redis.service postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/domaincheck/domainCheck
|
||||
Environment="WORKER_MODE=linux-systemd"
|
||||
ExecStart=/opt/domaincheck/domainCheck/.venv/bin/python /opt/domaincheck/domainCheck/detect_worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
8
domain-api/requirements.txt
Normal file
8
domain-api/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
psycopg2-binary==2.9.9
|
||||
redis==5.0.1
|
||||
python-dotenv==1.0.1
|
||||
python-multipart==0.0.9
|
||||
Reference in New Issue
Block a user