From 3db327d93b8afbe0cbd585752ea0256843ea50b8 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Sun, 17 May 2026 12:54:13 +0800 Subject: [PATCH] 22 --- .env.example | 4 + alembic/env.py | 2 +- .../002_create_desktop_configs_table.py | 44 ++ .../versions/003_add_user_role_phone_vip.py | 40 ++ .../versions/004_create_card_keys_table.py | 46 ++ .../versions/005_add_desktop_config_mark.py | 28 + app/api/v1/admin_card_keys.py | 103 ++++ app/api/v1/admin_desktop_configs.py | 95 ++++ app/api/v1/admin_users.py | 91 ++++ app/api/v1/app_config.py | 19 + app/api/v1/nodejs_scripts.py | 47 ++ app/api/v1/router.py | 15 +- app/core/roles.py | 8 + app/dependencies.py | 13 + app/models/__init__.py | 4 +- app/models/card_key.py | 25 + app/models/desktopConfig.py | 17 + app/models/user.py | 18 +- app/schemas/admin_card_key.py | 64 +++ app/schemas/admin_desktop_config.py | 54 ++ app/schemas/admin_user.py | 60 +++ app/schemas/app_config.py | 6 + app/schemas/auth.py | 1 + app/schemas/common.py | 28 + app/services/admin_card_key.py | 128 +++++ app/services/admin_desktop_config.py | 88 ++++ app/services/admin_user.py | 109 ++++ app/services/auth.py | 7 +- app/services/desktop_config.py | 12 + scripts/nodejs/desktop_config.js | 129 +++++ scripts/nodejs/douyin_browser_constants.js | 70 +++ scripts/nodejs/douyin_browser_manager.js | 216 ++++++++ scripts/nodejs/douyin_content_parser.js | 147 ++++++ scripts/nodejs/douyin_pipeline.js | 225 ++++++++ scripts/nodejs/douyin_video_downloader.js | 484 ++++++++++++++++++ scripts/nodejs/pipeline_native.js | 321 ++++++++++++ scripts/nodejs/video_rewrite_optimizer.js | 66 +++ ..._pipeline.js => douyin_pipeline.js.bakcup} | 0 38 files changed, 2820 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/002_create_desktop_configs_table.py create mode 100644 alembic/versions/003_add_user_role_phone_vip.py create mode 100644 alembic/versions/004_create_card_keys_table.py create mode 100644 alembic/versions/005_add_desktop_config_mark.py create mode 100644 app/api/v1/admin_card_keys.py create mode 100644 app/api/v1/admin_desktop_configs.py create mode 100644 app/api/v1/admin_users.py create mode 100644 app/api/v1/app_config.py create mode 100644 app/api/v1/nodejs_scripts.py create mode 100644 app/core/roles.py create mode 100644 app/models/card_key.py create mode 100644 app/models/desktopConfig.py create mode 100644 app/schemas/admin_card_key.py create mode 100644 app/schemas/admin_desktop_config.py create mode 100644 app/schemas/admin_user.py create mode 100644 app/schemas/app_config.py create mode 100644 app/services/admin_card_key.py create mode 100644 app/services/admin_desktop_config.py create mode 100644 app/services/admin_user.py create mode 100644 app/services/desktop_config.py create mode 100644 scripts/nodejs/desktop_config.js create mode 100644 scripts/nodejs/douyin_browser_constants.js create mode 100644 scripts/nodejs/douyin_browser_manager.js create mode 100644 scripts/nodejs/douyin_content_parser.js create mode 100644 scripts/nodejs/douyin_pipeline.js create mode 100644 scripts/nodejs/douyin_video_downloader.js create mode 100644 scripts/nodejs/pipeline_native.js create mode 100644 scripts/nodejs/video_rewrite_optimizer.js rename scripts/quickjs/{douyin_pipeline.js => douyin_pipeline.js.bakcup} (100%) diff --git a/.env.example b/.env.example index 237b51c..26ea73e 100644 --- a/.env.example +++ b/.env.example @@ -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_。 +# 示例键名:LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIG(JSON)等。 diff --git a/alembic/env.py b/alembic/env.py index 56edc0e..93567d3 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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() diff --git a/alembic/versions/002_create_desktop_configs_table.py b/alembic/versions/002_create_desktop_configs_table.py new file mode 100644 index 0000000..404c8e3 --- /dev/null +++ b/alembic/versions/002_create_desktop_configs_table.py @@ -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") diff --git a/alembic/versions/003_add_user_role_phone_vip.py b/alembic/versions/003_add_user_role_phone_vip.py new file mode 100644 index 0000000..5cca1f1 --- /dev/null +++ b/alembic/versions/003_add_user_role_phone_vip.py @@ -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") diff --git a/alembic/versions/004_create_card_keys_table.py b/alembic/versions/004_create_card_keys_table.py new file mode 100644 index 0000000..aeb9576 --- /dev/null +++ b/alembic/versions/004_create_card_keys_table.py @@ -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") diff --git a/alembic/versions/005_add_desktop_config_mark.py b/alembic/versions/005_add_desktop_config_mark.py new file mode 100644 index 0000000..a520b2c --- /dev/null +++ b/alembic/versions/005_add_desktop_config_mark.py @@ -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") diff --git a/app/api/v1/admin_card_keys.py b/app/api/v1/admin_card_keys.py new file mode 100644 index 0000000..7587745 --- /dev/null +++ b/app/api/v1/admin_card_keys.py @@ -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="卡密已删除") diff --git a/app/api/v1/admin_desktop_configs.py b/app/api/v1/admin_desktop_configs.py new file mode 100644 index 0000000..2fb3dfc --- /dev/null +++ b/app/api/v1/admin_desktop_configs.py @@ -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="配置已删除") diff --git a/app/api/v1/admin_users.py b/app/api/v1/admin_users.py new file mode 100644 index 0000000..eb73bd0 --- /dev/null +++ b/app/api/v1/admin_users.py @@ -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="用户已删除") diff --git a/app/api/v1/app_config.py b/app/api/v1/app_config.py new file mode 100644 index 0000000..14bbc71 --- /dev/null +++ b/app/api/v1/app_config.py @@ -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) diff --git a/app/api/v1/nodejs_scripts.py b/app/api/v1/nodejs_scripts.py new file mode 100644 index 0000000..50fb40e --- /dev/null +++ b/app/api/v1/nodejs_scripts.py @@ -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}) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 5b7352d..527ea9b 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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) diff --git a/app/core/roles.py b/app/core/roles.py new file mode 100644 index 0000000..c6016d3 --- /dev/null +++ b/app/core/roles.py @@ -0,0 +1,8 @@ +ROLE_ADMIN = 1 +ROLE_AGENT = 2 + +ROLE_LABELS: dict[int, str] = { + 0: "普通用户", + ROLE_ADMIN: "管理员", + ROLE_AGENT: "代理", +} diff --git a/app/dependencies.py b/app/dependencies.py index f877d70..b23e353 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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)] diff --git a/app/models/__init__.py b/app/models/__init__.py index b2e47e8..370961c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/card_key.py b/app/models/card_key.py new file mode 100644 index 0000000..25d3ebb --- /dev/null +++ b/app/models/card_key.py @@ -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) diff --git a/app/models/desktopConfig.py b/app/models/desktopConfig.py new file mode 100644 index 0000000..d70a852 --- /dev/null +++ b/app/models/desktopConfig.py @@ -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()) diff --git a/app/models/user.py b/app/models/user.py index 646d8d3..ef5a2bf 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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()) diff --git a/app/schemas/admin_card_key.py b/app/schemas/admin_card_key.py new file mode 100644 index 0000000..dc8d1df --- /dev/null +++ b/app/schemas/admin_card_key.py @@ -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 diff --git a/app/schemas/admin_desktop_config.py b/app/schemas/admin_desktop_config.py new file mode 100644 index 0000000..3306cbe --- /dev/null +++ b/app/schemas/admin_desktop_config.py @@ -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 diff --git a/app/schemas/admin_user.py b/app/schemas/admin_user.py new file mode 100644 index 0000000..eaf193f --- /dev/null +++ b/app/schemas/admin_user.py @@ -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 diff --git a/app/schemas/app_config.py b/app/schemas/app_config.py new file mode 100644 index 0000000..9b024b0 --- /dev/null +++ b/app/schemas/app_config.py @@ -0,0 +1,6 @@ +"""桌面端应用配置:name -> value 字典。""" + +from typing import Dict + +# GET /api/v1/appConfig 的 data 载荷类型 +AppConfigMap = Dict[str, str] diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 1217fe8..dd1af82 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -37,6 +37,7 @@ class UserPublic(BaseModel): id: int username: str + role_id: int class TokenResponse(BaseModel): diff --git a/app/schemas/common.py b/app/schemas/common.py index cbc4222..c17343e 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -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, + ) diff --git a/app/services/admin_card_key.py b/app/services/admin_card_key.py new file mode 100644 index 0000000..d53426a --- /dev/null +++ b/app/services/admin_card_key.py @@ -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) diff --git a/app/services/admin_desktop_config.py b/app/services/admin_desktop_config.py new file mode 100644 index 0000000..9266d27 --- /dev/null +++ b/app/services/admin_desktop_config.py @@ -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) diff --git a/app/services/admin_user.py b/app/services/admin_user.py new file mode 100644 index 0000000..e252d6a --- /dev/null +++ b/app/services/admin_user.py @@ -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) diff --git a/app/services/auth.py b/app/services/auth.py index d5cf53f..d32a4af 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -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) diff --git a/app/services/desktop_config.py b/app/services/desktop_config.py new file mode 100644 index 0000000..16675b0 --- /dev/null +++ b/app/services/desktop_config.py @@ -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} diff --git a/scripts/nodejs/desktop_config.js b/scripts/nodejs/desktop_config.js new file mode 100644 index 0000000..157a03c --- /dev/null +++ b/scripts/nodejs/desktop_config.js @@ -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} 逻辑键(大写+下划线)→ 值 */ +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, +}; diff --git a/scripts/nodejs/douyin_browser_constants.js b/scripts/nodejs/douyin_browser_constants.js new file mode 100644 index 0000000..3e02da7 --- /dev/null +++ b/scripts/nodejs/douyin_browser_constants.js @@ -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, +}; diff --git a/scripts/nodejs/douyin_browser_manager.js b/scripts/nodejs/douyin_browser_manager.js new file mode 100644 index 0000000..2221748 --- /dev/null +++ b/scripts/nodejs/douyin_browser_manager.js @@ -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 }; diff --git a/scripts/nodejs/douyin_content_parser.js b/scripts/nodejs/douyin_content_parser.js new file mode 100644 index 0000000..d127932 --- /dev/null +++ b/scripts/nodejs/douyin_content_parser.js @@ -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 }; diff --git a/scripts/nodejs/douyin_pipeline.js b/scripts/nodejs/douyin_pipeline.js new file mode 100644 index 0000000..21b020f --- /dev/null +++ b/scripts/nodejs/douyin_pipeline.js @@ -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 + ); +}; diff --git a/scripts/nodejs/douyin_video_downloader.js b/scripts/nodejs/douyin_video_downloader.js new file mode 100644 index 0000000..f5520bb --- /dev/null +++ b/scripts/nodejs/douyin_video_downloader.js @@ -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 }; diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js new file mode 100644 index 0000000..b5b8965 --- /dev/null +++ b/scripts/nodejs/pipeline_native.js @@ -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, +}; diff --git a/scripts/nodejs/video_rewrite_optimizer.js b/scripts/nodejs/video_rewrite_optimizer.js new file mode 100644 index 0000000..124a64b --- /dev/null +++ b/scripts/nodejs/video_rewrite_optimizer.js @@ -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 }; diff --git a/scripts/quickjs/douyin_pipeline.js b/scripts/quickjs/douyin_pipeline.js.bakcup similarity index 100% rename from scripts/quickjs/douyin_pipeline.js rename to scripts/quickjs/douyin_pipeline.js.bakcup