22
This commit is contained in:
@@ -17,3 +17,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
|
||||
# CORS(逗号分隔,* 表示允许全部)
|
||||
CORS_ORIGINS=http://localhost:1420,tauri://localhost
|
||||
|
||||
# 桌面端配置写入 MySQL 表 desktop_configs(name / value),非本文件环境变量。
|
||||
# 登录后 GET /api/v1/appConfig 返回全部键值;Tauri 注入 Node 环境变量 AICLIENT_CFG_<NAME>。
|
||||
# 示例键名:LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIG(JSON)等。
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.models import User # noqa: F401 — 注册元数据
|
||||
from app.models import CardKey, DesktopConfig, User # noqa: F401 — 注册元数据
|
||||
|
||||
config = context.config
|
||||
settings = get_settings()
|
||||
|
||||
44
alembic/versions/002_create_desktop_configs_table.py
Normal file
44
alembic/versions/002_create_desktop_configs_table.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""create desktop_configs table
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-05-16
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"desktop_configs",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("desktop_configs")
|
||||
40
alembic/versions/003_add_user_role_phone_vip.py
Normal file
40
alembic/versions/003_add_user_role_phone_vip.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""add user role_id phone vip_end_time
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-05-16
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("phone", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("role_id", sa.Integer(), server_default="0", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("vip_end_time", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_users_phone"), table_name="users")
|
||||
op.drop_column("users", "vip_end_time")
|
||||
op.drop_column("users", "role_id")
|
||||
op.drop_column("users", "phone")
|
||||
46
alembic/versions/004_create_card_keys_table.py
Normal file
46
alembic/versions/004_create_card_keys_table.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""create card_keys table
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-05-16
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"card_keys",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("serial_number", sa.String(length=64), nullable=False),
|
||||
sa.Column("duration_days", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("username", sa.String(length=64), nullable=True),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_card_keys_serial_number"), "card_keys", ["serial_number"], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_card_keys_serial_number"), table_name="card_keys")
|
||||
op.drop_table("card_keys")
|
||||
28
alembic/versions/005_add_desktop_config_mark.py
Normal file
28
alembic/versions/005_add_desktop_config_mark.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""add desktop_configs.mark column
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-05-16
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"desktop_configs",
|
||||
sa.Column("mark", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("desktop_configs", "mark")
|
||||
103
app/api/v1/admin_card_keys.py
Normal file
103
app/api/v1/admin_card_keys.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""管理员卡密 CRUD。"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.dependencies import AdminUser, DbSession
|
||||
from app.schemas.admin_card_key import (
|
||||
CardKeyBatchCreateResult,
|
||||
CardKeyCreate,
|
||||
CardKeyOut,
|
||||
CardKeyUpdate,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, PaginatedData
|
||||
from app.services import admin_card_key as card_key_service
|
||||
|
||||
router = APIRouter(prefix="/admin/card-keys", tags=["管理-卡密"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PaginatedData[CardKeyOut]])
|
||||
async def list_card_keys(
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(
|
||||
card_key_service.DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=card_key_service.MAX_PAGE_SIZE,
|
||||
),
|
||||
status: Literal["all", "unused", "used"] | None = Query(
|
||||
default="all",
|
||||
description="筛选:all 全部 / unused 未使用 / used 已激活",
|
||||
),
|
||||
) -> ApiResponse[PaginatedData[CardKeyOut]]:
|
||||
filter_status = None if status == "all" else status
|
||||
cards, total = await card_key_service.list_card_keys(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=filter_status,
|
||||
)
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="",
|
||||
data=PaginatedData.build(
|
||||
[CardKeyOut.model_validate(c) for c in cards],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[CardKeyBatchCreateResult])
|
||||
async def create_card_keys(
|
||||
body: CardKeyCreate,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[CardKeyBatchCreateResult]:
|
||||
try:
|
||||
created = await card_key_service.create_card_keys(db, body)
|
||||
except card_key_service.AdminCardKeyError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
items = [CardKeyOut.model_validate(c) for c in created]
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message=f"已生成 {len(items)} 张卡密",
|
||||
data=CardKeyBatchCreateResult(items=items, created_count=len(items)),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{card_id}", response_model=ApiResponse[CardKeyOut])
|
||||
async def update_card_key(
|
||||
card_id: int,
|
||||
body: CardKeyUpdate,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[CardKeyOut]:
|
||||
try:
|
||||
card = await card_key_service.update_card_key(db, card_id, body)
|
||||
except card_key_service.AdminCardKeyError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="卡密已更新",
|
||||
data=CardKeyOut.model_validate(card),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{card_id}", response_model=ApiResponse[None])
|
||||
async def delete_card_key(
|
||||
card_id: int,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[None]:
|
||||
try:
|
||||
await card_key_service.delete_card_key(db, card_id)
|
||||
except card_key_service.AdminCardKeyError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(ok=True, message="卡密已删除")
|
||||
95
app/api/v1/admin_desktop_configs.py
Normal file
95
app/api/v1/admin_desktop_configs.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""管理员 desktop_configs 键值 CRUD。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.dependencies import AdminUser, DbSession
|
||||
from app.schemas.admin_desktop_config import (
|
||||
DesktopConfigCreate,
|
||||
DesktopConfigOut,
|
||||
DesktopConfigUpdate,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, PaginatedData
|
||||
from app.services import admin_desktop_config as config_service
|
||||
|
||||
router = APIRouter(prefix="/admin/desktop-configs", tags=["管理-桌面配置"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PaginatedData[DesktopConfigOut]])
|
||||
async def list_configs(
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(
|
||||
config_service.DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=config_service.MAX_PAGE_SIZE,
|
||||
),
|
||||
keyword: str | None = Query(default=None, max_length=64, description="按键名模糊搜索"),
|
||||
) -> ApiResponse[PaginatedData[DesktopConfigOut]]:
|
||||
rows, total = await config_service.list_configs(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
)
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="",
|
||||
data=PaginatedData.build(
|
||||
[DesktopConfigOut.model_validate(r) for r in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[DesktopConfigOut])
|
||||
async def create_config(
|
||||
body: DesktopConfigCreate,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[DesktopConfigOut]:
|
||||
try:
|
||||
row = await config_service.create_config(db, body)
|
||||
except config_service.AdminDesktopConfigError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="配置已创建",
|
||||
data=DesktopConfigOut.model_validate(row),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{config_id}", response_model=ApiResponse[DesktopConfigOut])
|
||||
async def update_config(
|
||||
config_id: int,
|
||||
body: DesktopConfigUpdate,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[DesktopConfigOut]:
|
||||
try:
|
||||
row = await config_service.update_config(db, config_id, body)
|
||||
except config_service.AdminDesktopConfigError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="配置已更新",
|
||||
data=DesktopConfigOut.model_validate(row),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{config_id}", response_model=ApiResponse[None])
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[None]:
|
||||
try:
|
||||
await config_service.delete_config(db, config_id)
|
||||
except config_service.AdminDesktopConfigError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(ok=True, message="配置已删除")
|
||||
91
app/api/v1/admin_users.py
Normal file
91
app/api/v1/admin_users.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""管理员用户 CRUD。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.dependencies import AdminUser, DbSession
|
||||
from app.schemas.admin_user import AdminUserCreate, AdminUserOut, AdminUserUpdate
|
||||
from app.schemas.common import ApiResponse, PaginatedData
|
||||
from app.services import admin_user as admin_user_service
|
||||
|
||||
router = APIRouter(prefix="/admin/users", tags=["管理-用户"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[PaginatedData[AdminUserOut]])
|
||||
async def list_users(
|
||||
_admin: AdminUser,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(
|
||||
admin_user_service.DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=admin_user_service.MAX_PAGE_SIZE,
|
||||
description="每页条数",
|
||||
),
|
||||
) -> ApiResponse[PaginatedData[AdminUserOut]]:
|
||||
users, total = await admin_user_service.list_users(db, page=page, page_size=page_size)
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="",
|
||||
data=PaginatedData.build(
|
||||
[AdminUserOut.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ApiResponse[AdminUserOut])
|
||||
async def create_user(
|
||||
body: AdminUserCreate,
|
||||
admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[AdminUserOut]:
|
||||
try:
|
||||
user = await admin_user_service.create_user(db, body)
|
||||
except admin_user_service.AdminUserError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="用户已创建",
|
||||
data=AdminUserOut.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=ApiResponse[AdminUserOut])
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: AdminUserUpdate,
|
||||
admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[AdminUserOut]:
|
||||
try:
|
||||
user = await admin_user_service.update_user(
|
||||
db,
|
||||
user_id,
|
||||
body,
|
||||
actor_id=admin.id,
|
||||
)
|
||||
except admin_user_service.AdminUserError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(
|
||||
ok=True,
|
||||
message="用户已更新",
|
||||
data=AdminUserOut.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=ApiResponse[None])
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[None]:
|
||||
try:
|
||||
await admin_user_service.delete_user(db, user_id, actor_id=admin.id)
|
||||
except admin_user_service.AdminUserError as exc:
|
||||
return ApiResponse(ok=False, message=exc.message)
|
||||
|
||||
return ApiResponse(ok=True, message="用户已删除")
|
||||
19
app/api/v1/app_config.py
Normal file
19
app/api/v1/app_config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""已登录用户拉取 desktop_configs 表全部键值。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import CurrentUser, DbSession
|
||||
from app.schemas.app_config import AppConfigMap
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services import desktop_config as desktop_config_service
|
||||
|
||||
router = APIRouter(prefix="/appConfig", tags=["appConfig"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[AppConfigMap])
|
||||
async def get_app_config(
|
||||
_user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ApiResponse[AppConfigMap]:
|
||||
items = await desktop_config_service.list_all_as_map(db)
|
||||
return ApiResponse(ok=True, message="", data=items)
|
||||
47
app/api/v1/nodejs_scripts.py
Normal file
47
app/api/v1/nodejs_scripts.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Node.js 流水线脚本:按文件名下发源码,供桌面端拉取后在 Node 子进程中执行。"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
|
||||
router = APIRouter(prefix="/nodejs-scripts", tags=["nodejs"])
|
||||
|
||||
# pythonbackend/scripts/nodejs/*.js
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts" / "nodejs"
|
||||
|
||||
_SAFE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*\.js$")
|
||||
|
||||
|
||||
def _validate_name(name: str) -> str | None:
|
||||
if not name or not _SAFE_NAME.fullmatch(name.strip()):
|
||||
return None
|
||||
return name.strip()
|
||||
|
||||
|
||||
@router.get("", response_model=ApiResponse[dict])
|
||||
async def get_nodejs_script(name: str) -> ApiResponse[dict]:
|
||||
safe = _validate_name(name)
|
||||
if not safe:
|
||||
return ApiResponse(ok=False, message="无效的脚本名称", data=None)
|
||||
path = _SCRIPTS_DIR / safe
|
||||
try:
|
||||
base_resolved = _SCRIPTS_DIR.resolve()
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(base_resolved)
|
||||
except (OSError, ValueError):
|
||||
return ApiResponse(ok=False, message="无效的脚本路径", data=None)
|
||||
if not path.is_file():
|
||||
return ApiResponse(ok=False, message=f"脚本不存在: {safe}", data=None)
|
||||
source = path.read_text(encoding="utf-8")
|
||||
return ApiResponse(ok=True, message="", data={"source": source})
|
||||
|
||||
|
||||
@router.get("/list", response_model=ApiResponse[dict])
|
||||
async def list_nodejs_scripts() -> ApiResponse[dict]:
|
||||
if not _SCRIPTS_DIR.is_dir():
|
||||
return ApiResponse(ok=True, message="", data={"names": []})
|
||||
names = sorted(p.name for p in _SCRIPTS_DIR.glob("*.js") if p.is_file())
|
||||
return ApiResponse(ok=True, message="", data={"names": names})
|
||||
@@ -1,7 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, quickjs_scripts
|
||||
from app.api.v1 import (
|
||||
admin_card_keys,
|
||||
admin_desktop_configs,
|
||||
admin_users,
|
||||
app_config,
|
||||
auth,
|
||||
nodejs_scripts,
|
||||
quickjs_scripts,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(admin_users.router)
|
||||
api_router.include_router(admin_card_keys.router)
|
||||
api_router.include_router(admin_desktop_configs.router)
|
||||
api_router.include_router(app_config.router)
|
||||
api_router.include_router(quickjs_scripts.router)
|
||||
api_router.include_router(nodejs_scripts.router)
|
||||
|
||||
8
app/core/roles.py
Normal file
8
app/core/roles.py
Normal file
@@ -0,0 +1,8 @@
|
||||
ROLE_ADMIN = 1
|
||||
ROLE_AGENT = 2
|
||||
|
||||
ROLE_LABELS: dict[int, str] = {
|
||||
0: "普通用户",
|
||||
ROLE_ADMIN: "管理员",
|
||||
ROLE_AGENT: "代理",
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import redis.asyncio as redis
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.roles import ROLE_ADMIN
|
||||
from app.core.security import decode_access_token
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
@@ -52,3 +53,15 @@ async def get_current_user(
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
async def require_admin(current_user: CurrentUser) -> User:
|
||||
if current_user.role_id != ROLE_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from app.models.card_key import CardKey
|
||||
from app.models.desktopConfig import DesktopConfig
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["User"]
|
||||
__all__ = ["User", "DesktopConfig", "CardKey"]
|
||||
|
||||
25
app/models/card_key.py
Normal file
25
app/models/card_key.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CardKey(Base):
|
||||
__tablename__ = "card_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
duration_days: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
17
app/models/desktopConfig.py
Normal file
17
app/models/desktopConfig.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DesktopConfig(Base):
|
||||
__tablename__ = "desktop_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
mark: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy import DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -10,14 +10,12 @@ class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
role_id: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
vip_end_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
64
app/schemas/admin_card_key.py
Normal file
64
app/schemas/admin_card_key.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class CardKeyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
serial_number: str
|
||||
duration_days: int
|
||||
created_at: datetime
|
||||
activated_at: datetime | None = None
|
||||
user_id: int | None = None
|
||||
username: str | None = None
|
||||
remark: str | None = None
|
||||
|
||||
|
||||
class CardKeyCreate(BaseModel):
|
||||
duration_days: int = Field(ge=1, le=3650, description="有效天数")
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
count: int = Field(default=1, ge=1, le=100, description="批量生成数量")
|
||||
serial_number: str | None = Field(default=None, max_length=64)
|
||||
|
||||
@field_validator("remark")
|
||||
@classmethod
|
||||
def normalize_remark(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
@field_validator("serial_number")
|
||||
@classmethod
|
||||
def normalize_serial(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip().upper()
|
||||
return stripped or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def serial_only_for_single(self) -> "CardKeyCreate":
|
||||
if self.serial_number and self.count != 1:
|
||||
raise ValueError("指定序列号时仅可生成 1 张卡密")
|
||||
return self
|
||||
|
||||
|
||||
class CardKeyUpdate(BaseModel):
|
||||
duration_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("remark")
|
||||
@classmethod
|
||||
def normalize_remark(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
class CardKeyBatchCreateResult(BaseModel):
|
||||
items: list[CardKeyOut]
|
||||
created_count: int
|
||||
54
app/schemas/admin_desktop_config.py
Normal file
54
app/schemas/admin_desktop_config.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class DesktopConfigOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
value: str
|
||||
mark: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DesktopConfigCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
value: str = Field(min_length=0)
|
||||
mark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def strip_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@field_validator("mark")
|
||||
@classmethod
|
||||
def normalize_mark(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
class DesktopConfigUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
value: str | None = None
|
||||
mark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def strip_name(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
@field_validator("mark")
|
||||
@classmethod
|
||||
def normalize_mark(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
60
app/schemas/admin_user.py
Normal file
60
app/schemas/admin_user.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
phone: str | None = None
|
||||
role_id: int
|
||||
vip_end_time: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
phone: str | None = Field(default=None, max_length=64)
|
||||
role_id: int = Field(default=0, ge=0, le=2)
|
||||
vip_end_time: datetime | None = None
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def strip_username(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def normalize_phone(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
password: str | None = Field(default=None, min_length=6, max_length=128)
|
||||
phone: str | None = Field(default=None, max_length=64)
|
||||
role_id: int | None = Field(default=None, ge=0, le=2)
|
||||
vip_end_time: datetime | None = None
|
||||
clear_vip_end_time: bool = False
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def strip_username(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def normalize_phone(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
6
app/schemas/app_config.py
Normal file
6
app/schemas/app_config.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""桌面端应用配置:name -> value 字典。"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
# GET /api/v1/appConfig 的 data 载荷类型
|
||||
AppConfigMap = Dict[str, str]
|
||||
@@ -37,6 +37,7 @@ class UserPublic(BaseModel):
|
||||
|
||||
id: int
|
||||
username: str
|
||||
role_id: int
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
|
||||
@@ -13,3 +13,31 @@ class ApiResponse(BaseModel, Generic[T]):
|
||||
ok: bool
|
||||
message: str = ""
|
||||
data: T | None = None
|
||||
|
||||
|
||||
class PaginatedData(BaseModel, Generic[T]):
|
||||
"""分页列表载荷。"""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
items: list[T],
|
||||
*,
|
||||
total: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> "PaginatedData[T]":
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
return cls(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
128
app/services/admin_card_key.py
Normal file
128
app/services/admin_card_key.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.card_key import CardKey
|
||||
from app.schemas.admin_card_key import CardKeyCreate, CardKeyUpdate
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
_SERIAL_ALPHABET = string.ascii_uppercase + string.digits
|
||||
_SERIAL_LENGTH = 16
|
||||
|
||||
|
||||
class AdminCardKeyError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _generate_serial() -> str:
|
||||
return "".join(secrets.choice(_SERIAL_ALPHABET) for _ in range(_SERIAL_LENGTH))
|
||||
|
||||
|
||||
async def _unique_serial(db: AsyncSession, preferred: str | None = None) -> str:
|
||||
if preferred:
|
||||
existing = await db.scalar(
|
||||
select(CardKey.id).where(CardKey.serial_number == preferred)
|
||||
)
|
||||
if existing is not None:
|
||||
raise AdminCardKeyError("序列号已存在")
|
||||
return preferred
|
||||
|
||||
for _ in range(20):
|
||||
candidate = _generate_serial()
|
||||
existing = await db.scalar(
|
||||
select(CardKey.id).where(CardKey.serial_number == candidate)
|
||||
)
|
||||
if existing is None:
|
||||
return candidate
|
||||
raise AdminCardKeyError("生成序列号失败,请重试")
|
||||
|
||||
|
||||
async def list_card_keys(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
status: str | None = None,
|
||||
) -> tuple[list[CardKey], int]:
|
||||
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
||||
page = max(page, 1)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
filters = []
|
||||
if status == "unused":
|
||||
filters.append(CardKey.activated_at.is_(None))
|
||||
elif status == "used":
|
||||
filters.append(CardKey.activated_at.is_not(None))
|
||||
|
||||
count_stmt = select(func.count()).select_from(CardKey)
|
||||
list_stmt = select(CardKey)
|
||||
for clause in filters:
|
||||
count_stmt = count_stmt.where(clause)
|
||||
list_stmt = list_stmt.where(clause)
|
||||
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
result = await db.scalars(
|
||||
list_stmt.order_by(CardKey.id.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.all()), total
|
||||
|
||||
|
||||
async def create_card_keys(db: AsyncSession, body: CardKeyCreate) -> list[CardKey]:
|
||||
created: list[CardKey] = []
|
||||
|
||||
for index in range(body.count):
|
||||
serial = await _unique_serial(
|
||||
db,
|
||||
body.serial_number if index == 0 else None,
|
||||
)
|
||||
card = CardKey(
|
||||
serial_number=serial,
|
||||
duration_days=body.duration_days,
|
||||
remark=body.remark,
|
||||
)
|
||||
db.add(card)
|
||||
created.append(card)
|
||||
|
||||
await db.flush()
|
||||
for card in created:
|
||||
await db.refresh(card)
|
||||
return created
|
||||
|
||||
|
||||
async def update_card_key(
|
||||
db: AsyncSession,
|
||||
card_id: int,
|
||||
body: CardKeyUpdate,
|
||||
) -> CardKey:
|
||||
card = await db.get(CardKey, card_id)
|
||||
if card is None:
|
||||
raise AdminCardKeyError("卡密不存在")
|
||||
|
||||
if card.activated_at is not None:
|
||||
if body.duration_days is not None:
|
||||
raise AdminCardKeyError("已激活卡密不能修改时长")
|
||||
elif body.duration_days is not None:
|
||||
card.duration_days = body.duration_days
|
||||
|
||||
if body.remark is not None:
|
||||
card.remark = body.remark
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(card)
|
||||
return card
|
||||
|
||||
|
||||
async def delete_card_key(db: AsyncSession, card_id: int) -> None:
|
||||
card = await db.get(CardKey, card_id)
|
||||
if card is None:
|
||||
raise AdminCardKeyError("卡密不存在")
|
||||
if card.activated_at is not None:
|
||||
raise AdminCardKeyError("已激活卡密不能删除")
|
||||
|
||||
await db.delete(card)
|
||||
88
app/services/admin_desktop_config.py
Normal file
88
app/services/admin_desktop_config.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktopConfig import DesktopConfig
|
||||
from app.schemas.admin_desktop_config import DesktopConfigCreate, DesktopConfigUpdate
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class AdminDesktopConfigError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def list_configs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
keyword: str | None = None,
|
||||
) -> tuple[list[DesktopConfig], int]:
|
||||
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
||||
page = max(page, 1)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
filters = []
|
||||
if keyword:
|
||||
like = f"%{keyword.strip()}%"
|
||||
filters.append(DesktopConfig.name.ilike(like))
|
||||
|
||||
count_stmt = select(func.count()).select_from(DesktopConfig)
|
||||
list_stmt = select(DesktopConfig)
|
||||
for clause in filters:
|
||||
count_stmt = count_stmt.where(clause)
|
||||
list_stmt = list_stmt.where(clause)
|
||||
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
result = await db.scalars(
|
||||
list_stmt.order_by(DesktopConfig.name.asc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.all()), total
|
||||
|
||||
|
||||
async def create_config(db: AsyncSession, body: DesktopConfigCreate) -> DesktopConfig:
|
||||
existing = await db.scalar(select(DesktopConfig).where(DesktopConfig.name == body.name))
|
||||
if existing is not None:
|
||||
raise AdminDesktopConfigError("配置键名已存在")
|
||||
|
||||
row = DesktopConfig(name=body.name, value=body.value, mark=body.mark)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def update_config(
|
||||
db: AsyncSession,
|
||||
config_id: int,
|
||||
body: DesktopConfigUpdate,
|
||||
) -> DesktopConfig:
|
||||
row = await db.get(DesktopConfig, config_id)
|
||||
if row is None:
|
||||
raise AdminDesktopConfigError("配置不存在")
|
||||
|
||||
if body.name is not None and body.name != row.name:
|
||||
taken = await db.scalar(select(DesktopConfig).where(DesktopConfig.name == body.name))
|
||||
if taken is not None:
|
||||
raise AdminDesktopConfigError("配置键名已存在")
|
||||
row.name = body.name
|
||||
|
||||
if body.value is not None:
|
||||
row.value = body.value
|
||||
|
||||
if body.mark is not None:
|
||||
row.mark = body.mark
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_config(db: AsyncSession, config_id: int) -> None:
|
||||
row = await db.get(DesktopConfig, config_id)
|
||||
if row is None:
|
||||
raise AdminDesktopConfigError("配置不存在")
|
||||
await db.delete(row)
|
||||
109
app/services/admin_user.py
Normal file
109
app/services/admin_user.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.roles import ROLE_ADMIN
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class AdminUserError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def list_users(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = DEFAULT_PAGE_SIZE,
|
||||
) -> tuple[list[User], int]:
|
||||
page_size = min(max(page_size, 1), MAX_PAGE_SIZE)
|
||||
page = max(page, 1)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
result = await db.scalars(
|
||||
select(User).order_by(User.id.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.all()), total
|
||||
|
||||
|
||||
async def create_user(db: AsyncSession, body: AdminUserCreate) -> User:
|
||||
existing = await db.scalar(select(User).where(User.username == body.username))
|
||||
if existing is not None:
|
||||
raise AdminUserError("用户名已存在")
|
||||
|
||||
if body.phone:
|
||||
phone_taken = await db.scalar(select(User).where(User.phone == body.phone))
|
||||
if phone_taken is not None:
|
||||
raise AdminUserError("手机号已被使用")
|
||||
|
||||
user = User(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
phone=body.phone,
|
||||
role_id=body.role_id,
|
||||
vip_end_time=body.vip_end_time,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
body: AdminUserUpdate,
|
||||
*,
|
||||
actor_id: int,
|
||||
) -> User:
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise AdminUserError("用户不存在")
|
||||
|
||||
if body.username is not None and body.username != user.username:
|
||||
taken = await db.scalar(select(User).where(User.username == body.username))
|
||||
if taken is not None:
|
||||
raise AdminUserError("用户名已存在")
|
||||
user.username = body.username
|
||||
|
||||
if body.phone is not None:
|
||||
if body.phone != user.phone:
|
||||
taken = await db.scalar(select(User).where(User.phone == body.phone))
|
||||
if taken is not None:
|
||||
raise AdminUserError("手机号已被使用")
|
||||
user.phone = body.phone
|
||||
|
||||
if body.role_id is not None:
|
||||
if user_id == actor_id and body.role_id != ROLE_ADMIN:
|
||||
raise AdminUserError("不能修改自己的管理员角色")
|
||||
user.role_id = body.role_id
|
||||
|
||||
if body.password:
|
||||
user.password_hash = hash_password(body.password)
|
||||
|
||||
if body.clear_vip_end_time:
|
||||
user.vip_end_time = None
|
||||
elif body.vip_end_time is not None:
|
||||
user.vip_end_time = body.vip_end_time
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user_id: int, *, actor_id: int) -> None:
|
||||
if user_id == actor_id:
|
||||
raise AdminUserError("不能删除当前登录账号")
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise AdminUserError("用户不存在")
|
||||
|
||||
await db.delete(user)
|
||||
@@ -30,7 +30,12 @@ async def register_user(
|
||||
if existing is not None:
|
||||
raise AuthError("用户名已存在")
|
||||
|
||||
user = User(username=username, password_hash=hash_password(password))
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role_id=0,
|
||||
phone=None,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
12
app/services/desktop_config.py
Normal file
12
app/services/desktop_config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""desktop_configs 表:键值配置。"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktopConfig import DesktopConfig
|
||||
|
||||
|
||||
async def list_all_as_map(db: AsyncSession) -> dict[str, str]:
|
||||
result = await db.execute(select(DesktopConfig))
|
||||
rows = result.scalars().all()
|
||||
return {row.name: row.value for row in rows}
|
||||
129
scripts/nodejs/desktop_config.js
Normal file
129
scripts/nodejs/desktop_config.js
Normal file
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 从进程环境变量读取 desktop_configs(Rust 注入 AICLIENT_CFG_*)。
|
||||
* 库表 name 经规范化后作为 env 后缀,如 name=LLM_API_KEY → AICLIENT_CFG_LLM_API_KEY
|
||||
*/
|
||||
|
||||
const ENV_PREFIX = "AICLIENT_CFG_";
|
||||
|
||||
function normalizeKey(name) {
|
||||
return String(name || "")
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/** @returns {Record<string, string>} 逻辑键(大写+下划线)→ 值 */
|
||||
function readAllFromEnv() {
|
||||
const out = {};
|
||||
for (const [ek, ev] of Object.entries(process.env)) {
|
||||
if (!ek.startsWith(ENV_PREFIX) || ev == null || ev === "") continue;
|
||||
const logical = ek.slice(ENV_PREFIX.length);
|
||||
out[logical] = ev;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function get(logicalKey, fallback = "") {
|
||||
const k = normalizeKey(logicalKey);
|
||||
return process.env[ENV_PREFIX + k] || fallback;
|
||||
}
|
||||
|
||||
function parseJson(val, fallback = null) {
|
||||
if (!val) return fallback;
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平配置组装为流水线使用的 modelConfig(兼容现有脚本字段名)。
|
||||
* 可在库表增加任意键;复杂对象用 JSON 字符串存于 OSS_CONFIG、ASR_SERVER_INFO 等。
|
||||
*/
|
||||
function buildModelConfig() {
|
||||
const c = readAllFromEnv();
|
||||
const pick = (...keys) => {
|
||||
for (const k of keys) {
|
||||
const v = c[normalizeKey(k)];
|
||||
if (v) return v;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
let apiUrl = pick("LLM_API_URL", "API_URL");
|
||||
if (apiUrl) {
|
||||
apiUrl = apiUrl.replace(/\/$/, "");
|
||||
if (!apiUrl.includes("/chat/completions")) {
|
||||
apiUrl = apiUrl.endsWith("/v1")
|
||||
? `${apiUrl}/chat/completions`
|
||||
: `${apiUrl}/v1/chat/completions`;
|
||||
}
|
||||
}
|
||||
|
||||
const asrMode = pick("ASR_MODE", "asr_mode") || "online";
|
||||
|
||||
let ossConfig = parseJson(pick("OSS_CONFIG"));
|
||||
if (!ossConfig && pick("OSS_ACCESS_KEY_ID")) {
|
||||
ossConfig = {
|
||||
accessKeyId: pick("OSS_ACCESS_KEY_ID"),
|
||||
accessKeySecret: pick("OSS_ACCESS_KEY_SECRET"),
|
||||
bucket: pick("OSS_BUCKET"),
|
||||
region: pick("OSS_REGION"),
|
||||
endpoint: pick("OSS_ENDPOINT") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let asrServerInfo = parseJson(pick("ASR_SERVER_INFO"));
|
||||
if (!asrServerInfo && pick("ASR_SERVER_URL")) {
|
||||
asrServerInfo = {
|
||||
name: pick("ASR_SERVER_NAME", "local") || "local",
|
||||
baseUrl: pick("ASR_SERVER_URL").replace(/\/$/, ""),
|
||||
asrPath: pick("ASR_SERVER_ASR_PATH", "/asr") || "/asr",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "openai",
|
||||
modelId: pick("LLM_MODEL_ID", "MODEL_ID"),
|
||||
apiUrl,
|
||||
apiKey: pick("LLM_API_KEY", "API_KEY"),
|
||||
type: pick("LLM_TYPE", "TYPE") || "openai",
|
||||
asrMode,
|
||||
aliyunApiKey: pick("DASHSCOPE_API_KEY", "ALIYUN_API_KEY"),
|
||||
ossConfig: ossConfig || undefined,
|
||||
asrServerInfo: asrServerInfo || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelConfig(modelConfig) {
|
||||
if (!modelConfig.apiKey) {
|
||||
return { ok: false, error: "缺少配置 LLM_API_KEY(desktop_configs 表)" };
|
||||
}
|
||||
if (!modelConfig.apiUrl) {
|
||||
return { ok: false, error: "缺少配置 LLM_API_URL" };
|
||||
}
|
||||
if (modelConfig.asrMode === "online") {
|
||||
if (!modelConfig.aliyunApiKey) {
|
||||
return { ok: false, error: "在线 ASR 需配置 DASHSCOPE_API_KEY" };
|
||||
}
|
||||
if (!modelConfig.ossConfig?.bucket) {
|
||||
return { ok: false, error: "在线 ASR 需配置 OSS(或 OSS_CONFIG JSON)" };
|
||||
}
|
||||
}
|
||||
if (modelConfig.asrMode === "local" && !modelConfig.asrServerInfo) {
|
||||
return { ok: false, error: "本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENV_PREFIX,
|
||||
readAllFromEnv,
|
||||
get,
|
||||
buildModelConfig,
|
||||
validateModelConfig,
|
||||
};
|
||||
70
scripts/nodejs/douyin_browser_constants.js
Normal file
70
scripts/nodejs/douyin_browser_constants.js
Normal file
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
/** 与 zhenqianba Electron 版 COMMON_BROWSER_ARGS / STEALTH 对齐 */
|
||||
|
||||
const COMMON_BROWSER_ARGS = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-infobars",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-breakpad",
|
||||
"--disable-component-update",
|
||||
"--disable-sync",
|
||||
"--disable-default-apps",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-background-networking",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--enable-gpu",
|
||||
"--enable-webgl",
|
||||
"--ignore-gpu-blacklist",
|
||||
"--enable-accelerated-video-decode",
|
||||
"--enable-gpu-rasterization",
|
||||
"--enable-proprietary-codecs",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
];
|
||||
|
||||
const BROWSER_IGNORE_DEFAULT_ARGS = ["--enable-automation"];
|
||||
|
||||
const STEALTH_INIT_SCRIPT = `
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true
|
||||
});
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {}, disconnect: function() {} }; },
|
||||
sendMessage: function() {},
|
||||
id: undefined
|
||||
},
|
||||
loadTimes: function() { return { firstPaintTime: 0, startLoadTime: 0, commitLoadTime: 0, finishDocumentLoadTime: 0, finishLoadTime: 0, firstPaintAfterLoadTime: 0, navigationType: 'Other', requestTime: Date.now() / 1000 }; },
|
||||
csi: function() { return { startE: Date.now(), onloadT: Date.now(), pageT: Math.random() * 1000 + 500, tran: 15 }; },
|
||||
app: { isInstalled: false }
|
||||
};
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [{ name: 'Chrome PDF Plugin', description: 'Portable Document Format', filename: 'internal-pdf-viewer', length: 1 }],
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['zh-CN', 'zh', 'en'],
|
||||
configurable: true
|
||||
});
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
delete window.webdriver;
|
||||
`;
|
||||
|
||||
const DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
module.exports = {
|
||||
COMMON_BROWSER_ARGS,
|
||||
BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
STEALTH_INIT_SCRIPT,
|
||||
DEFAULT_USER_AGENT,
|
||||
};
|
||||
216
scripts/nodejs/douyin_browser_manager.js
Normal file
216
scripts/nodejs/douyin_browser_manager.js
Normal file
@@ -0,0 +1,216 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Puppeteer 浏览器单例(参考 zhenqianba DouyinBrowserManager / Playwright 实现)
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const {
|
||||
COMMON_BROWSER_ARGS,
|
||||
BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
STEALTH_INIT_SCRIPT,
|
||||
DEFAULT_USER_AGENT,
|
||||
} = require("./douyin_browser_constants.js");
|
||||
|
||||
function getRuntimeDataPath(...parts) {
|
||||
const base =
|
||||
process.env.AICLIENT_DATA_DIR ||
|
||||
path.join(os.homedir(), ".aiclient");
|
||||
return path.join(base, ...parts);
|
||||
}
|
||||
|
||||
function getChromiumExecutablePath() {
|
||||
const fromEnv =
|
||||
process.env.AICLIENT_CHROMIUM_PATH ||
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [];
|
||||
if (process.platform === "win32") {
|
||||
const pf = process.env["ProgramFiles"] || "C:\\Program Files";
|
||||
const pfx86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||||
const local = process.env.LOCALAPPDATA || "";
|
||||
candidates.push(
|
||||
path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe")
|
||||
);
|
||||
} else if (process.platform === "darwin") {
|
||||
candidates.push(
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||
);
|
||||
} else {
|
||||
candidates.push(
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser"
|
||||
);
|
||||
}
|
||||
return candidates.find((p) => p && fs.existsSync(p));
|
||||
}
|
||||
|
||||
function loadPuppeteer() {
|
||||
try {
|
||||
return require("puppeteer-core");
|
||||
} catch (_) {
|
||||
return require("puppeteer");
|
||||
}
|
||||
}
|
||||
|
||||
function isHeadless() {
|
||||
if (process.env.AICLIENT_PUPPETEER_HEADLESS === "0") return false;
|
||||
if (process.env.AICLIENT_PUPPETEER_HEADLESS === "false") return false;
|
||||
return "new";
|
||||
}
|
||||
|
||||
class DouyinBrowserManager {
|
||||
constructor() {
|
||||
this.browser = null;
|
||||
this.isInitializing = false;
|
||||
this.userDataPath = getRuntimeDataPath("browser-data", "douyin");
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!DouyinBrowserManager.instance) {
|
||||
DouyinBrowserManager.instance = new DouyinBrowserManager();
|
||||
}
|
||||
return DouyinBrowserManager.instance;
|
||||
}
|
||||
|
||||
cleanupBrowserLocks(userDataPath) {
|
||||
const lockFiles = [
|
||||
"SingletonLock",
|
||||
"SingletonSocket",
|
||||
"SingletonCookie",
|
||||
"Lock",
|
||||
];
|
||||
for (const lockFile of lockFiles) {
|
||||
const lockPath = path.join(userDataPath, lockFile);
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
console.log("已清理锁文件:", lockFile);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("清理锁文件失败(忽略):", lockFile, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async launchBrowser(userDataPath) {
|
||||
const puppeteer = loadPuppeteer();
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
const executablePath = getChromiumExecutablePath();
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
"未找到 Chromium/Chrome。请安装 Chrome/Edge,或设置 AICLIENT_CHROMIUM_PATH,并在 src-tauri/resources/nodejs 执行 npm install"
|
||||
);
|
||||
}
|
||||
console.log("使用浏览器:", executablePath);
|
||||
this.browser = await puppeteer.launch({
|
||||
executablePath,
|
||||
headless: isHeadless(),
|
||||
userDataDir: userDataPath,
|
||||
args: COMMON_BROWSER_ARGS,
|
||||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
defaultViewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
console.log("浏览器实例初始化成功");
|
||||
this.browser.on("disconnected", () => {
|
||||
console.log("浏览器已断开连接");
|
||||
this.browser = null;
|
||||
});
|
||||
}
|
||||
|
||||
async initBrowser() {
|
||||
if (this.isInitializing) {
|
||||
while (this.isInitializing) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.browser && this.browser.isConnected()) {
|
||||
return;
|
||||
}
|
||||
this.isInitializing = true;
|
||||
try {
|
||||
const executablePath = getChromiumExecutablePath();
|
||||
if (executablePath && !fs.existsSync(executablePath)) {
|
||||
console.warn("Chromium 路径不存在,将使用 Puppeteer 自带浏览器:", executablePath);
|
||||
}
|
||||
try {
|
||||
console.log("[阶段1] 使用用户数据目录:", this.userDataPath);
|
||||
await this.launchBrowser(this.userDataPath);
|
||||
return;
|
||||
} catch (error1) {
|
||||
console.error("[阶段1] 启动失败:", error1.message);
|
||||
try {
|
||||
console.log("[阶段2] 清理锁文件后重试...");
|
||||
this.cleanupBrowserLocks(this.userDataPath);
|
||||
await this.launchBrowser(this.userDataPath);
|
||||
return;
|
||||
} catch (error2) {
|
||||
console.error("[阶段2] 启动失败:", error2.message);
|
||||
const tempDataPath = getRuntimeDataPath(
|
||||
"temp",
|
||||
`douyin-browser-${Date.now()}`
|
||||
);
|
||||
console.log("[阶段3] 使用临时目录:", tempDataPath);
|
||||
fs.mkdirSync(tempDataPath, { recursive: true });
|
||||
await this.launchBrowser(tempDataPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async newPage() {
|
||||
if (!this.browser || !this.browser.isConnected()) {
|
||||
await this.initBrowser();
|
||||
}
|
||||
const page = await this.browser.newPage();
|
||||
await page.setUserAgent(DEFAULT_USER_AGENT);
|
||||
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(60000);
|
||||
return page;
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
console.log("浏览器管理器已关闭");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("关闭浏览器管理器失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized() {
|
||||
return this.browser != null && this.browser.isConnected();
|
||||
}
|
||||
|
||||
getBrowserInfo() {
|
||||
return { browser: this.browser };
|
||||
}
|
||||
|
||||
static async cleanup() {
|
||||
if (DouyinBrowserManager.instance) {
|
||||
await DouyinBrowserManager.instance.close();
|
||||
DouyinBrowserManager.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DouyinBrowserManager, getRuntimeDataPath };
|
||||
147
scripts/nodejs/douyin_content_parser.js
Normal file
147
scripts/nodejs/douyin_content_parser.js
Normal file
@@ -0,0 +1,147 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 从混杂文本中提取抖音分享内容(链接、文案、话题)。
|
||||
* 自 zhenqianba DouyinContentParser 移植,CommonJS 导出供 require。
|
||||
*/
|
||||
|
||||
const DIRECT_VIDEO_PATTERN =
|
||||
/https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i;
|
||||
|
||||
class DouyinContentParser {
|
||||
/**
|
||||
* @param {string} input
|
||||
* @returns {{ success: true, content: object } | { success: false, error: string }}
|
||||
*/
|
||||
static parseContent(input) {
|
||||
try {
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) {
|
||||
return { success: false, error: "输入内容为空" };
|
||||
}
|
||||
const videoUrl = this.extractVideoUrl(raw);
|
||||
if (!videoUrl) {
|
||||
return { success: false, error: "未找到有效的抖音视频链接" };
|
||||
}
|
||||
const description = this.extractDescription(raw, videoUrl);
|
||||
const hashtags = this.extractHashtags(raw);
|
||||
return {
|
||||
success: true,
|
||||
content: {
|
||||
videoUrl,
|
||||
description,
|
||||
hashtags,
|
||||
rawText: raw,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `解析失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static extractVideoUrl(text) {
|
||||
const directVideoMatch = text.match(DIRECT_VIDEO_PATTERN);
|
||||
if (directVideoMatch && directVideoMatch[0]) {
|
||||
return directVideoMatch[0];
|
||||
}
|
||||
const patterns = [
|
||||
/https:\/\/v\.douyin\.com\/[\w_\-@]+\/?/i,
|
||||
/https:\/\/(?:www\.)?douyin\.com\/(?:video|modal)\/\d+/i,
|
||||
/https:\/\/iesdouyin\.com\/(?:share\/video|video)\/\d+/i,
|
||||
/v\.douyin\.com\/[\w_\-@]+/i,
|
||||
/douyin\.com\/(?:video|modal)\/\d+/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
let url = match[0];
|
||||
if (!url.startsWith("http")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static isDirectVideoUrl(url) {
|
||||
return DIRECT_VIDEO_PATTERN.test(String(url || "").trim());
|
||||
}
|
||||
|
||||
static extractDescription(text, videoUrl) {
|
||||
const urlIndex = text.indexOf(videoUrl);
|
||||
let descriptionText = "";
|
||||
if (urlIndex > 0) {
|
||||
descriptionText = text.substring(0, urlIndex).trim();
|
||||
} else {
|
||||
const descriptionMatch = text.match(/^([^h]*?)(?:https?:\/\/|$)/);
|
||||
if (descriptionMatch && descriptionMatch[1]) {
|
||||
descriptionText = descriptionMatch[1].trim();
|
||||
}
|
||||
}
|
||||
return this.cleanDescription(descriptionText);
|
||||
}
|
||||
|
||||
static cleanDescription(text) {
|
||||
if (!text) return "";
|
||||
let cleaned = text.replace(/^[\d\s./]+/, "").trim();
|
||||
const startMatch = cleaned.match(/^[^\u4e00-\u9fff\w\s]*(.*)$/);
|
||||
if (startMatch && startMatch[1]) {
|
||||
const potential = startMatch[1].trim();
|
||||
if (potential.length > 0) {
|
||||
cleaned = potential;
|
||||
}
|
||||
}
|
||||
if (/^[\W_]+$/.test(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
static extractHashtags(text) {
|
||||
const hashtagPattern = /#[\w\u4e00-\u9fff_]+/g;
|
||||
const matches = text.match(hashtagPattern);
|
||||
if (!matches) return [];
|
||||
return Array.from(new Set(matches));
|
||||
}
|
||||
|
||||
static extractVideoId(videoUrl) {
|
||||
const shortMatch = videoUrl.match(/v\.douyin\.com\/([\w_\-@]+)/i);
|
||||
if (shortMatch) return shortMatch[1];
|
||||
const standardMatch = videoUrl.match(/\/(?:video|modal)\/(\d+)/i);
|
||||
if (standardMatch) return standardMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
static generateTitleFromDescription(description, maxLength = 30) {
|
||||
if (!description) {
|
||||
return "分享精彩内容";
|
||||
}
|
||||
const sentenceEnd = description.match(/[。!?\n]/);
|
||||
let title = "";
|
||||
if (sentenceEnd) {
|
||||
title = description.substring(0, sentenceEnd.index).trim();
|
||||
} else {
|
||||
title = description;
|
||||
}
|
||||
if (title.length > maxLength) {
|
||||
title = title.substring(0, maxLength) + "...";
|
||||
}
|
||||
return title || "精彩分享";
|
||||
}
|
||||
|
||||
static quickExtractUrl(text) {
|
||||
const r = this.parseContent(text);
|
||||
return r.success && r.content ? r.content.videoUrl : null;
|
||||
}
|
||||
|
||||
static quickExtractDescription(text) {
|
||||
const r = this.parseContent(text);
|
||||
return r.success && r.content ? r.content.description : null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DouyinContentParser, DIRECT_VIDEO_PATTERN };
|
||||
225
scripts/nodejs/douyin_pipeline.js
Normal file
225
scripts/nodejs/douyin_pipeline.js
Normal file
@@ -0,0 +1,225 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* douyin_pipeline.js — Node 完整仿写流水线
|
||||
* 对齐 quickjs/douyin_pipeline.js 与 Electron VideoRewriteIntegration.processDouyinShareComplete
|
||||
*
|
||||
* 由后端 `/api/v1/nodejs-scripts` 下发;Tauri 调用 run_nodejs_script → __nodejsMain(params)
|
||||
*/
|
||||
|
||||
const { DouyinContentParser } = require("./douyin_content_parser.js");
|
||||
const { DouyinVideoDownloader } = require("./douyin_video_downloader.js");
|
||||
const { VideoRewriteOptimizer } = require("./video_rewrite_optimizer.js");
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
const desktopConfig = require("./desktop_config.js");
|
||||
|
||||
const log = (level, msg, fields) =>
|
||||
globalThis.__native.log(level, msg, fields == null ? null : fields);
|
||||
const progress = (s) => {
|
||||
try {
|
||||
globalThis.__native.emitProgress(s);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) {
|
||||
if (!modelConfig?.apiUrl || !modelConfig?.apiKey) {
|
||||
log("error", "rewrite.missingConfig");
|
||||
return null;
|
||||
}
|
||||
const prompt = VideoRewriteOptimizer.buildRewritePrompt(
|
||||
content,
|
||||
rewriteConfig || {},
|
||||
customPrompt
|
||||
);
|
||||
const result = await pipelineNative.openaiChat({
|
||||
apiUrl: modelConfig.apiUrl,
|
||||
apiKey: modelConfig.apiKey,
|
||||
model: modelConfig.modelId,
|
||||
messages: [
|
||||
{ role: "system", content: "你是专业的短视频文案改写助手。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
temperature:
|
||||
typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8,
|
||||
max_tokens: modelConfig.maxTokens || 2000,
|
||||
});
|
||||
if (!result?.success) {
|
||||
log("error", "rewrite.failed", { error: result?.error });
|
||||
return null;
|
||||
}
|
||||
return result.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整流程:解析 → Puppeteer/直链下载 → ffmpeg 提音频 → ASR → AI 仿写
|
||||
*/
|
||||
async function processDouyinShareComplete(
|
||||
shareText,
|
||||
modelConfig,
|
||||
rewriteConfig,
|
||||
customPrompt
|
||||
) {
|
||||
let videoPath = null;
|
||||
let audioPath = null;
|
||||
try {
|
||||
log("info", "pipeline.start", {
|
||||
inputLength: shareText?.length,
|
||||
asrMode: modelConfig?.asrMode,
|
||||
});
|
||||
|
||||
progress("正在解析视频URL...");
|
||||
const parseResult = DouyinContentParser.parseContent(shareText);
|
||||
if (!parseResult.success || !parseResult.content) {
|
||||
return { success: false, error: parseResult.error || "内容解析失败" };
|
||||
}
|
||||
|
||||
const { videoUrl, hashtags } = parseResult.content;
|
||||
log("info", "pipeline.urlExtracted", { videoUrl });
|
||||
|
||||
progress("正在下载视频...");
|
||||
const downloader = new DouyinVideoDownloader();
|
||||
const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl)
|
||||
? await downloader.downloadDirectVideo(videoUrl)
|
||||
: await downloader.downloadDouyinVideo(videoUrl);
|
||||
|
||||
if (!downloadResult.success || !downloadResult.videoPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: downloadResult.error || "视频下载失败",
|
||||
};
|
||||
}
|
||||
videoPath = downloadResult.videoPath;
|
||||
log("info", "pipeline.videoDownloaded", { videoPath });
|
||||
|
||||
progress("视频下载完成,正在提取音频...");
|
||||
try {
|
||||
audioPath = await pipelineNative.ffmpegExtractAudio(videoPath);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频提取失败: ${e.message || e}`,
|
||||
};
|
||||
}
|
||||
log("info", "pipeline.audioExtracted", { audioPath });
|
||||
|
||||
const asrMode = modelConfig?.asrMode || "online";
|
||||
let recognizedText = "";
|
||||
|
||||
if (asrMode === "online") {
|
||||
progress("音频提取完成,正在上传到OSS...");
|
||||
const ossConfig = modelConfig.ossConfig;
|
||||
if (!ossConfig?.bucket) {
|
||||
return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" };
|
||||
}
|
||||
const upload = await pipelineNative.aliyunOssUpload(audioPath, ossConfig);
|
||||
if (!upload?.success || !upload.url) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频上传失败: ${upload?.error || "unknown"}`,
|
||||
};
|
||||
}
|
||||
log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 80) });
|
||||
|
||||
progress("音频上传完成,正在识别文案(在线模式)...");
|
||||
const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, {
|
||||
apiKey: modelConfig.aliyunApiKey,
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
});
|
||||
if (!asr?.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `语音识别失败(在线模式): ${asr?.error || "unknown"}`,
|
||||
};
|
||||
}
|
||||
recognizedText = asr.text || "";
|
||||
} else {
|
||||
progress("音频提取完成,正在识别文案(本地模式)...");
|
||||
const info = modelConfig.asrServerInfo;
|
||||
if (!info) {
|
||||
return {
|
||||
success: false,
|
||||
error: "未配置本地语音识别服务器,请在 .env 中配置 VITE_ASR_SERVER_URL",
|
||||
};
|
||||
}
|
||||
const asr = await pipelineNative.localAsr(audioPath, info);
|
||||
if (!asr?.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `语音识别失败(本地模式): ${asr?.error || "unknown"}`,
|
||||
};
|
||||
}
|
||||
recognizedText = asr.text || "";
|
||||
}
|
||||
|
||||
if (!modelConfig?.apiUrl || !modelConfig?.apiKey) {
|
||||
return { success: false, error: "未配置AI模型,请先在 .env 中配置" };
|
||||
}
|
||||
|
||||
progress("文案识别完成,正在改写...");
|
||||
const rewrittenDescription = await rewriteWithAI(
|
||||
recognizedText,
|
||||
modelConfig,
|
||||
rewriteConfig,
|
||||
customPrompt
|
||||
);
|
||||
if (!rewrittenDescription) {
|
||||
return {
|
||||
success: false,
|
||||
error: "AI仿写失败,请检查模型配置和网络连接",
|
||||
};
|
||||
}
|
||||
|
||||
const rewrittenTitle =
|
||||
DouyinContentParser.generateTitleFromDescription(rewrittenDescription);
|
||||
const keepTags = rewriteConfig?.keepHashtags !== false;
|
||||
const fullContent =
|
||||
keepTags && hashtags?.length
|
||||
? `${rewrittenDescription}\n\n${hashtags.join(" ")}`
|
||||
: rewrittenDescription;
|
||||
|
||||
progress("完成!");
|
||||
log("info", "pipeline.success", {
|
||||
originalLength: recognizedText.length,
|
||||
rewrittenLength: rewrittenDescription.length,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
original: {
|
||||
videoUrl,
|
||||
description: recognizedText,
|
||||
hashtags: hashtags || [],
|
||||
title: "视频音频识别结果",
|
||||
},
|
||||
rewritten: {
|
||||
description: rewrittenDescription,
|
||||
title: rewrittenTitle,
|
||||
fullContent,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = (e && (e.stack || e.message)) || String(e);
|
||||
log("error", "pipeline.error", { message: msg });
|
||||
return { success: false, error: msg };
|
||||
} finally {
|
||||
if (videoPath) pipelineNative.deleteFile(videoPath);
|
||||
if (audioPath) pipelineNative.deleteFile(audioPath);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function (params) {
|
||||
const p = params || {};
|
||||
const modelConfig = desktopConfig.buildModelConfig();
|
||||
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (!check.ok) {
|
||||
return { success: false, error: check.error };
|
||||
}
|
||||
return processDouyinShareComplete(
|
||||
p.shareText || p.share_text || "",
|
||||
modelConfig,
|
||||
p.rewriteConfig,
|
||||
p.customPrompt || null
|
||||
);
|
||||
};
|
||||
484
scripts/nodejs/douyin_video_downloader.js
Normal file
484
scripts/nodejs/douyin_video_downloader.js
Normal file
@@ -0,0 +1,484 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 抖音视频下载 — Puppeteer 版(参考 zhenqianba Electron DouyinVideoDownloader)
|
||||
* 直链走 HTTP;分享链接用浏览器拦截 aweme detail API 后下载。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
|
||||
const { DouyinBrowserManager, getRuntimeDataPath } = require("./douyin_browser_manager.js");
|
||||
|
||||
const DIRECT_VIDEO_PATTERN =
|
||||
/https?:\/\/[^\s"'<>]+?\.(mp4|mov|m4v|webm|avi|mkv)(\?[^\s"'<>]*)?/i;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
class DouyinVideoDownloader {
|
||||
constructor(externalBrowser) {
|
||||
this.externalBrowser = externalBrowser || null;
|
||||
this.browserManager = DouyinBrowserManager.getInstance();
|
||||
this.userDataPath = getRuntimeDataPath("browser-data", "douyin");
|
||||
}
|
||||
|
||||
getDownloadDir() {
|
||||
const downloadDir = getRuntimeDataPath("downloads", "videos");
|
||||
if (!fs.existsSync(downloadDir)) {
|
||||
fs.mkdirSync(downloadDir, { recursive: true });
|
||||
}
|
||||
return downloadDir;
|
||||
}
|
||||
|
||||
inferVideoExtension(videoUrl) {
|
||||
try {
|
||||
const pathname = new URL(videoUrl).pathname.toLowerCase();
|
||||
const matchedExt = pathname.match(/\.(mp4|mov|m4v|webm|avi|mkv)$/i);
|
||||
if (matchedExt) return `.${matchedExt[1].toLowerCase()}`;
|
||||
} catch (e) {
|
||||
console.warn("inferVideoExtension failed:", e.message);
|
||||
}
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
isDirectVideoUrl(videoUrl) {
|
||||
return DIRECT_VIDEO_PATTERN.test(String(videoUrl || "").trim());
|
||||
}
|
||||
|
||||
downloadRemoteFile(fileUrl, savePath, headers, redirectCount = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirectCount > 5) {
|
||||
reject(new Error("Too many redirects while downloading video"));
|
||||
return;
|
||||
}
|
||||
const client = fileUrl.startsWith("https://") ? https : http;
|
||||
const fileStream = fs.createWriteStream(savePath);
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fileStream.destroy();
|
||||
if (fs.existsSync(savePath)) fs.unlinkSync(savePath);
|
||||
reject(error);
|
||||
};
|
||||
const request = client.get(fileUrl, { headers }, (response) => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
const location = response.headers.location;
|
||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
||||
fileStream.close();
|
||||
if (fs.existsSync(savePath)) fs.unlinkSync(savePath);
|
||||
const redirectUrl = location.startsWith("http")
|
||||
? location
|
||||
: new URL(location, fileUrl).toString();
|
||||
this.downloadRemoteFile(redirectUrl, savePath, headers, redirectCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
if (statusCode !== 200) {
|
||||
fail(new Error(`HTTP ${statusCode}: ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
response.pipe(fileStream);
|
||||
fileStream.on("finish", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
request.on("error", fail);
|
||||
fileStream.on("error", (err) => {
|
||||
request.destroy();
|
||||
fail(err);
|
||||
});
|
||||
request.setTimeout(120000, () => {
|
||||
request.destroy();
|
||||
fail(new Error("Download timed out after 120 seconds"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initBrowser() {
|
||||
if (this.externalBrowser) {
|
||||
console.log("使用外部提供的浏览器实例");
|
||||
return;
|
||||
}
|
||||
await this.browserManager.initBrowser();
|
||||
}
|
||||
|
||||
async extractAwemeIdAsync(url) {
|
||||
let normalizedUrl = String(url || "").trim();
|
||||
if (!normalizedUrl.startsWith("http")) {
|
||||
normalizedUrl = "https://" + normalizedUrl;
|
||||
}
|
||||
const patterns = [
|
||||
/\/video\/(\d+)/,
|
||||
/modal_id=(\d+)/,
|
||||
/share\/video\/(\d+)/,
|
||||
/item_id=(\d+)/,
|
||||
/aweme_id=(\d+)/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = normalizedUrl.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
if (
|
||||
normalizedUrl.includes("v.douyin.com") ||
|
||||
normalizedUrl.includes("iesdouyin.com")
|
||||
) {
|
||||
try {
|
||||
const realUrl = await this.resolveShortUrl(normalizedUrl);
|
||||
if (realUrl && realUrl !== normalizedUrl) {
|
||||
for (const pattern of patterns) {
|
||||
const match = realUrl.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("解析短链接失败:", e.message);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
resolveShortUrl(shortUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(shortUrl);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
},
|
||||
timeout: 30000,
|
||||
};
|
||||
const req = https.request(options, (res) => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
res.statusCode >= 300 &&
|
||||
res.statusCode < 400 &&
|
||||
res.headers.location
|
||||
) {
|
||||
resolve(res.headers.location);
|
||||
} else {
|
||||
resolve(shortUrl);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("短链接解析超时"));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
interceptVideoInfo(page, awemeId) {
|
||||
return new Promise((resolve) => {
|
||||
let found = false;
|
||||
const handler = async (response) => {
|
||||
if (found) return;
|
||||
const url = response.url();
|
||||
if (
|
||||
url.includes("aweme/v1/web/aweme/detail") &&
|
||||
url.includes(awemeId)
|
||||
) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
const jsonStr = text.replace(/^[^{]*/, "");
|
||||
data = JSON.parse(jsonStr);
|
||||
}
|
||||
if (data.aweme_detail) {
|
||||
found = true;
|
||||
page.off("response", handler);
|
||||
resolve(data.aweme_detail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析 API 响应失败:", error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
page.on("response", handler);
|
||||
setTimeout(() => {
|
||||
if (!found) {
|
||||
page.off("response", handler);
|
||||
console.log("API 拦截超时,将尝试其他方法");
|
||||
resolve(null);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadVideo(videoUrl, savePath) {
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Referer: "https://www.douyin.com/",
|
||||
Accept: "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
};
|
||||
return this.downloadRemoteFile(videoUrl, savePath, headers);
|
||||
}
|
||||
|
||||
async downloadDirectVideo(videoUrl) {
|
||||
try {
|
||||
if (!this.isDirectVideoUrl(videoUrl)) {
|
||||
return { success: false, error: "Unsupported direct video url" };
|
||||
}
|
||||
const downloadDir = this.getDownloadDir();
|
||||
const extension = this.inferVideoExtension(videoUrl);
|
||||
const videoPath = path.join(downloadDir, `direct_${Date.now()}${extension}`);
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept: "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
};
|
||||
await this.downloadRemoteFile(videoUrl, videoPath, headers);
|
||||
return {
|
||||
success: true,
|
||||
videoPath,
|
||||
audioPath: videoPath.replace(path.extname(videoPath), "_audio.wav"),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async downloadDouyinVideo(videoUrl) {
|
||||
let page = null;
|
||||
try {
|
||||
console.log("开始下载抖音视频:", videoUrl);
|
||||
const awemeId = await this.extractAwemeIdAsync(videoUrl);
|
||||
if (!awemeId) {
|
||||
return { success: false, error: "无法从URL中提取视频ID,请检查链接格式" };
|
||||
}
|
||||
console.log("提取到视频ID:", awemeId);
|
||||
|
||||
const downloadDir = this.getDownloadDir();
|
||||
const videoPath = path.join(
|
||||
downloadDir,
|
||||
`douyin_${awemeId}_${Date.now()}.mp4`
|
||||
);
|
||||
|
||||
await this.initBrowser();
|
||||
if (this.externalBrowser) {
|
||||
page = await this.externalBrowser.newPage();
|
||||
} else {
|
||||
page = await this.browserManager.newPage();
|
||||
}
|
||||
|
||||
const videoInfoPromise = this.interceptVideoInfo(page, awemeId);
|
||||
const directVideoUrl = `https://www.douyin.com/video/${awemeId}`;
|
||||
console.log("访问视频页面:", directVideoUrl);
|
||||
|
||||
try {
|
||||
await page.goto(directVideoUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
const currentUrl = page.url();
|
||||
const isLoginPage =
|
||||
currentUrl.includes("/login") || currentUrl.includes("login?");
|
||||
if (isLoginPage) {
|
||||
console.log("页面跳转到登录页,尝试引导登录...");
|
||||
await this.ensureLoggedIn(page);
|
||||
await page.goto(directVideoUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
if (page.url().includes("/login")) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"无法访问视频页面,请先扫码登录抖音(设置 AICLIENT_PUPPETEER_HEADLESS=0 显示浏览器窗口)",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (gotoError) {
|
||||
console.log(
|
||||
"页面 goto 超时(已忽略),继续等待 API:",
|
||||
gotoError.message
|
||||
);
|
||||
}
|
||||
|
||||
const videoInfo = await videoInfoPromise;
|
||||
if (!videoInfo) {
|
||||
try {
|
||||
const pageContent = await page.content();
|
||||
if (pageContent.includes("aweme_detail")) {
|
||||
return {
|
||||
success: true,
|
||||
videoPath,
|
||||
videoInfo: {
|
||||
aweme_id: awemeId,
|
||||
desc: `抖音视频 ${awemeId}`,
|
||||
},
|
||||
audioPath: videoPath.replace(".mp4", "_audio.wav"),
|
||||
};
|
||||
}
|
||||
} catch (pageError) {
|
||||
console.error("页面解析失败:", pageError.message);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: "无法获取视频信息,可能视频不存在或访问受限",
|
||||
};
|
||||
}
|
||||
|
||||
const videoUrls = videoInfo.video?.play_addr?.url_list;
|
||||
if (!videoUrls || !videoUrls.length) {
|
||||
return { success: false, error: "无法获取视频下载链接,可能视频版权限制" };
|
||||
}
|
||||
|
||||
const downloadUrl = videoUrls[0];
|
||||
console.log("开始下载视频文件...");
|
||||
await this.downloadVideo(downloadUrl, videoPath);
|
||||
console.log("视频下载完成:", videoPath, "size:", fs.statSync(videoPath).size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoPath,
|
||||
videoInfo,
|
||||
audioPath: videoPath.replace(".mp4", "_audio.wav"),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("下载视频失败:", error);
|
||||
let errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes("3221225781") ||
|
||||
errorMessage.includes("0xC0000135")
|
||||
) {
|
||||
errorMessage =
|
||||
"浏览器启动失败(错误码: 0xC0000135)。请安装 VC++ 运行库: https://aka.ms/vs/17/release/vc_redist.x64.exe";
|
||||
} else if (/puppeteer|browser|Could not find Chrome/i.test(errorMessage)) {
|
||||
errorMessage =
|
||||
"浏览器启动失败。请在 src-tauri/resources/nodejs 执行 npm install,或设置 AICLIENT_CHROMIUM_PATH";
|
||||
} else if (/timeout/i.test(errorMessage)) {
|
||||
errorMessage = "网络超时,请检查网络连接或重试";
|
||||
}
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
if (page) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch (closeError) {
|
||||
console.log("关闭页面失败:", closeError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoggedIn(page) {
|
||||
try {
|
||||
if (this.externalBrowser) {
|
||||
console.log("使用外部浏览器,跳过登录检查");
|
||||
return;
|
||||
}
|
||||
const loginCookieNames = [
|
||||
"sessionid",
|
||||
"uid_tt",
|
||||
"LOGIN_STATUS",
|
||||
"sid_guard",
|
||||
"sid_tt",
|
||||
];
|
||||
let cookies = await page.cookies("https://www.douyin.com");
|
||||
let loginCookies = cookies.filter(
|
||||
(c) => loginCookieNames.includes(c.name) && c.value
|
||||
);
|
||||
if (loginCookies.length > 0) {
|
||||
console.log("检测到已登录 Cookie");
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto("https://www.douyin.com/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await sleep(2000);
|
||||
|
||||
const isLoggedIn = await page.evaluate(() => {
|
||||
const loginBtnSelectors = [
|
||||
'[data-e2e="login-button"]',
|
||||
".login-btn",
|
||||
'button[class*="login"]',
|
||||
];
|
||||
const userInfoSelectors = [
|
||||
'[data-e2e="user-info"]',
|
||||
'[data-e2e="user-avatar"]',
|
||||
'[class*="userInfo"]',
|
||||
".user-avatar",
|
||||
".nickname",
|
||||
];
|
||||
const hasLoginButton = loginBtnSelectors.some((s) =>
|
||||
document.querySelector(s)
|
||||
);
|
||||
const hasUserInfo = userInfoSelectors.some((s) =>
|
||||
document.querySelector(s)
|
||||
);
|
||||
return hasUserInfo || !hasLoginButton;
|
||||
});
|
||||
|
||||
if (isLoggedIn) {
|
||||
console.log("用户已登录");
|
||||
return;
|
||||
}
|
||||
|
||||
const headless =
|
||||
process.env.AICLIENT_PUPPETEER_HEADLESS !== "0" &&
|
||||
process.env.AICLIENT_PUPPETEER_HEADLESS !== "false";
|
||||
if (headless) {
|
||||
console.log(
|
||||
"无头模式下无法扫码登录。请设置环境变量 AICLIENT_PUPPETEER_HEADLESS=0 后重试"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("=".repeat(60));
|
||||
console.log("请在浏览器窗口中使用抖音 APP 扫码登录");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const loginTimeout = 120000;
|
||||
const checkInterval = 3000;
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < loginTimeout) {
|
||||
await sleep(checkInterval);
|
||||
cookies = await page.cookies("https://www.douyin.com");
|
||||
loginCookies = cookies.filter(
|
||||
(c) => loginCookieNames.includes(c.name) && c.value
|
||||
);
|
||||
if (loginCookies.length > 0) {
|
||||
console.log("登录成功");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登录引导失败:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.externalBrowser) return;
|
||||
try {
|
||||
await this.browserManager.close();
|
||||
} catch (error) {
|
||||
console.error("关闭浏览器失败:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DouyinVideoDownloader.DIRECT_VIDEO_PATTERN = DIRECT_VIDEO_PATTERN;
|
||||
|
||||
module.exports = { DouyinVideoDownloader };
|
||||
321
scripts/nodejs/pipeline_native.js
Normal file
321
scripts/nodejs/pipeline_native.js
Normal file
@@ -0,0 +1,321 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Node 流水线宿主能力(对齐 QuickJS __native / Rust js_runtime/native.rs)
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
const { spawn } = require("child_process");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
|
||||
const TEMP_DIR = path.join(os.tmpdir(), "aiclient-node-pipeline");
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function tempPath(prefix, ext) {
|
||||
return path.join(TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext || "tmp"}`);
|
||||
}
|
||||
|
||||
function deleteFile(p) {
|
||||
try {
|
||||
if (p && fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(cmd, args, timeoutMs = 300000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`命令超时: ${cmd}`));
|
||||
}, timeoutMs);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} 退出码 ${code}: ${stderr.slice(0, 500)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function locateFfmpeg() {
|
||||
if (process.env.AICLIENT_FFMPEG_PATH && fs.existsSync(process.env.AICLIENT_FFMPEG_PATH)) {
|
||||
return process.env.AICLIENT_FFMPEG_PATH;
|
||||
}
|
||||
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
}
|
||||
|
||||
async function ffmpegExtractAudio(videoPath) {
|
||||
const dest = tempPath("audio", "wav");
|
||||
const ffmpeg = locateFfmpeg();
|
||||
await runCommand(ffmpeg, [
|
||||
"-y",
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
dest,
|
||||
]);
|
||||
return dest;
|
||||
}
|
||||
|
||||
function rfc1123Date() {
|
||||
return new Date().toUTCString();
|
||||
}
|
||||
|
||||
function signOss(secret, str) {
|
||||
return crypto.createHmac("sha1", secret).update(str).digest("base64");
|
||||
}
|
||||
|
||||
async function aliyunOssUpload(localPath, cfg) {
|
||||
const ak = cfg.accessKeyId;
|
||||
const sk = cfg.accessKeySecret;
|
||||
const bucket = cfg.bucket;
|
||||
const region = cfg.region;
|
||||
if (!ak || !sk || !bucket || !region) {
|
||||
return { success: false, error: "OSS 配置不完整" };
|
||||
}
|
||||
const objectKey = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
|
||||
const host = `${bucket}.${region}.aliyuncs.com`;
|
||||
const endpoint = (cfg.endpoint || `https://${host}`).replace(/\/$/, "");
|
||||
const url = `${endpoint}/${objectKey}`;
|
||||
const body = fs.readFileSync(localPath);
|
||||
const date = rfc1123Date();
|
||||
const contentType = "audio/wav";
|
||||
const canonicalizedResource = `/${bucket}/${objectKey}`;
|
||||
const strToSign = `PUT\n\n${contentType}\n${date}\n${canonicalizedResource}`;
|
||||
const signature = signOss(sk, strToSign);
|
||||
const authorization = `OSS ${ak}:${signature}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const u = new URL(url);
|
||||
const opts = {
|
||||
hostname: u.hostname,
|
||||
port: u.port || 443,
|
||||
path: u.pathname,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Date: date,
|
||||
"Content-Type": contentType,
|
||||
Authorization: authorization,
|
||||
"Content-Length": body.length,
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ success: true, url });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `OSS HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", (e) => resolve({ success: false, error: e.message }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { _raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function aliyunAsrFiletrans(audioUrl, opts) {
|
||||
const apiKey = opts.apiKey || opts.api_key;
|
||||
const model = opts.model || "qwen3-asr-flash-filetrans";
|
||||
if (!apiKey) {
|
||||
return { success: false, error: "缺少 DashScope apiKey" };
|
||||
}
|
||||
const submit = await fetchJson(
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { file_urls: [audioUrl] },
|
||||
parameters: {},
|
||||
}),
|
||||
}
|
||||
);
|
||||
const taskId = submit?.output?.task_id;
|
||||
if (!taskId) {
|
||||
return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` };
|
||||
}
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(5000);
|
||||
const poll = await fetchJson(
|
||||
`https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`,
|
||||
{ headers: { Authorization: `Bearer ${apiKey}` } }
|
||||
);
|
||||
const status = poll?.output?.task_status;
|
||||
if (status === "SUCCEEDED") {
|
||||
const transUrl = poll?.output?.results?.[0]?.transcription_url;
|
||||
if (!transUrl) {
|
||||
return { success: false, error: "ASR 成功但无 transcription_url" };
|
||||
}
|
||||
const trans = await fetchJson(transUrl);
|
||||
let fullText = "";
|
||||
if (trans.transcripts && trans.transcripts.length) {
|
||||
fullText = trans.transcripts.map((t) => t.text || "").filter(Boolean).join("\n");
|
||||
} else if (trans.text) {
|
||||
fullText = trans.text;
|
||||
}
|
||||
return { success: true, text: fullText };
|
||||
}
|
||||
if (status === "FAILED" || status === "UNKNOWN") {
|
||||
return {
|
||||
success: false,
|
||||
error: poll?.output?.message || "ASR 任务失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: false, error: "ASR 轮询超时" };
|
||||
}
|
||||
|
||||
async function localAsr(audioPath, info) {
|
||||
const baseUrl = (info.baseUrl || info.localPath || "").replace(/\/$/, "");
|
||||
if (!baseUrl) {
|
||||
return { success: false, error: "未配置本地 ASR 服务地址" };
|
||||
}
|
||||
const asrPath = info.asrPath || "/asr";
|
||||
const url = `${baseUrl}${asrPath.startsWith("/") ? asrPath : `/${asrPath}`}`;
|
||||
const boundary = `----aiclient${Date.now()}`;
|
||||
const fileBuf = fs.readFileSync(audioPath);
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`,
|
||||
"utf8"
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
|
||||
const body = Buffer.concat([head, fileBuf, tail]);
|
||||
const u = new URL(url);
|
||||
const client = u.protocol === "https:" ? https : http;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = client.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
||||
path: u.pathname + u.search,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
"Content-Length": body.length,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
const data = JSON.parse(raw);
|
||||
if (data.code === 0 || data.success) {
|
||||
const records =
|
||||
data.data?.data?.records ||
|
||||
data.data?.records ||
|
||||
data.records ||
|
||||
[];
|
||||
const text = Array.isArray(records)
|
||||
? records.map((r) => r.text || "").join("")
|
||||
: data.text || data.data?.text || "";
|
||||
resolve({ success: true, text: String(text) });
|
||||
} else {
|
||||
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", (e) => resolve({ success: false, error: e.message }));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function openaiChat(req) {
|
||||
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
|
||||
if (!apiUrl.includes("/chat/completions")) {
|
||||
apiUrl = apiUrl.endsWith("/v1")
|
||||
? `${apiUrl}/chat/completions`
|
||||
: `${apiUrl}/v1/chat/completions`;
|
||||
}
|
||||
const body = {
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
stream: false,
|
||||
temperature: typeof req.temperature === "number" ? req.temperature : 0.8,
|
||||
max_tokens: req.max_tokens || req.maxTokens || 2000,
|
||||
};
|
||||
try {
|
||||
const data = await fetchJson(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${req.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
return { success: false, error: "模型返回为空" };
|
||||
}
|
||||
return { success: true, content: String(content).trim() };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tempPath,
|
||||
deleteFile,
|
||||
ffmpegExtractAudio,
|
||||
aliyunOssUpload,
|
||||
aliyunAsrFiletrans,
|
||||
localAsr,
|
||||
openaiChat,
|
||||
};
|
||||
66
scripts/nodejs/video_rewrite_optimizer.js
Normal file
66
scripts/nodejs/video_rewrite_optimizer.js
Normal file
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
|
||||
/** 仿写提示词(对齐 zhenqianba VideoRewriteOptimizer) */
|
||||
|
||||
class VideoRewriteOptimizer {
|
||||
static replacePromptVariables(template, content) {
|
||||
return String(template)
|
||||
.replace(/\{\{content\}\}/g, content)
|
||||
.replace(/\{content\}/g, content)
|
||||
.replace(/\{\{text\}\}/g, content)
|
||||
.replace(/\{text\}/g, content);
|
||||
}
|
||||
|
||||
static getStyleGuide(style) {
|
||||
const guides = {
|
||||
casual: "轻松随意、接地气、易产生共鸣,像和朋友聊天一样",
|
||||
professional: "正式专业、有信服力、适合商务或教育内容",
|
||||
emotional: "充满情感、富有感染力、能打动人心",
|
||||
humorous: "幽默诙谐、容易引起笑声和转发、保持积极态度",
|
||||
};
|
||||
return guides[style] || guides.casual;
|
||||
}
|
||||
|
||||
static getLengthGuide(length) {
|
||||
const guides = {
|
||||
short: "简洁有力,100字以内,快速传达核心信息",
|
||||
medium: "适中篇幅,100-300字,既能完整表达又不显冗长",
|
||||
long: "详细深入,300-500字,充分展开论点和细节",
|
||||
};
|
||||
return guides[length] || guides.medium;
|
||||
}
|
||||
|
||||
static buildDefaultPrompt(content, config) {
|
||||
const cfg = config || {};
|
||||
const styleGuide = this.getStyleGuide(cfg.style || "casual");
|
||||
const lengthGuide = this.getLengthGuide(cfg.length || "medium");
|
||||
return `你是一个专业的视频内容创意编写专家,擅长创作吸引人的视频文案。
|
||||
|
||||
## 原始文案:
|
||||
${content}
|
||||
|
||||
## 仿写要求:
|
||||
1. **风格**: ${styleGuide}
|
||||
2. **长度**: ${lengthGuide}
|
||||
3. **核心保持**: 保留原文案的核心信息和主题
|
||||
4. **创意提升**: 在措辞、表述角度、情感吸引力上创新
|
||||
5. **格式**:
|
||||
${cfg.keepHashtags !== false ? "- 保留原有话题标签(#开头的内容)" : "- 不使用话题标签"}
|
||||
${cfg.keepEmoji !== false ? "- 可以适当添加表情符号增加生动性" : "- 不使用表情符号"}
|
||||
6. **禁止事项**:
|
||||
- 不要改变事实信息
|
||||
- 不要添加虚假承诺
|
||||
- 不要包含敏感词汇
|
||||
|
||||
请直接输出仿写后的文案,不需要任何额外说明或标记。`;
|
||||
}
|
||||
|
||||
static buildRewritePrompt(content, config = {}, customPrompt) {
|
||||
if (customPrompt && String(customPrompt).trim()) {
|
||||
return this.replacePromptVariables(customPrompt, content);
|
||||
}
|
||||
return this.buildDefaultPrompt(content, config);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VideoRewriteOptimizer };
|
||||
Reference in New Issue
Block a user