This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:13 +08:00
parent 1b8ca10ec9
commit 3db327d93b
38 changed files with 2820 additions and 14 deletions

View File

@@ -17,3 +17,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES=10080
# CORS逗号分隔* 表示允许全部)
CORS_ORIGINS=http://localhost:1420,tauri://localhost
# 桌面端配置写入 MySQL 表 desktop_configsname / 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_CONFIGJSON等。

View File

@@ -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()

View 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")

View 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")

View 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")

View 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")

View 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="卡密已删除")

View 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
View 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
View 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)

View 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})

View File

@@ -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
View File

@@ -0,0 +1,8 @@
ROLE_ADMIN = 1
ROLE_AGENT = 2
ROLE_LABELS: dict[int, str] = {
0: "普通用户",
ROLE_ADMIN: "管理员",
ROLE_AGENT: "代理",
}

View File

@@ -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)]

View File

@@ -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
View 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)

View 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())

View File

@@ -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())

View 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

View 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
View 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

View File

@@ -0,0 +1,6 @@
"""桌面端应用配置name -> value 字典。"""
from typing import Dict
# GET /api/v1/appConfig 的 data 载荷类型
AppConfigMap = Dict[str, str]

View File

@@ -37,6 +37,7 @@ class UserPublic(BaseModel):
id: int
username: str
role_id: int
class TokenResponse(BaseModel):

View File

@@ -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,
)

View 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)

View 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
View 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)

View File

@@ -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)

View 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}

View File

@@ -0,0 +1,129 @@
"use strict";
/**
* 从进程环境变量读取 desktop_configsRust 注入 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_KEYdesktop_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,
};

View 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,
};

View 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 };

View 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 };

View 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: "未配置阿里云 OSSbucket / 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
);
};

View 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 };

View 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,
};

View 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 };