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