基础系统版本

main
mula.liu 2026-02-25 16:48:31 +08:00
commit 72ccd010c1
2377 changed files with 884792 additions and 0 deletions

30
.gitignore vendored 100644
View File

@ -0,0 +1,30 @@
# macOS
.DS_Store
# env files
.env
.env.*
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.log
.venv/
backend/.venv/
.pytest_cache/
.mypy_cache/
# Node
node_modules/
frontend/node_modules/
frontend/dist/
# Editor
.idea/
.vscode/
# AI
.claude/
.gemini-clipboard/

130
README.md 100644
View File

@ -0,0 +1,130 @@
# Nex Basse 基础平台
本项目是面向团队快速开发的基础平台,包含用户、角色、权限、码表、系统参数等核心模块。前端采用 React + TypeScript后端为 FastAPIPython 3.9+),数据库先实现 MySQL 版本。
## 主要功能
- 用户管理
- 用户列表、创建、编辑、禁用、删除
- 重置密码、角色分配
- 角色管理
- 角色列表、创建
- 角色权限分配(权限树勾选)
- 权限与菜单
- 功能权限树(三级:一级/二级菜单 + 三级按钮)
- 菜单动态加载、按角色权限可见
- 码表管理
- 码表类型与条目管理
- 系统参数管理
- 多类型参数string/int/bool/json
## 技术栈
### 前端
- React 18 + TypeScript
- Vite 5
- Ant Design 5
- 统一组件:`ListTable`、`Toast`、`DetailDrawer`
### 后端
- FastAPI
- SQLAlchemy 2.0
- Alembic
- Redistoken/刷新 token/异步任务占位)
- PyMySQL
### 数据库
- MySQL 5.7开发、8.0+(部署)
- 字符集:`utf8mb4`,排序规则:`utf8mb4_unicode_ci`
- 状态字段统一:`status TINYINT(1)`
## 目录结构
```
.
├─ backend
│ ├─ app
│ │ ├─ api/v1/endpoints
│ │ ├─ core
│ │ ├─ models
│ │ ├─ schemas
│ │ └─ services
│ ├─ docs
│ │ ├─ db_schema.md
│ │ └─ migrations
│ ├─ scripts
│ ├─ requirements.txt
│ └─ ENV.md
├─ design
│ └─ 页面参考图与数据库设计
├─ frontend
│ ├─ src
│ │ ├─ components
│ │ ├─ layout
│ │ └─ pages
│ ├─ package.json
│ └─ vite.config.ts
└─ README.md
```
## 本地启动
### 1. 后端
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 2. 前端
```bash
cd frontend
yarn install
yarn dev --host 0.0.0.0 --port 5173
```
## 环境配置
复制示例文件并填写:
- `backend/.env.example` -> `backend/.env`
- `frontend/.env.example` -> `frontend/.env`
详情见:`backend/ENV.md`
## 数据库初始化
执行初始化 SQL
```bash
mysql -u <user> -p <db_name> < backend/docs/init_mysql.sql
```
若已有旧数据,参考 `backend/docs/migrations/` 中的迁移脚本。
## 管理员账号
默认账号密码:
```
admin / 123456
```
可通过脚本重置:
```bash
cd backend
source .venv/bin/activate
INIT_ADMIN_PASSWORD=123456 PYTHONPATH=./ /Users/jiliu/WorkSpace/nex_basse/backend/.venv/bin/python scripts/init_admin.py
```
## 备注
- 功能页面统一在主框架右侧载入(左侧菜单可收起/展开)
- 新增/编辑使用右侧抽屉DetailDrawer
- 后端权限树为三级结构:一级/二级菜单 + 三级按钮

34
backend/ENV.md 100644
View File

@ -0,0 +1,34 @@
# 环境配置说明
本项目默认使用 `.env` 读取配置。请复制 `backend/.env.example``backend/.env` 后填写。
## MySQL
- 开发环境 MySQL 5.7,生产 MySQL 8.0+
- 表字符集:`utf8mb4`,排序规则:`utf8mb4_unicode_ci`
## Redis
- 主要用于 token、黑名单与异步任务状态
## Token 过期时间
- 默认从环境变量读取
- **正式逻辑**:存入系统参数 `sys_param`
- `security.access_token_minutes`
- `security.refresh_token_minutes`
## 启动示例
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## 初始化数据库
1. 先执行 `backend/docs/init_mysql.sql`
2. 初始化管理员账号默认admin / 123456
```bash
cd backend
source .venv/bin/activate
python -m scripts.init_admin
```

View File

@ -0,0 +1,35 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@ -0,0 +1,67 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
def get_url() -> str:
settings = get_settings()
return (
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
"?charset=utf8mb4"
)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,291 @@
"""add cascade delete to role permission
Revision ID: 99adde8b54c2
Revises:
Create Date: 2026-02-13 16:17:57.673230
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '99adde8b54c2'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('sys_dict_item', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('idx_dict_item_type', table_name='sys_dict_item')
op.create_index(op.f('ix_sys_dict_item_type_code'), 'sys_dict_item', ['type_code'], unique=False)
op.alter_column('sys_dict_type', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('type_code', table_name='sys_dict_type')
op.create_unique_constraint('uk_dict_type_code', 'sys_dict_type', ['type_code'])
op.alter_column('sys_log', 'id',
existing_type=mysql.BIGINT(),
comment=None,
existing_comment='日志ID',
existing_nullable=False,
autoincrement=True)
op.alter_column('sys_log', 'user_id',
existing_type=mysql.BIGINT(),
type_=sa.Integer(),
comment=None,
existing_comment='操作用户ID',
existing_nullable=True)
op.alter_column('sys_log', 'username',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment=None,
existing_comment='用户名',
existing_nullable=True)
op.alter_column('sys_log', 'operation_type',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment=None,
existing_comment='操作类型',
existing_nullable=False)
op.alter_column('sys_log', 'resource_type',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment=None,
existing_comment='资源类型',
existing_nullable=False)
op.alter_column('sys_log', 'resource_id',
existing_type=mysql.BIGINT(),
comment=None,
existing_comment='资源ID',
existing_nullable=True)
op.alter_column('sys_log', 'detail',
existing_type=mysql.TEXT(charset='utf8mb4', collation='utf8mb4_0900_ai_ci'),
comment=None,
existing_comment='操作详情JSON',
existing_nullable=True)
op.alter_column('sys_log', 'ip_address',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment=None,
existing_comment='IP地址',
existing_nullable=True)
op.alter_column('sys_log', 'user_agent',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=500),
comment=None,
existing_comment='用户代理',
existing_nullable=True)
op.alter_column('sys_log', 'status',
existing_type=mysql.TINYINT(),
type_=sa.Integer(),
nullable=False,
comment=None,
existing_comment='状态0-失败 1-成功',
existing_server_default=sa.text("'1'"))
op.alter_column('sys_log', 'error_message',
existing_type=mysql.TEXT(charset='utf8mb4', collation='utf8mb4_0900_ai_ci'),
comment=None,
existing_comment='错误信息',
existing_nullable=True)
op.alter_column('sys_log', 'created_at',
existing_type=mysql.DATETIME(),
nullable=False,
comment=None,
existing_comment='操作时间',
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_index('idx_created_at', table_name='sys_log')
op.drop_index('idx_resource', table_name='sys_log')
op.drop_index('idx_user_id', table_name='sys_log')
op.create_index(op.f('ix_sys_log_created_at'), 'sys_log', ['created_at'], unique=False)
op.create_index(op.f('ix_sys_log_resource_id'), 'sys_log', ['resource_id'], unique=False)
op.create_index(op.f('ix_sys_log_user_id'), 'sys_log', ['user_id'], unique=False)
op.drop_constraint('operation_logs_ibfk_1', 'sys_log', type_='foreignkey')
op.create_foreign_key(None, 'sys_log', 'sys_user', ['user_id'], ['user_id'])
op.drop_table_comment(
'sys_log',
existing_comment='操作日志表',
schema=None
)
op.alter_column('sys_param', 'param_type',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20),
type_=sa.Enum('string', 'int', 'bool', 'json', name='paramtypeenum', native_enum=False),
existing_nullable=False,
existing_server_default=sa.text("'string'"))
op.alter_column('sys_param', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('param_key', table_name='sys_param')
op.create_unique_constraint('uk_param_key', 'sys_param', ['param_key'])
op.alter_column('sys_permission', 'perm_type',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20),
type_=sa.Enum('menu', 'button', name='permissiontypeenum', native_enum=False),
existing_nullable=False)
op.alter_column('sys_permission', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('idx_perm_parent', table_name='sys_permission')
op.create_index(op.f('ix_sys_permission_parent_id'), 'sys_permission', ['parent_id'], unique=False)
op.drop_column('sys_permission', 'component')
op.alter_column('sys_role', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('idx_role_perm_perm', table_name='sys_role_permission')
op.drop_index('idx_role_perm_role', table_name='sys_role_permission')
op.create_index(op.f('ix_sys_role_permission_perm_id'), 'sys_role_permission', ['perm_id'], unique=False)
op.create_index(op.f('ix_sys_role_permission_role_id'), 'sys_role_permission', ['role_id'], unique=False)
op.create_foreign_key(None, 'sys_role_permission', 'sys_permission', ['perm_id'], ['perm_id'], ondelete='CASCADE')
op.create_foreign_key(None, 'sys_role_permission', 'sys_role', ['role_id'], ['role_id'])
op.alter_column('sys_user', 'status',
existing_type=mysql.TINYINT(display_width=1),
type_=sa.Integer(),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index('idx_user_role_role', table_name='sys_user_role')
op.drop_index('idx_user_role_user', table_name='sys_user_role')
op.create_index(op.f('ix_sys_user_role_role_id'), 'sys_user_role', ['role_id'], unique=False)
op.create_index(op.f('ix_sys_user_role_user_id'), 'sys_user_role', ['user_id'], unique=False)
op.create_foreign_key(None, 'sys_user_role', 'sys_role', ['role_id'], ['role_id'])
op.create_foreign_key(None, 'sys_user_role', 'sys_user', ['user_id'], ['user_id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'sys_user_role', type_='foreignkey')
op.drop_constraint(None, 'sys_user_role', type_='foreignkey')
op.drop_index(op.f('ix_sys_user_role_user_id'), table_name='sys_user_role')
op.drop_index(op.f('ix_sys_user_role_role_id'), table_name='sys_user_role')
op.create_index('idx_user_role_user', 'sys_user_role', ['user_id'], unique=False)
op.create_index('idx_user_role_role', 'sys_user_role', ['role_id'], unique=False)
op.alter_column('sys_user', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_constraint(None, 'sys_role_permission', type_='foreignkey')
op.drop_constraint(None, 'sys_role_permission', type_='foreignkey')
op.drop_index(op.f('ix_sys_role_permission_role_id'), table_name='sys_role_permission')
op.drop_index(op.f('ix_sys_role_permission_perm_id'), table_name='sys_role_permission')
op.create_index('idx_role_perm_role', 'sys_role_permission', ['role_id'], unique=False)
op.create_index('idx_role_perm_perm', 'sys_role_permission', ['perm_id'], unique=False)
op.alter_column('sys_role', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.add_column('sys_permission', sa.Column('component', mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=255), nullable=True))
op.drop_index(op.f('ix_sys_permission_parent_id'), table_name='sys_permission')
op.create_index('idx_perm_parent', 'sys_permission', ['parent_id'], unique=False)
op.alter_column('sys_permission', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('sys_permission', 'perm_type',
existing_type=sa.Enum('menu', 'button', name='permissiontypeenum', native_enum=False),
type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20),
existing_nullable=False)
op.drop_constraint('uk_param_key', 'sys_param', type_='unique')
op.create_index('param_key', 'sys_param', ['param_key'], unique=True)
op.alter_column('sys_param', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('sys_param', 'param_type',
existing_type=sa.Enum('string', 'int', 'bool', 'json', name='paramtypeenum', native_enum=False),
type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20),
existing_nullable=False,
existing_server_default=sa.text("'string'"))
op.create_table_comment(
'sys_log',
'操作日志表',
existing_comment=None,
schema=None
)
op.drop_constraint(None, 'sys_log', type_='foreignkey')
op.create_foreign_key('operation_logs_ibfk_1', 'sys_log', 'users', ['user_id'], ['id'], referent_schema='nex_docus', onupdate='RESTRICT', ondelete='SET NULL')
op.drop_index(op.f('ix_sys_log_user_id'), table_name='sys_log')
op.drop_index(op.f('ix_sys_log_resource_id'), table_name='sys_log')
op.drop_index(op.f('ix_sys_log_created_at'), table_name='sys_log')
op.create_index('idx_user_id', 'sys_log', ['user_id'], unique=False)
op.create_index('idx_resource', 'sys_log', ['resource_type', 'resource_id'], unique=False)
op.create_index('idx_created_at', 'sys_log', ['created_at'], unique=False)
op.alter_column('sys_log', 'created_at',
existing_type=mysql.DATETIME(),
nullable=True,
comment='操作时间',
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('sys_log', 'error_message',
existing_type=mysql.TEXT(charset='utf8mb4', collation='utf8mb4_0900_ai_ci'),
comment='错误信息',
existing_nullable=True)
op.alter_column('sys_log', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(),
nullable=True,
comment='状态0-失败 1-成功',
existing_server_default=sa.text("'1'"))
op.alter_column('sys_log', 'user_agent',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=500),
comment='用户代理',
existing_nullable=True)
op.alter_column('sys_log', 'ip_address',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment='IP地址',
existing_nullable=True)
op.alter_column('sys_log', 'detail',
existing_type=mysql.TEXT(charset='utf8mb4', collation='utf8mb4_0900_ai_ci'),
comment='操作详情JSON',
existing_nullable=True)
op.alter_column('sys_log', 'resource_id',
existing_type=mysql.BIGINT(),
comment='资源ID',
existing_nullable=True)
op.alter_column('sys_log', 'resource_type',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment='资源类型',
existing_nullable=False)
op.alter_column('sys_log', 'operation_type',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment='操作类型',
existing_nullable=False)
op.alter_column('sys_log', 'username',
existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_0900_ai_ci', length=50),
comment='用户名',
existing_nullable=True)
op.alter_column('sys_log', 'user_id',
existing_type=sa.Integer(),
type_=mysql.BIGINT(),
comment='操作用户ID',
existing_nullable=True)
op.alter_column('sys_log', 'id',
existing_type=mysql.BIGINT(),
comment='日志ID',
existing_nullable=False,
autoincrement=True)
op.drop_constraint('uk_dict_type_code', 'sys_dict_type', type_='unique')
op.create_index('type_code', 'sys_dict_type', ['type_code'], unique=True)
op.alter_column('sys_dict_type', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.drop_index(op.f('ix_sys_dict_item_type_code'), table_name='sys_dict_item')
op.create_index('idx_dict_item_type', 'sys_dict_item', ['type_code'], unique=False)
op.alter_column('sys_dict_item', 'status',
existing_type=sa.Integer(),
type_=mysql.TINYINT(display_width=1),
existing_nullable=False,
existing_server_default=sa.text("'1'"))
# ### end Alembic commands ###

View File

View File

View File

View File

@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.models import AIModel
from app.schemas.ai_model import AIModelOut, AIModelCreate, AIModelUpdate
from typing import List, Optional
router = APIRouter(prefix="/ai-models", tags=["ai-models"])
@router.get("", response_model=List[AIModelOut])
def list_ai_models(
model_type: Optional[str] = Query(None, pattern="^(asr|llm)$"),
db: Session = Depends(get_db)
):
query = db.query(AIModel)
if model_type:
query = query.filter(AIModel.model_type == model_type)
return query.order_by(AIModel.created_at.desc()).all()
@router.post("", response_model=AIModelOut)
def create_ai_model(payload: AIModelCreate, db: Session = Depends(get_db)):
# Logic: If set as default, unset others of same type
if payload.is_default:
db.query(AIModel).filter(AIModel.model_type == payload.model_type).update({"is_default": False})
item = AIModel(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/{model_id}", response_model=AIModelOut)
def update_ai_model(model_id: int, payload: AIModelUpdate, db: Session = Depends(get_db)):
item = db.query(AIModel).filter(AIModel.model_id == model_id).first()
if not item:
raise HTTPException(status_code=404, detail="Model not found")
update_data = payload.model_dump(exclude_unset=True)
if update_data.get("is_default"):
# Unset others
target_type = update_data.get("model_type", item.model_type)
db.query(AIModel).filter(AIModel.model_type == target_type).update({"is_default": False})
for k, v in update_data.items():
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/{model_id}")
def delete_ai_model(model_id: int, db: Session = Depends(get_db)):
item = db.query(AIModel).filter(AIModel.model_id == model_id).first()
if not item:
raise HTTPException(status_code=404, detail="Model not found")
if item.is_default:
raise HTTPException(status_code=400, detail="Cannot delete default model")
db.delete(item)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from redis import Redis
from app.core.db import get_db
from app.core.redis import get_redis
from app.schemas.auth import LoginRequest, TokenPair, RefreshRequest, LogoutRequest
from app.services.auth_service import (
authenticate_user,
create_token_pair,
refresh_access_token,
logout_refresh_token,
)
from app.services.log_service import create_log
from app.core.security import decode_token
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenPair)
def login(
payload: LoginRequest,
request: Request,
db: Session = Depends(get_db),
redis: Redis = Depends(get_redis)
):
user = authenticate_user(db, payload.username, payload.password)
if not user:
# Optionally log failed login attempt here
create_log(
db=db,
user_id=None,
username=payload.username,
operation_type="LOGIN",
resource_type="auth",
detail=f"Failed login attempt for user: {payload.username}",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
status=0,
error_message="Invalid credentials"
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
access_token, refresh_token = create_token_pair(db, redis, user)
create_log(
db=db,
user_id=user.user_id,
username=user.username,
operation_type="LOGIN",
resource_type="auth",
detail="User logged in successfully",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
status=1
)
return TokenPair(access_token=access_token, refresh_token=refresh_token)
@router.post("/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
try:
access_token, refresh_token = refresh_access_token(db, redis, payload.refresh_token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
return TokenPair(access_token=access_token, refresh_token=refresh_token)
@router.post("/logout")
def logout(
payload: LogoutRequest,
request: Request,
redis: Redis = Depends(get_redis),
db: Session = Depends(get_db)
):
user_id = None
username = None
# Try to extract user info for logging before invalidating token
try:
token_payload = decode_token(payload.refresh_token)
if token_payload and "sub" in token_payload:
user_id = int(token_payload["sub"])
user = db.query(User).filter(User.user_id == user_id).first()
if user:
username = user.username
except:
pass
try:
logout_refresh_token(redis, payload.refresh_token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
if user_id:
create_log(
db=db,
user_id=user_id,
username=username,
operation_type="LOGOUT",
resource_type="auth",
detail="User logged out",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
status=1
)
return {"status": "ok"}

View File

@ -0,0 +1,93 @@
from datetime import datetime, timedelta
from pathlib import Path
import os
import shutil
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from redis import Redis
from app.core.db import get_db
from app.core.redis import get_redis
from app.schemas.dashboard import DashboardSummary, UserStats, StorageStats, ServerStats
from app.models import User
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg"}
def _get_base_dir() -> Path:
return Path(__file__).resolve().parents[5]
def _scan_storage(storage_dir: Path) -> tuple[int, int]:
total_bytes = 0
file_count = 0
if not storage_dir.exists():
return 0, 0
for root, _dirs, files in os.walk(storage_dir):
for name in files:
file_path = Path(root) / name
try:
total_bytes += file_path.stat().st_size
except OSError:
continue
file_count += 1
return total_bytes, file_count
def _get_cpu_memory() -> tuple[float, float]:
try:
import psutil # type: ignore
cpu = psutil.cpu_percent(interval=0.2)
mem = psutil.virtual_memory().percent
return float(cpu), float(mem)
except Exception:
return 0.0, 0.0
def _get_disk_usage(path: Path) -> float:
try:
total, used, _free = shutil.disk_usage(path)
if total == 0:
return 0.0
return round(used / total * 100, 2)
except Exception:
return 0.0
@router.get("/summary", response_model=DashboardSummary)
def get_dashboard_summary(db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
total_users = db.query(func.count(User.user_id)).filter(User.is_deleted.is_(False)).scalar() or 0
now = datetime.now()
start = datetime(now.year, now.month, now.day)
end = start + timedelta(days=1)
new_today = (
db.query(func.count(User.user_id))
.filter(User.is_deleted.is_(False), User.created_at >= start, User.created_at < end)
.scalar()
or 0
)
try:
online = len(redis.keys("auth:online:*"))
except Exception:
online = 0
base_dir = _get_base_dir()
storage_dir = base_dir / "storage"
total_bytes, audio_files = _scan_storage(storage_dir)
total_gb = round(total_bytes / (1024 ** 3), 2)
cpu, mem = _get_cpu_memory()
disk = _get_disk_usage(storage_dir if storage_dir.exists() else base_dir)
return DashboardSummary(
users=UserStats(total=total_users, new_today=new_today, online=online),
storage=StorageStats(total_gb=total_gb, audio_files=audio_files),
server=ServerStats(cpu=cpu, memory=mem, disk=disk),
)

View File

@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.dict import (
DictTypeOut,
DictTypeCreate,
DictTypeUpdate,
DictItemOut,
DictItemCreate,
DictItemUpdate,
)
from app.models import DictType, DictItem
router = APIRouter(prefix="/dicts", tags=["dicts"])
@router.get("/types", response_model=list[DictTypeOut])
def list_types(db: Session = Depends(get_db)):
return db.query(DictType).all()
@router.post("/types", response_model=DictTypeOut)
def create_type(payload: DictTypeCreate, db: Session = Depends(get_db)):
exists = db.query(DictType).filter(DictType.type_code == payload.type_code).first()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Type code exists")
item = DictType(
type_code=payload.type_code,
type_name=payload.type_name,
status=payload.status,
remark=payload.remark,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/types/{type_id}", response_model=DictTypeOut)
def update_type(type_id: int, payload: DictTypeUpdate, db: Session = Depends(get_db)):
item = db.query(DictType).filter(DictType.dict_type_id == type_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Type not found")
if payload.type_name is not None:
item.type_name = payload.type_name
if payload.status is not None:
item.status = payload.status
if payload.remark is not None:
item.remark = payload.remark
db.commit()
db.refresh(item)
return item
@router.delete("/types/{type_id}")
def delete_type(type_id: int, db: Session = Depends(get_db)):
item = db.query(DictType).filter(DictType.dict_type_id == type_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Type not found")
db.delete(item)
db.commit()
return {"status": "ok"}
@router.get("/items", response_model=list[DictItemOut])
def list_items(type_code: str, db: Session = Depends(get_db)):
return db.query(DictItem).filter(DictItem.type_code == type_code).order_by(DictItem.sort_order).all()
@router.post("/items", response_model=DictItemOut)
def create_item(payload: DictItemCreate, db: Session = Depends(get_db)):
item = DictItem(
type_code=payload.type_code,
item_label=payload.item_label,
item_value=payload.item_value,
sort_order=payload.sort_order,
status=payload.status,
remark=payload.remark,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/items/{item_id}", response_model=DictItemOut)
def update_item(item_id: int, payload: DictItemUpdate, db: Session = Depends(get_db)):
item = db.query(DictItem).filter(DictItem.dict_item_id == item_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
if payload.item_label is not None:
item.item_label = payload.item_label
if payload.item_value is not None:
item.item_value = payload.item_value
if payload.sort_order is not None:
item.sort_order = payload.sort_order
if payload.status is not None:
item.status = payload.status
if payload.remark is not None:
item.remark = payload.remark
db.commit()
db.refresh(item)
return item
@router.delete("/items/{item_id}")
def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.query(DictItem).filter(DictItem.dict_item_id == item_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found")
db.delete(item)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.core.db import get_db
from app.models.sys_log import SysLog
from app.schemas.sys_log import SysLogPage
router = APIRouter(prefix="/logs", tags=["logs"])
@router.get("", response_model=SysLogPage)
def list_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[int] = None,
username: Optional[str] = None,
operation_type: Optional[str] = None,
status: Optional[int] = None,
db: Session = Depends(get_db)
):
query = db.query(SysLog)
if username:
query = query.filter(SysLog.username.ilike(f"%{username}%"))
elif user_id:
query = query.filter(SysLog.user_id == user_id)
if operation_type:
query = query.filter(SysLog.operation_type == operation_type)
if status is not None:
query = query.filter(SysLog.status == status)
total = query.count()
items = query.order_by(SysLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {"items": items, "total": total}

View File

@ -0,0 +1,192 @@
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_, func, desc
from app.core.db import get_db, SessionLocal
from app.core.deps import get_current_user
from app.models import Meeting, MeetingAttendee, User, TranscriptSegment, SummarizeTask, TranscriptTask
from app.schemas.meeting import MeetingOut, MeetingDetailOut, MeetingUpdate, MeetingListOut
from app.services.meeting_service import MeetingService
from typing import List, Optional
router = APIRouter(prefix="/meetings", tags=["meetings"])
async def run_summarize_worker(task_id: str):
db = SessionLocal()
try:
await MeetingService.process_summarize_task(db, task_id)
finally:
db.close()
@router.post("/{meeting_id}/summarize")
async def start_summarize(
meeting_id: int,
payload: dict,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
try:
task = await MeetingService.create_summarize_task(
db,
meeting_id,
payload.get("prompt_id"),
payload.get("model_id"),
payload.get("extra_prompt", "")
)
background_tasks.add_task(run_summarize_worker, task.task_id)
return {"task_id": task.task_id, "status": task.status}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/tasks", response_model=dict)
def list_all_tasks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
task_type: Optional[str] = Query(None), # summarize, transcript
status: Optional[str] = Query(None),
):
"""
统一任务监控接口合并转译和总结任务
"""
tasks = []
# 1. 获取总结任务
sum_query = db.query(SummarizeTask, Meeting.title, User.username).join(
Meeting, SummarizeTask.meeting_id == Meeting.meeting_id
).join(
User, Meeting.user_id == User.user_id
)
if status:
sum_query = sum_query.filter(SummarizeTask.status == status)
for t, m_title, username in sum_query.order_by(desc(SummarizeTask.created_at)).limit(50).all():
tasks.append({
"task_id": t.task_id,
"type": "总结",
"meeting_title": m_title,
"creator": username,
"status": t.status,
"progress": t.progress,
"created_at": t.created_at,
"meeting_id": t.meeting_id
})
# 2. 获取转译任务
trans_query = db.query(TranscriptTask, Meeting.title, User.username).join(
Meeting, TranscriptTask.meeting_id == Meeting.meeting_id
).join(
User, Meeting.user_id == User.user_id
)
if status:
trans_query = trans_query.filter(TranscriptTask.status == status)
for t, m_title, username in trans_query.order_by(desc(TranscriptTask.created_at)).limit(50).all():
tasks.append({
"task_id": t.task_id,
"type": "转译",
"meeting_title": m_title,
"creator": username,
"status": t.status,
"progress": t.progress,
"created_at": t.created_at,
"meeting_id": t.meeting_id
})
# 按时间全局排序
tasks.sort(key=lambda x: x["created_at"], reverse=True)
return {"items": tasks}
@router.get("/tasks/{task_id}")
def get_task_progress(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = MeetingService.get_task_status(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"task_id": task.task_id,
"status": task.status,
"progress": task.progress,
"result": task.result,
"error": task.error_message
}
# ... 其余接口保持不变
@router.get("", response_model=MeetingListOut)
def list_meetings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
scope: str = Query("all", description="all, created, joined"),
keyword: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
):
query = db.query(Meeting)
if scope == "created":
query = query.filter(Meeting.user_id == current_user.user_id)
elif scope == "joined":
query = query.join(MeetingAttendee).filter(MeetingAttendee.user_id == current_user.user_id)
else:
query = query.outerjoin(MeetingAttendee).filter(
or_(Meeting.user_id == current_user.user_id, MeetingAttendee.user_id == current_user.user_id)
)
if keyword:
query = query.filter(Meeting.title.contains(keyword))
total = query.distinct().count()
results = query.distinct().order_by(Meeting.meeting_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
items = []
for m in results:
item = MeetingOut.model_validate(m)
item.creator_name = m.creator.display_name if m.creator else "Unknown"
item.creator_avatar = m.creator.avatar if m.creator else None
attendees = []
for att in m.attendees:
if att.user:
attendees.append({"attendee_id": att.attendee_id,"user_id": att.user_id,"display_name": att.user.display_name,"avatar": att.user.avatar})
item.attendees = attendees
items.append(item)
return {"items": items, "total": total}
@router.get("/{meeting_id}", response_model=MeetingDetailOut)
def get_meeting_detail(
meeting_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
is_attendee = any(a.user_id == current_user.user_id for a in meeting.attendees)
if meeting.user_id != current_user.user_id and not is_attendee:
raise HTTPException(status_code=403, detail="Not authorized")
item = MeetingDetailOut.model_validate(meeting)
item.creator_name = meeting.creator.display_name if meeting.creator else "Unknown"
item.creator_avatar = meeting.creator.avatar if meeting.creator else None
item.attendees = [
{"attendee_id": a.attendee_id, "user_id": a.user_id, "display_name": a.user.display_name if a.user else "Unknown", "avatar": a.user.avatar if a.user else None}
for a in meeting.attendees
]
item.segments = sorted([s for s in meeting.segments], key=lambda x: x.start_time_ms)
return item
@router.delete("/{meeting_id}")
def delete_meeting(
meeting_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if meeting.user_id != current_user.user_id:
role_codes = [ur.role.role_code for ur in current_user.roles]
if "admin" not in role_codes and "superuser" not in role_codes:
raise HTTPException(status_code=403, detail="Forbidden")
db.delete(meeting)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models import Permission, RolePermission, UserRole, User
from app.models.enums import StatusEnum, PermissionTypeEnum
router = APIRouter(prefix="/menus", tags=["menus"])
def build_tree(items):
by_parent = {}
for item in items:
by_parent.setdefault(item.parent_id, []).append(item)
def make_node(item):
return {
"id": item.perm_id,
"name": item.name,
"code": item.code,
"type": item.perm_type,
"level": item.level,
"path": item.path,
"icon": item.icon,
"children": [make_node(child) for child in by_parent.get(item.perm_id, [])],
}
roots = by_parent.get(None, []) + by_parent.get(0, [])
return [make_node(node) for node in roots]
@router.get("/tree")
def menu_tree(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
items = (
db.query(Permission)
.join(RolePermission, RolePermission.perm_id == Permission.perm_id)
.join(UserRole, UserRole.role_id == RolePermission.role_id)
.filter(UserRole.user_id == current_user.user_id)
.filter(Permission.status == int(StatusEnum.ENABLED))
.filter(Permission.perm_type.in_([PermissionTypeEnum.MENU, PermissionTypeEnum.BUTTON]))
.order_by(Permission.level, Permission.sort_order)
.all()
)
return build_tree(items)

View File

@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.param import ParamOut, ParamCreate, ParamUpdate
from app.models import SystemParam
router = APIRouter(prefix="/params", tags=["params"])
@router.get("", response_model=list[ParamOut])
def list_params(db: Session = Depends(get_db)):
return db.query(SystemParam).all()
@router.post("", response_model=ParamOut)
def create_param(payload: ParamCreate, db: Session = Depends(get_db)):
exists = db.query(SystemParam).filter(SystemParam.param_key == payload.param_key).first()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Param key exists")
item = SystemParam(
param_key=payload.param_key,
param_value=payload.param_value,
param_type=payload.param_type,
status=payload.status,
is_system=payload.is_system,
description=payload.description,
)
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/{param_id}", response_model=ParamOut)
def update_param(param_id: int, payload: ParamUpdate, db: Session = Depends(get_db)):
item = db.query(SystemParam).filter(SystemParam.param_id == param_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Param not found")
if payload.param_value is not None:
item.param_value = payload.param_value
if payload.param_type is not None:
item.param_type = payload.param_type
if payload.status is not None:
item.status = payload.status
if payload.is_system is not None:
item.is_system = payload.is_system
if payload.description is not None:
item.description = payload.description
db.commit()
db.refresh(item)
return item
@router.delete("/{param_id}")
def delete_param(param_id: int, db: Session = Depends(get_db)):
item = db.query(SystemParam).filter(SystemParam.param_id == param_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Param not found")
db.delete(item)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.deps import get_current_user
from app.schemas.permission import PermissionOut, PermissionCreate, PermissionUpdate
from app.models import Permission, RolePermission, UserRole, User
from app.models.enums import StatusEnum
router = APIRouter(prefix="/permissions", tags=["permissions"])
@router.get("", response_model=list[PermissionOut])
def list_permissions(db: Session = Depends(get_db)):
return db.query(Permission).order_by(Permission.level, Permission.sort_order).all()
@router.post("", response_model=PermissionOut)
def create_permission(payload: PermissionCreate, db: Session = Depends(get_db)):
exists = db.query(Permission).filter(Permission.code == payload.code).first()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Permission code exists")
item = Permission(**payload.model_dump())
db.add(item)
db.commit()
db.refresh(item)
return item
@router.put("/{perm_id}", response_model=PermissionOut)
def update_permission(perm_id: int, payload: PermissionUpdate, db: Session = Depends(get_db)):
item = db.query(Permission).filter(Permission.perm_id == perm_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(item, k, v)
db.commit()
db.refresh(item)
return item
@router.delete("/{perm_id}")
def delete_permission(perm_id: int, db: Session = Depends(get_db)):
item = db.query(Permission).filter(Permission.perm_id == perm_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found")
db.delete(item)
db.commit()
return {"status": "ok"}
@router.get("/me")
def my_permissions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = (
db.query(Permission.code)
.join(RolePermission, RolePermission.perm_id == Permission.perm_id)
.join(UserRole, UserRole.role_id == RolePermission.role_id)
.filter(UserRole.user_id == current_user.user_id)
.filter(Permission.status == int(StatusEnum.ENABLED))
.all()
)
return [r[0] for r in rows]

View File

@ -0,0 +1,192 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_, and_
from app.core.db import get_db
from app.core.deps import get_current_user
from app.models import User, PromptTemplate, UserPromptConfig
from app.schemas.prompt import (
PromptTemplateOut,
PromptTemplateCreate,
PromptTemplateUpdate,
UserPromptConfigUpdate
)
from typing import List, Optional
router = APIRouter(prefix="/prompts", tags=["prompts"])
def is_admin(user: User):
role_codes = [ur.role.role_code for ur in user.roles] if hasattr(user, 'roles') else []
return "admin" in role_codes or "superuser" in role_codes
@router.get("", response_model=List[PromptTemplateOut])
def list_prompts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
keyword: Optional[str] = Query(None),
category: Optional[str] = Query(None),
scope: str = Query("personal", description="system or personal")
):
"""
根据 scope 返回不同范围的模板
"""
filters = []
if scope == "system":
# 系统管理入口:仅展示系统级模板
if not is_admin(current_user):
raise HTTPException(status_code=403, detail="无权访问系统模板库")
filters.append(PromptTemplate.is_system == True)
else:
# 个人管理入口:展示已发布的系统模板 + 自己的个人模板
accessibility_filter = or_(
and_(PromptTemplate.is_system == True, PromptTemplate.status == 1),
PromptTemplate.user_id == current_user.user_id
)
filters.append(accessibility_filter)
if keyword:
filters.append(or_(
PromptTemplate.name.contains(keyword),
PromptTemplate.description.contains(keyword),
PromptTemplate.content.contains(keyword)
))
if category:
filters.append(PromptTemplate.category == category)
query = db.query(
PromptTemplate,
UserPromptConfig.is_active,
UserPromptConfig.user_sort_order
).outerjoin(
UserPromptConfig,
(UserPromptConfig.template_id == PromptTemplate.id) & (UserPromptConfig.user_id == current_user.user_id)
).filter(and_(*filters))
results = query.all()
out = []
for template, is_active, user_sort_order in results:
item = PromptTemplateOut.model_validate(template)
item.is_active = is_active if is_active is not None else True
item.user_sort_order = user_sort_order if user_sort_order is not None else template.sort_order
out.append(item)
# 排序:系统管理入口按全局排序,个人入口按个人排序
if scope == "system":
out.sort(key=lambda x: x.sort_order)
else:
out.sort(key=lambda x: (x.user_sort_order, x.sort_order))
return out
@router.post("", response_model=PromptTemplateOut)
def create_prompt(
payload: PromptTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_is_admin = is_admin(current_user)
item_data = payload.model_dump()
# 如果显式指定为系统模板,必须是管理员
if item_data.get("is_system"):
if not user_is_admin:
raise HTTPException(status_code=403, detail="仅管理员可创建系统模板")
item_data["user_id"] = None
else:
item_data["user_id"] = current_user.user_id
item_data["is_system"] = False
item = PromptTemplate(**item_data)
db.add(item)
db.commit()
db.refresh(item)
res = PromptTemplateOut.model_validate(item)
res.is_active = True
res.user_sort_order = item.sort_order
return res
@router.put("/{prompt_id}", response_model=PromptTemplateOut)
def update_prompt(
prompt_id: int,
payload: PromptTemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first()
if not item:
raise HTTPException(status_code=404, detail="模板不存在")
user_is_admin = is_admin(current_user)
# 系统模板仅管理员可改
if item.is_system and not user_is_admin:
raise HTTPException(status_code=403, detail="无权修改系统模板")
# 个人模板仅主人可改
if not item.is_system and item.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="无权修改他人模板")
update_data = payload.model_dump(exclude_unset=True)
if "is_system" in update_data:
if user_is_admin:
new_val = update_data.pop("is_system")
item.is_system = new_val
item.user_id = None if new_val else current_user.user_id
else:
update_data.pop("is_system")
for k, v in update_data.items():
setattr(item, k, v)
db.commit()
db.refresh(item)
return PromptTemplateOut.model_validate(item)
@router.delete("/{prompt_id}")
def delete_prompt(
prompt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first()
if not item:
raise HTTPException(status_code=404, detail="模板不存在")
user_is_admin = is_admin(current_user)
if item.is_system and not user_is_admin:
raise HTTPException(status_code=403, detail="无权删除系统模板")
if not item.is_system and item.user_id != current_user.user_id:
raise HTTPException(status_code=403, detail="无权删除他人模板")
db.delete(item)
db.commit()
return {"status": "ok"}
@router.patch("/{prompt_id}/config")
def update_user_config(
prompt_id: int,
payload: UserPromptConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
config = db.query(UserPromptConfig).filter(
UserPromptConfig.template_id == prompt_id,
UserPromptConfig.user_id == current_user.user_id
).first()
if not config:
template = db.query(PromptTemplate).filter(PromptTemplate.id == prompt_id).first()
config = UserPromptConfig(
user_id=current_user.user_id,
template_id=prompt_id,
is_active=True,
user_sort_order=template.sort_order if template else 0
)
db.add(config)
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(config, k, v)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.role import RoleOut, RoleCreate, RoleUpdate, RolePermissionUpdate
from app.models import Role, RolePermission, Permission, UserRole
from app.models.enums import StatusEnum
router = APIRouter(prefix="/roles", tags=["roles"])
@router.get("", response_model=list[RoleOut])
def list_roles(db: Session = Depends(get_db)):
return db.query(Role).all()
@router.post("", response_model=RoleOut)
def create_role(payload: RoleCreate, db: Session = Depends(get_db)):
exists = db.query(Role).filter(Role.role_code == payload.role_code).first()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Role code exists")
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
status=payload.status,
remark=payload.remark,
)
db.add(role)
db.commit()
db.refresh(role)
return role
@router.put("/{role_id}", response_model=RoleOut)
def update_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)):
role = db.query(Role).filter(Role.role_id == role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
if payload.role_name is not None:
role.role_name = payload.role_name
if payload.status is not None:
role.status = payload.status
if payload.remark is not None:
role.remark = payload.remark
db.commit()
db.refresh(role)
return role
@router.delete("/{role_id}")
def delete_role(role_id: int, db: Session = Depends(get_db)):
role = db.query(Role).filter(Role.role_id == role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
# Check if users are assigned
user_count = db.query(UserRole).filter(UserRole.role_id == role_id).count()
if user_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete role with assigned users")
# Delete permissions
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
db.delete(role)
db.commit()
return {"status": "ok"}
@router.get("/{role_id}/permissions")
def get_role_permissions(role_id: int, db: Session = Depends(get_db)):
rows = db.query(RolePermission.perm_id).filter(RolePermission.role_id == role_id).all()
return [r[0] for r in rows]
@router.put("/{role_id}/permissions")
def update_role_permissions(role_id: int, payload: RolePermissionUpdate, db: Session = Depends(get_db)):
exists = db.query(Role).filter(Role.role_id == role_id).first()
if not exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
for perm_id in payload.perm_ids:
db.add(RolePermission(role_id=role_id, perm_id=perm_id))
db.commit()
return {"status": "ok"}
@router.get("/{role_id}/users")
def get_role_users(role_id: int, db: Session = Depends(get_db)):
from app.models import User
users = (
db.query(User)
.join(UserRole, UserRole.user_id == User.user_id)
.filter(UserRole.role_id == role_id, User.is_deleted.is_(False))
.all()
)
return users

View File

@ -0,0 +1,175 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from pathlib import Path
import shutil
from app.core.db import get_db
from app.core.deps import get_current_user
from app.schemas.user import UserOut, UserMe, UserCreate, UserUpdate, PasswordChange
from app.models import User, UserRole, Role
from app.core.security import hash_password, verify_password
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserMe)
def get_me(current_user: User = Depends(get_current_user)):
# 优先返回 role_code 用于前端权限判断
roles = [ur.role.role_code for ur in current_user.roles]
user_dict = UserOut.model_validate(current_user).model_dump()
user_dict["roles"] = roles
return user_dict
@router.put("/me/password")
def change_password(
payload: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not verify_password(payload.old_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password incorrect"
)
current_user.password_hash = hash_password(payload.new_password)
db.commit()
return {"status": "ok"}
@router.post("/me/avatar")
def upload_avatar(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Invalid file type")
# Path resolution: backend/app/api/v1/endpoints/users.py -> root
BASE_DIR = Path(__file__).resolve().parents[5]
# parents[0] is endpoints, [1] v1, [2] api, [3] app, [4] backend, [5] root?
# Let's verify:
# file: .../backend/app/api/v1/endpoints/users.py
# parent: .../backend/app/api/v1/endpoints
# parents[0]: endpoints
# parents[1]: v1
# parents[2]: api
# parents[3]: app
# parents[4]: backend
# parents[5]: root (where storage is)
STORAGE_DIR = BASE_DIR / "storage"
user_dir = STORAGE_DIR / "users" / str(current_user.user_id) / "avatar"
user_dir.mkdir(parents=True, exist_ok=True)
file_path = user_dir / file.filename
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Assuming backend serves /storage
avatar_url = f"/storage/users/{current_user.user_id}/avatar/{file.filename}"
current_user.avatar = avatar_url
db.commit()
db.refresh(current_user)
return {"avatar": avatar_url}
@router.get("", response_model=list[UserOut])
def list_users(db: Session = Depends(get_db)):
return db.query(User).filter(User.is_deleted.is_(False)).all()
@router.post("", response_model=UserOut)
def create_user(payload: UserCreate, db: Session = Depends(get_db)):
exists = db.query(User).filter(User.username == payload.username).first()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username exists")
password = payload.password or "123456"
user = User(
username=payload.username,
display_name=payload.display_name,
email=payload.email,
phone=payload.phone,
password_hash=hash_password(password),
status=payload.status,
is_deleted=False,
)
db.add(user)
db.flush()
for role_id in payload.role_ids:
db.add(UserRole(user_id=user.user_id, role_id=role_id))
db.commit()
db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserOut)
def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)):
user = db.query(User).filter(User.user_id == user_id, User.is_deleted.is_(False)).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if payload.display_name is not None:
user.display_name = payload.display_name
if payload.email is not None:
user.email = payload.email
if payload.phone is not None:
user.phone = payload.phone
if payload.status is not None:
user.status = payload.status
if payload.role_ids is not None:
db.query(UserRole).filter(UserRole.user_id == user.user_id).delete()
for role_id in payload.role_ids:
db.add(UserRole(user_id=user.user_id, role_id=role_id))
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.user_id == user_id, User.is_deleted.is_(False)).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.is_deleted = True
db.commit()
return {"status": "ok"}
@router.get("/{user_id}/roles")
def get_user_roles(user_id: int, db: Session = Depends(get_db)):
roles = (
db.query(Role.role_id)
.join(UserRole, UserRole.role_id == Role.role_id)
.filter(UserRole.user_id == user_id)
.all()
)
return [r[0] for r in roles]
@router.put("/{user_id}/roles")
def update_user_roles(user_id: int, role_ids: list[int], db: Session = Depends(get_db)):
db.query(UserRole).filter(UserRole.user_id == user_id).delete()
for role_id in role_ids:
db.add(UserRole(user_id=user_id, role_id=role_id))
db.commit()
return {"status": "ok"}
@router.post("/{user_id}/reset-password")
def reset_password(user_id: int, password: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.user_id == user_id, User.is_deleted.is_(False)).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(password)
db.commit()
return {"status": "ok"}

View File

@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, permissions, roles, menus, dicts, params, logs, dashboard, prompts, ai_models, meetings
router = APIRouter()
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(roles.router)
router.include_router(permissions.router)
router.include_router(menus.router)
router.include_router(dicts.router)
router.include_router(params.router)
router.include_router(logs.router)
router.include_router(dashboard.router)
router.include_router(prompts.router)
router.include_router(ai_models.router)
router.include_router(meetings.router)

View File

View File

@ -0,0 +1,38 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
ENV_PATH = Path(__file__).resolve().parents[2] / ".env"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=str(ENV_PATH), env_file_encoding="utf-8", extra="ignore")
app_name: str = "nex_basse"
env: str = "dev"
api_v1_prefix: str = "/api/v1"
# Database
db_host: str = "127.0.0.1"
db_port: int = 3306
db_user: str = "root"
db_password: str = ""
db_name: str = "nex_basse"
db_echo: bool = False
# Redis
redis_host: str = "127.0.0.1"
redis_port: int = 6379
redis_db: int = 0
redis_password: str | None = None
# JWT
jwt_secret_key: str = "change_me"
jwt_algorithm: str = "HS256"
jwt_access_token_minutes: int = 60
jwt_refresh_token_minutes: int = 60 * 24 * 7
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@ -0,0 +1,12 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi import FastAPI
def setup_cors(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

View File

@ -0,0 +1,31 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .config import get_settings
settings = get_settings()
def build_db_url() -> str:
return (
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
"?charset=utf8mb4"
)
engine = create_engine(
build_db_url(),
echo=settings.db_echo,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,58 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from redis import Redis
from app.core.db import get_db
from app.core.security import decode_token
from app.core.redis import get_redis
from app.models import User
from app.models.enums import StatusEnum
bearer_scheme = HTTPBearer()
def get_current_user(
creds: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
redis: Redis = Depends(get_redis),
) -> User:
token = creds.credentials
try:
payload = decode_token(token)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user_id = payload.get("sub")
# Enforce redis-backed online session; if missing, force re-login
online_key = f"auth:online:{user_id}"
if not redis.exists(online_key):
# Fallback: if refresh token exists for this user, recreate online key
cursor = 0
found_ttl = None
while True:
cursor, keys = redis.scan(cursor=cursor, match="auth:refresh:*", count=200)
for key in keys:
try:
val = redis.get(key)
if val == str(user_id):
ttl = redis.ttl(key)
if ttl and ttl > 0:
found_ttl = ttl
break
except Exception:
continue
if found_ttl is not None or cursor == 0:
break
if found_ttl is not None:
redis.setex(online_key, found_ttl, "1")
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
user = db.query(User).filter(User.user_id == int(user_id), User.is_deleted.is_(False)).first()
if not user or user.status != int(StatusEnum.ENABLED):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User disabled")
return user

View File

@ -0,0 +1,17 @@
from redis.asyncio import Redis
from .config import get_settings
settings = get_settings()
# Async redis client for FastAPI/Async services
redis_client = Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
password=settings.redis_password,
decode_responses=True,
)
def get_redis() -> Redis:
return redis_client

View File

@ -0,0 +1,36 @@
from datetime import datetime, timedelta, timezone
from passlib.context import CryptContext
from passlib.exc import UnknownHashError
from jose import jwt, JWTError
from typing import Any
from .config import get_settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
settings = get_settings()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return pwd_context.verify(plain_password, hashed_password)
except UnknownHashError:
return False
def create_token(data: dict[str, Any], expires_delta: timedelta) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
return payload
except JWTError as exc:
raise ValueError("Invalid token") from exc

View File

@ -0,0 +1,47 @@
import sys
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
# Add the parent directory of 'app' to sys.path to support 'python app/main.py'
backend_dir = Path(__file__).resolve().parent.parent
if str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
from app.core.config import get_settings
from app.core.cors import setup_cors
from app.api.v1.router import router as api_router
settings = get_settings()
app = FastAPI(title=settings.app_name)
setup_cors(app)
app.include_router(api_router, prefix=settings.api_v1_prefix)
# Mount storage
# Assuming backend is running from backend/ directory or root.
# Relative to backend/app/main.py, storage is ../../storage if run from inside app?
# Usually we run uvicorn from backend/ dir.
# So storage is ../storage relative to backend/ (cwd).
# Or if storage is in root of workspace, and we run from backend/, it is ../storage.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STORAGE_DIR = BASE_DIR / "storage"
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
app.mount(f"{settings.api_v1_prefix}/storage", StaticFiles(directory=str(STORAGE_DIR)), name="storage")
@app.get("/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
# When running as 'python app/main.py', the app is in the current module's 'app' variable,
# but uvicorn.run("app.main:app") is better for reload support.
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -0,0 +1,33 @@
from .base import Base
from .user import User
from .role import Role
from .permission import Permission
from .mapping import UserRole, RolePermission
from .dict import DictType, DictItem
from .param import SystemParam
from .sys_log import SysLog
from .prompt import PromptTemplate, UserPromptConfig
from .ai_model import AIModel
from .meeting import Meeting, MeetingAttendee, MeetingAudio, TranscriptTask, TranscriptSegment, SummarizeTask
__all__ = [
"Base",
"User",
"Role",
"Permission",
"UserRole",
"RolePermission",
"DictType",
"DictItem",
"SystemParam",
"SysLog",
"PromptTemplate",
"UserPromptConfig",
"AIModel",
"Meeting",
"MeetingAttendee",
"MeetingAudio",
"TranscriptTask",
"TranscriptSegment",
"SummarizeTask",
]

View File

@ -0,0 +1,19 @@
from sqlalchemy import String, Integer, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
class AIModel(Base, TimestampMixin):
__tablename__ = "biz_ai_model"
__table_args__ = MYSQL_TABLE_ARGS
model_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
model_type: Mapped[str] = mapped_column(String(20), nullable=False) # asr / llm
provider: Mapped[str] = mapped_column(String(50), nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
api_key: Mapped[str | None] = mapped_column(String(255))
base_url: Mapped[str | None] = mapped_column(String(255))
api_path: Mapped[str | None] = mapped_column(String(100))
config: Mapped[dict | None] = mapped_column(JSON)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[int] = mapped_column(Integer, default=1)

View File

@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import DateTime
MYSQL_TABLE_ARGS = {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@ -0,0 +1,35 @@
from sqlalchemy import String, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Enum, UniqueConstraint
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
from .enums import StatusEnum
class DictType(Base, TimestampMixin):
__tablename__ = "sys_dict_type"
__table_args__ = (
UniqueConstraint("type_code", name="uk_dict_type_code"),
MYSQL_TABLE_ARGS,
)
dict_type_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
type_code: Mapped[str] = mapped_column(String(50), nullable=False)
type_name: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
remark: Mapped[str | None] = mapped_column(Text)
class DictItem(Base, TimestampMixin):
__tablename__ = "sys_dict_item"
__table_args__ = (
UniqueConstraint("type_code", "item_value", name="uk_dict_item"),
MYSQL_TABLE_ARGS,
)
dict_item_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
type_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
item_label: Mapped[str] = mapped_column(String(100), nullable=False)
item_value: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
remark: Mapped[str | None] = mapped_column(Text)

View File

@ -0,0 +1,18 @@
import enum
class StatusEnum(int, enum.Enum):
ENABLED = 1
DISABLED = 0
class PermissionTypeEnum(str, enum.Enum):
MENU = "menu"
BUTTON = "button"
class ParamTypeEnum(str, enum.Enum):
STRING = "string"
INT = "int"
BOOL = "bool"
JSON = "json"

View File

@ -0,0 +1,33 @@
from sqlalchemy import Integer, UniqueConstraint, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
class UserRole(Base, TimestampMixin):
__tablename__ = "sys_user_role"
__table_args__ = (
UniqueConstraint("user_id", "role_id", name="uk_user_role"),
MYSQL_TABLE_ARGS,
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_user.user_id"), nullable=False, index=True)
role_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_role.role_id"), nullable=False, index=True)
user = relationship("User", back_populates="roles")
role = relationship("Role", back_populates="users")
class RolePermission(Base, TimestampMixin):
__tablename__ = "sys_role_permission"
__table_args__ = (
UniqueConstraint("role_id", "perm_id", name="uk_role_perm"),
MYSQL_TABLE_ARGS,
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
role_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_role.role_id"), nullable=False, index=True)
perm_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_permission.perm_id", ondelete="CASCADE"), nullable=False, index=True)
role = relationship("Role", back_populates="permissions")
permission = relationship("Permission", back_populates="roles")

View File

@ -0,0 +1,100 @@
from sqlalchemy import String, Integer, Text, ForeignKey, DateTime, DECIMAL, Enum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
class Meeting(Base, TimestampMixin):
__tablename__ = "biz_meeting"
__table_args__ = MYSQL_TABLE_ARGS
meeting_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.user_id"), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
tags: Mapped[str | None] = mapped_column(String(255))
meeting_time: Mapped[datetime | None] = mapped_column(DateTime)
access_password: Mapped[str | None] = mapped_column(String(10))
status: Mapped[str] = mapped_column(String(20), default="draft") # draft, transcribing, summarizing, completed
summary: Mapped[str | None] = mapped_column(Text)
creator = relationship("User", backref="created_meetings")
attendees = relationship("MeetingAttendee", back_populates="meeting", cascade="all, delete-orphan")
audios = relationship("MeetingAudio", back_populates="meeting", cascade="all, delete-orphan")
segments = relationship("TranscriptSegment", back_populates="meeting", cascade="all, delete-orphan")
class MeetingAttendee(Base):
__tablename__ = "biz_meeting_attendees"
__table_args__ = MYSQL_TABLE_ARGS
attendee_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
meeting_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_meeting.meeting_id", ondelete="CASCADE"), index=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.user_id"))
meeting = relationship("Meeting", back_populates="attendees")
user = relationship("User")
class MeetingAudio(Base):
__tablename__ = "biz_meeting_audio"
__table_args__ = MYSQL_TABLE_ARGS
audio_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
meeting_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_meeting.meeting_id", ondelete="CASCADE"))
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
file_name: Mapped[str | None] = mapped_column(String(200))
file_size: Mapped[float | None] = mapped_column(DECIMAL(10, 0))
duration: Mapped[int] = mapped_column(Integer, default=0)
processing_status: Mapped[str] = mapped_column(String(20), default="uploaded")
upload_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
error_message: Mapped[str | None] = mapped_column(Text)
meeting = relationship("Meeting", back_populates="audios")
class TranscriptTask(Base):
__tablename__ = "biz_transcript_task"
__table_args__ = MYSQL_TABLE_ARGS
task_id: Mapped[str] = mapped_column(String(255), primary_key=True)
meeting_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_meeting.meeting_id", ondelete="CASCADE"), index=True)
model_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("biz_ai_model.model_id"))
language: Mapped[str] = mapped_column(String(10), default="auto")
status: Mapped[str] = mapped_column(String(20), default="pending") # pending, processing, completed, failed
progress: Mapped[int] = mapped_column(Integer, default=0)
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
error_message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class TranscriptSegment(Base):
__tablename__ = "biz_transcript_segment"
__table_args__ = MYSQL_TABLE_ARGS
segment_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
meeting_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_meeting.meeting_id", ondelete="CASCADE"))
audio_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("biz_meeting_audio.audio_id", ondelete="CASCADE"))
speaker_id: Mapped[int | None] = mapped_column(Integer)
speaker_tag: Mapped[str | None] = mapped_column(String(50))
start_time_ms: Mapped[int] = mapped_column(Integer, nullable=False)
end_time_ms: Mapped[int] = mapped_column(Integer, nullable=False)
text_content: Mapped[str] = mapped_column(Text, nullable=False)
meeting = relationship("Meeting", back_populates="segments")
class SummarizeTask(Base):
__tablename__ = "biz_summarize_task"
__table_args__ = MYSQL_TABLE_ARGS
task_id: Mapped[str] = mapped_column(String(100), primary_key=True)
meeting_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_meeting.meeting_id", ondelete="CASCADE"), index=True)
prompt_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("biz_prompt_template.id"))
model_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("biz_ai_model.model_id"))
user_prompt: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(50), default="pending", index=True)
progress: Mapped[int] = mapped_column(Integer, default=0)
result: Mapped[str | None] = mapped_column(Text)
error_message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime)

View File

@ -0,0 +1,25 @@
from sqlalchemy import String, Integer, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Enum, UniqueConstraint
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
from .enums import ParamTypeEnum, StatusEnum
class SystemParam(Base, TimestampMixin):
__tablename__ = "sys_param"
__table_args__ = (
UniqueConstraint("param_key", name="uk_param_key"),
MYSQL_TABLE_ARGS,
)
param_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
param_key: Mapped[str] = mapped_column(String(100), nullable=False)
param_value: Mapped[str] = mapped_column(Text, nullable=False)
param_type: Mapped[ParamTypeEnum] = mapped_column(
Enum(ParamTypeEnum, native_enum=False, values_callable=lambda x: [e.value for e in x]),
default=ParamTypeEnum.STRING,
nullable=False,
)
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
description: Mapped[str | None] = mapped_column(Text)

View File

@ -0,0 +1,29 @@
from sqlalchemy import String, Integer, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Enum, JSON
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
from .enums import PermissionTypeEnum, StatusEnum
class Permission(Base, TimestampMixin):
__tablename__ = "sys_permission"
__table_args__ = MYSQL_TABLE_ARGS
perm_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
perm_type: Mapped[PermissionTypeEnum] = mapped_column(
Enum(PermissionTypeEnum, native_enum=False, values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
level: Mapped[int] = mapped_column(Integer, nullable=False)
path: Mapped[str | None] = mapped_column(String(255))
icon: Mapped[str | None] = mapped_column(String(100))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
meta: Mapped[dict | None] = mapped_column(JSON)
roles = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")

View File

@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import String, Integer, Boolean, Text, ForeignKey, UniqueConstraint, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
class PromptTemplate(Base, TimestampMixin):
__tablename__ = "biz_prompt_template"
__table_args__ = MYSQL_TABLE_ARGS
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="summary")
content: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(String(255))
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.user_id", ondelete="SET NULL"), index=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
status: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
user_configs = relationship("UserPromptConfig", back_populates="template", cascade="all, delete-orphan")
class UserPromptConfig(Base, TimestampMixin):
__tablename__ = "biz_user_prompt_config"
__table_args__ = (
UniqueConstraint("user_id", "template_id", name="uk_user_template"),
MYSQL_TABLE_ARGS,
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_user.user_id", ondelete="CASCADE"), nullable=False, index=True)
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("biz_prompt_template.id", ondelete="CASCADE"), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user_sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
template = relationship("PromptTemplate", back_populates="user_configs")

View File

@ -0,0 +1,19 @@
from sqlalchemy import String, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
from .enums import StatusEnum
from sqlalchemy import Enum
class Role(Base, TimestampMixin):
__tablename__ = "sys_role"
__table_args__ = MYSQL_TABLE_ARGS
role_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
role_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
role_name: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
remark: Mapped[str | None] = mapped_column(Text)
users = relationship("UserRole", back_populates="role")
permissions = relationship("RolePermission", back_populates="role")

View File

@ -0,0 +1,26 @@
from sqlalchemy import String, Integer, Text, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql.schema import ForeignKey
from .base import Base, MYSQL_TABLE_ARGS
from datetime import datetime
from sqlalchemy import func
class SysLog(Base):
__tablename__ = "sys_log"
__table_args__ = MYSQL_TABLE_ARGS
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.user_id"), nullable=True, index=True)
username: Mapped[str | None] = mapped_column(String(50))
operation_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
detail: Mapped[str | None] = mapped_column(Text)
ip_address: Mapped[str | None] = mapped_column(String(50))
user_agent: Mapped[str | None] = mapped_column(String(500))
status: Mapped[int] = mapped_column(Integer, default=1)
error_message: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=func.now(), index=True)
user = relationship("User")

View File

@ -0,0 +1,21 @@
from sqlalchemy import String, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin, MYSQL_TABLE_ARGS
from .enums import StatusEnum
class User(Base, TimestampMixin):
__tablename__ = "sys_user"
__table_args__ = MYSQL_TABLE_ARGS
user_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(50), nullable=False)
email: Mapped[str | None] = mapped_column(String(100), unique=True)
phone: Mapped[str | None] = mapped_column(String(30), unique=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar: Mapped[str | None] = mapped_column(String(255))
status: Mapped[int] = mapped_column(Integer, default=StatusEnum.ENABLED, nullable=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
roles = relationship("UserRole", back_populates="user")

View File

View File

@ -0,0 +1,44 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, Any
from datetime import datetime
class AIModelBase(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_type: str = Field(..., pattern="^(asr|llm)$")
provider: str = Field(..., max_length=50)
model_name: str = Field(..., max_length=100)
api_key: Optional[str] = None
base_url: Optional[str] = None
api_path: Optional[str] = None
config: Optional[dict[str, Any]] = None
is_default: bool = False
status: int = 1
class AIModelCreate(AIModelBase):
pass
class AIModelUpdate(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_type: Optional[str] = None
provider: Optional[str] = None
model_name: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
api_path: Optional[str] = None
config: Optional[dict[str, Any]] = None
is_default: Optional[bool] = None
status: Optional[int] = None
class AIModelOut(AIModelBase):
model_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,20 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"

View File

@ -0,0 +1,24 @@
from pydantic import BaseModel
class UserStats(BaseModel):
total: int
new_today: int
online: int
class StorageStats(BaseModel):
total_gb: float
audio_files: int
class ServerStats(BaseModel):
cpu: float
memory: float
disk: float
class DashboardSummary(BaseModel):
users: UserStats
storage: StorageStats
server: ServerStats

View File

@ -0,0 +1,58 @@
from pydantic import BaseModel
from datetime import datetime
class DictTypeOut(BaseModel):
dict_type_id: int
type_code: str
type_name: str
status: int
remark: str | None = None
created_at: datetime
class Config:
from_attributes = True
class DictTypeCreate(BaseModel):
type_code: str
type_name: str
status: int = 1
remark: str | None = None
class DictTypeUpdate(BaseModel):
type_name: str | None = None
status: int | None = None
remark: str | None = None
class DictItemOut(BaseModel):
dict_item_id: int
type_code: str
item_label: str
item_value: str
sort_order: int
status: int
remark: str | None = None
created_at: datetime
class Config:
from_attributes = True
class DictItemCreate(BaseModel):
type_code: str
item_label: str
item_value: str
sort_order: int = 0
status: int = 1
remark: str | None = None
class DictItemUpdate(BaseModel):
item_label: str | None = None
item_value: str | None = None
sort_order: int | None = None
status: int | None = None
remark: str | None = None

View File

@ -0,0 +1,67 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class MeetingBase(BaseModel):
title: str = Field(..., max_length=255)
tags: Optional[str] = None
meeting_time: Optional[datetime] = None
status: str = "draft"
class MeetingCreate(MeetingBase):
pass
class MeetingUpdate(BaseModel):
title: Optional[str] = None
tags: Optional[str] = None
status: Optional[str] = None
summary: Optional[str] = None
class AttendeeOut(BaseModel):
attendee_id: int
user_id: Optional[int]
display_name: Optional[str] = None
avatar: Optional[str] = None
class Config:
from_attributes = True
class MeetingOut(MeetingBase):
meeting_id: int
user_id: Optional[int]
creator_name: Optional[str] = None
creator_avatar: Optional[str] = None
summary: Optional[str] = None
created_at: datetime
updated_at: datetime
attendees: List[AttendeeOut] = []
class Config:
from_attributes = True
class TranscriptSegmentOut(BaseModel):
segment_id: int
speaker_id: Optional[int]
speaker_tag: Optional[str]
start_time_ms: int
end_time_ms: int
text_content: str
class Config:
from_attributes = True
class MeetingDetailOut(MeetingOut):
segments: List[TranscriptSegmentOut] = []
# Add other details like audio list if needed
class MeetingListOut(BaseModel):
items: List[MeetingOut]
total: int

View File

@ -0,0 +1,33 @@
from pydantic import BaseModel
from datetime import datetime
class ParamOut(BaseModel):
param_id: int
param_key: str
param_value: str
param_type: str
status: int
is_system: bool
description: str | None = None
created_at: datetime
class Config:
from_attributes = True
class ParamCreate(BaseModel):
param_key: str
param_value: str
param_type: str = "string"
status: int = 1
is_system: bool = False
description: str | None = None
class ParamUpdate(BaseModel):
param_value: str | None = None
param_type: str | None = None
status: int | None = None
is_system: bool | None = None
description: str | None = None

View File

@ -0,0 +1,51 @@
from pydantic import BaseModel
from typing import Any
class PermissionOut(BaseModel):
perm_id: int
parent_id: int | None
name: str
code: str
perm_type: str
level: int
path: str | None = None
component: str | None = None
icon: str | None = None
sort_order: int
is_visible: bool
status: int
meta: dict[str, Any] | None = None
class Config:
from_attributes = True
class PermissionCreate(BaseModel):
parent_id: int | None = None
name: str
code: str
perm_type: str
level: int
path: str | None = None
icon: str | None = None
sort_order: int = 0
is_visible: bool = True
status: int = 1
description: str | None = None
meta: dict[str, Any] | None = None
class PermissionUpdate(BaseModel):
parent_id: int | None = None
name: str | None = None
code: str | None = None
perm_type: str | None = None
level: int | None = None
path: str | None = None
icon: str | None = None
sort_order: int | None = None
is_visible: bool | None = None
status: int | None = None
description: str | None = None
meta: dict[str, Any] | None = None

View File

@ -0,0 +1,44 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
class PromptTemplateBase(BaseModel):
name: str = Field(..., max_length=100)
category: str = Field("summary", max_length=50)
content: str
description: Optional[str] = Field(None, max_length=255)
sort_order: int = 0
status: int = 1
class PromptTemplateCreate(PromptTemplateBase):
is_system: bool = False
class PromptTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=100)
category: Optional[str] = Field(None, max_length=50)
content: Optional[str] = None
description: Optional[str] = Field(None, max_length=255)
sort_order: Optional[int] = None
status: Optional[int] = None
is_system: Optional[bool] = None
class PromptTemplateOut(PromptTemplateBase):
id: int
user_id: Optional[int]
is_system: bool
is_active: bool = True # From UserPromptConfig
user_sort_order: Optional[int] = None # From UserPromptConfig
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserPromptConfigUpdate(BaseModel):
is_active: Optional[bool] = None
user_sort_order: Optional[int] = None

View File

@ -0,0 +1,31 @@
from pydantic import BaseModel
from datetime import datetime
class RoleOut(BaseModel):
role_id: int
role_code: str
role_name: str
status: int
remark: str | None = None
created_at: datetime
class Config:
from_attributes = True
class RoleCreate(BaseModel):
role_code: str
role_name: str
status: int = 1
remark: str | None = None
class RoleUpdate(BaseModel):
role_name: str | None = None
status: int | None = None
remark: str | None = None
class RolePermissionUpdate(BaseModel):
perm_ids: list[int]

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel
from datetime import datetime
class SysLogOut(BaseModel):
id: int
user_id: int | None
username: str | None
operation_type: str
resource_type: str
resource_id: int | None
detail: str | None
ip_address: str | None
user_agent: str | None
status: int
error_message: str | None
created_at: datetime
class Config:
from_attributes = True
class SysLogPage(BaseModel):
items: list[SysLogOut]
total: int
class SysLogCreate(BaseModel):
user_id: int | None = None
username: str | None = None
operation_type: str
resource_type: str
resource_id: int | None = None
detail: str | None = None
ip_address: str | None = None
user_agent: str | None = None
status: int = 1
error_message: str | None = None

View File

@ -0,0 +1,43 @@
from pydantic import BaseModel
from datetime import datetime
class UserOut(BaseModel):
user_id: int
username: str
display_name: str
email: str | None = None
phone: str | None = None
avatar: str | None = None
status: int
created_at: datetime
class Config:
from_attributes = True
class UserMe(UserOut):
roles: list[str] = []
class UserCreate(BaseModel):
username: str
display_name: str
email: str | None = None
phone: str | None = None
password: str | None = None
status: int = 1
role_ids: list[int] = []
class UserUpdate(BaseModel):
display_name: str | None = None
email: str | None = None
phone: str | None = None
status: int | None = None
role_ids: list[int] | None = None
class PasswordChange(BaseModel):
old_password: str
new_password: str

View File

View File

@ -0,0 +1,84 @@
from datetime import timedelta
from uuid import uuid4
from sqlalchemy.orm import Session
from redis import Redis
from app.core.security import verify_password, create_token, decode_token
from app.core.config import get_settings
from app.models import User
from app.models.enums import StatusEnum
from app.services.param_service import get_token_minutes
settings = get_settings()
def authenticate_user(db: Session, username: str, password: str) -> User | None:
user = (
db.query(User)
.filter(User.username == username, User.status == int(StatusEnum.ENABLED), User.is_deleted.is_(False))
.first()
)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
def create_token_pair(db: Session, redis: Redis, user: User) -> tuple[str, str]:
access_minutes, refresh_minutes = get_token_minutes(
db,
default_access=settings.jwt_access_token_minutes,
default_refresh=settings.jwt_refresh_token_minutes,
)
access_payload = {"sub": str(user.user_id), "type": "access"}
refresh_jti = str(uuid4())
refresh_payload = {"sub": str(user.user_id), "type": "refresh", "jti": refresh_jti}
access_token = create_token(access_payload, timedelta(minutes=access_minutes))
refresh_token = create_token(refresh_payload, timedelta(minutes=refresh_minutes))
redis_key = f"auth:refresh:{refresh_jti}"
redis.setex(redis_key, refresh_minutes * 60, str(user.user_id))
# Track online user with TTL synced to refresh token
online_key = f"auth:online:{user.user_id}"
redis.setex(online_key, refresh_minutes * 60, "1")
return access_token, refresh_token
def refresh_access_token(db: Session, redis: Redis, refresh_token: str) -> tuple[str, str]:
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise ValueError("Invalid token type")
jti = payload.get("jti")
if not jti:
raise ValueError("Invalid token")
redis_key = f"auth:refresh:{jti}"
user_id = redis.get(redis_key)
if not user_id:
raise ValueError("Refresh token expired")
user = db.query(User).filter(User.user_id == int(user_id), User.is_deleted.is_(False)).first()
if not user or user.status != int(StatusEnum.ENABLED):
raise ValueError("User not found")
# Invalidate the old refresh token to avoid stale online counts
redis.delete(redis_key)
return create_token_pair(db, redis, user)
def logout_refresh_token(redis: Redis, refresh_token: str) -> None:
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise ValueError("Invalid token type")
jti = payload.get("jti")
if not jti:
raise ValueError("Invalid token")
redis_key = f"auth:refresh:{jti}"
user_id = redis.get(redis_key)
redis.delete(redis_key)
if user_id:
redis.delete(f"auth:online:{user_id}")

View File

@ -0,0 +1,60 @@
import httpx
import json
import logging
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class LLMService:
@staticmethod
async def chat_completion(
api_key: str,
base_url: str,
model_name: str,
messages: List[Dict[str, str]],
api_path: str = "/chat/completions",
temperature: float = 0.7,
top_p: float = 0.9,
max_tokens: int = 4000
) -> str:
"""
Generic OpenAI-compatible chat completion caller.
"""
# Ensure base_url doesn't end with trailing slash if path starts with one
url = f"{base_url.rstrip('/')}{api_path}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model_name,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens
}
async with httpx.AsyncClient(timeout=120.0) as client:
try:
logger.info(f"Sending request to LLM: {url} (Model: {model_name})")
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
return content
except httpx.HTTPStatusError as e:
error_body = e.response.text
logger.error(f"LLM API Error ({e.response.status_code}): {error_body}")
# Try to parse error message from body
try:
err_json = json.loads(error_body)
detail = err_json.get("error", {}).get("message", error_body)
except:
detail = error_body
raise Exception(f"AI 服务调用失败: {detail}")
except Exception as e:
logger.error(f"Error calling LLM: {str(e)}")
raise Exception(f"连接 AI 服务发生错误: {str(e)}")

View File

@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from app.models.sys_log import SysLog
def create_log(
db: Session,
user_id: int | None,
username: str | None,
operation_type: str,
resource_type: str,
detail: str | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
status: int = 1,
resource_id: int | None = None,
error_message: str | None = None
):
log_entry = SysLog(
user_id=user_id,
username=username,
operation_type=operation_type,
resource_type=resource_type,
resource_id=resource_id,
detail=detail,
ip_address=ip_address,
user_agent=user_agent,
status=status,
error_message=error_message
)
db.add(log_entry)
db.commit()
db.refresh(log_entry)
return log_entry

View File

@ -0,0 +1,151 @@
from sqlalchemy.orm import Session
from app.models import Meeting, SummarizeTask, PromptTemplate, AIModel, TranscriptSegment
from app.services.llm_service import LLMService
import uuid
import json
import logging
import math
from datetime import datetime
from app.core.redis import redis_client
logger = logging.getLogger(__name__)
class MeetingService:
@staticmethod
async def create_summarize_task(
db: Session,
meeting_id: int,
prompt_id: int,
model_id: int,
extra_prompt: str = ""
):
# 1. 基础数据校验
meeting = db.query(Meeting).filter(Meeting.meeting_id == meeting_id).first()
if not meeting:
raise Exception("Meeting not found")
# 2. 格式化会议转译内容 (作为 user_prompt 素材)
segments = db.query(TranscriptSegment).filter(
TranscriptSegment.meeting_id == meeting_id
).order_by(TranscriptSegment.start_time_ms).all()
formatted_content = []
for s in segments:
secs = int(s.start_time_ms // 1000)
m, sc = divmod(secs, 60)
timestamp = f"[{m:02d}:{sc:02d}]"
speaker = s.speaker_tag or f"发言人{s.speaker_id or '?'}"
formatted_content.append(f"{timestamp} {speaker}: {s.text_content}")
meeting_text = "\n".join(formatted_content)
# 组合最终 user_prompt (素材 + 用户的附加要求)
user_prompt_content = f"### 会议转译内容 ###\n{meeting_text}"
if extra_prompt:
user_prompt_content += f"\n\n### 用户的额外指令 ###\n{extra_prompt}"
# 3. 创建任务记录 (按照数据库实际字段)
task_id = str(uuid.uuid4())
new_task = SummarizeTask(
task_id=task_id,
meeting_id=meeting_id,
prompt_id=prompt_id,
model_id=model_id,
user_prompt=user_prompt_content,
status="pending",
progress=0,
created_at=datetime.utcnow()
)
db.add(new_task)
# 更新会议状态为“总结中”
meeting.status = "summarizing"
db.commit()
# 4. 进入 Redis 队列
await redis_client.lpush("meeting:summarize:queue", task_id)
return new_task
@staticmethod
async def process_summarize_task(db: Session, task_id: str):
"""
后台 Worker 真实执行逻辑
"""
task = db.query(SummarizeTask).filter(SummarizeTask.task_id == task_id).first()
if not task:
return
try:
task.status = "processing"
task.progress = 15
db.commit()
# 1. 获取模型配置
model_config = db.query(AIModel).filter(AIModel.model_id == task.model_id).first()
if not model_config:
raise Exception("AI 模型配置不存在")
# 2. 实时获取提示词模板内容
prompt_tmpl = db.query(PromptTemplate).filter(PromptTemplate.id == task.prompt_id).first()
system_prompt = prompt_tmpl.content if prompt_tmpl else "请根据提供的会议转译内容生成准确的总结。"
task.progress = 30
db.commit()
# 3. 构建消息结构
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task.user_prompt}
]
# 解析模型参数
config_params = model_config.config or {}
temperature = config_params.get("temperature", 0.7)
top_p = config_params.get("top_p", 0.9)
logger.info(f"Task {task_id}: Launching LLM request to {model_config.model_name}")
task.progress = 50
db.commit()
# 4. 调用大模型服务
summary_result = await LLMService.chat_completion(
api_key=model_config.api_key,
base_url=model_config.base_url or "https://api.openai.com/v1",
model_name=model_config.model_name,
messages=messages,
api_path=model_config.api_path or "/chat/completions",
temperature=float(temperature),
top_p=float(top_p)
)
# 5. 任务完成,回写结果
task.result = summary_result
task.status = "completed"
task.progress = 100
task.completed_at = datetime.utcnow()
# 同步更新会议主表摘要和状态
meeting = db.query(Meeting).filter(Meeting.meeting_id == task.meeting_id).first()
if meeting:
meeting.summary = summary_result
meeting.status = "completed"
db.commit()
logger.info(f"Task {task_id} completed successfully")
except Exception as e:
logger.error(f"Task {task_id} execution error: {str(e)}")
task.status = "failed"
task.error_message = str(e)
# 还原会议状态
meeting = db.query(Meeting).filter(Meeting.meeting_id == task.meeting_id).first()
if meeting:
meeting.status = "draft"
db.commit()
@staticmethod
def get_task_status(db: Session, task_id: str):
return db.query(SummarizeTask).filter(SummarizeTask.task_id == task_id).first()

View File

@ -0,0 +1,37 @@
from sqlalchemy.orm import Session
from app.models import SystemParam
from app.models.enums import ParamTypeEnum
import json
ACCESS_TOKEN_KEY = "security.access_token_minutes"
REFRESH_TOKEN_KEY = "security.refresh_token_minutes"
def parse_param_value(param: SystemParam):
if param.param_type == ParamTypeEnum.INT:
return int(param.param_value)
if param.param_type == ParamTypeEnum.BOOL:
return param.param_value.lower() in {"1", "true", "yes"}
if param.param_type == ParamTypeEnum.JSON:
return json.loads(param.param_value)
return param.param_value
def get_param_value(db: Session, key: str):
return db.query(SystemParam).filter(SystemParam.param_key == key).first()
def get_token_minutes(db: Session, default_access: int, default_refresh: int) -> tuple[int, int]:
access = get_param_value(db, ACCESS_TOKEN_KEY)
refresh = get_param_value(db, REFRESH_TOKEN_KEY)
access_minutes = default_access
refresh_minutes = default_refresh
if access:
access_minutes = int(parse_param_value(access))
if refresh:
refresh_minutes = int(parse_param_value(refresh))
return access_minutes, refresh_minutes

View File

@ -0,0 +1,324 @@
# 数据库结构文档 (MySQL 5.7/8.0 兼容)
- 字符集:`utf8mb4`
- 排序规则:`utf8mb4_unicode_ci`
- 说明:本文件用于持续维护核心表结构与描述(每次变更请同步更新)。
## 1. 用户与角色
### 1.1 `sys_user` 用户表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| user_id | INT | PK, AUTO_INCREMENT | 用户ID |
| username | VARCHAR(50) | UNIQUE, NOT NULL | 登录名 |
| display_name | VARCHAR(50) | NOT NULL | 显示名 |
| email | VARCHAR(100) | UNIQUE | 邮箱 |
| phone | VARCHAR(30) | UNIQUE | 手机 |
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 |
| avatar | VARCHAR(255) | | 头像路径 |
| status | TINYINT(1) | NOT NULL | 状态1 启用 / 0 禁用 |
| is_deleted | TINYINT(1) | NOT NULL | 是否软删除 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 1.2 `sys_role` 角色表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| role_id | INT | PK, AUTO_INCREMENT | 角色ID |
| role_code | VARCHAR(50) | UNIQUE, NOT NULL | 角色编码 |
| role_name | VARCHAR(50) | NOT NULL | 角色名称 |
| status | TINYINT(1) | NOT NULL | 状态1 启用 / 0 禁用 |
| remark | TEXT | | 备注 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 1.3 `sys_user_role` 用户-角色关联表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | INT | PK, AUTO_INCREMENT | 关联ID |
| user_id | INT | INDEX, NOT NULL | 用户ID |
| role_id | INT | INDEX, NOT NULL | 角色ID |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
| | | UNIQUE(user_id, role_id) | 唯一约束 |
## 2. 权限
### 2.1 `sys_permission` 权限表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| perm_id | INT | PK, AUTO_INCREMENT | 权限ID |
| parent_id | INT | INDEX | 父级权限ID |
| name | VARCHAR(100) | NOT NULL | 权限名称 |
| code | VARCHAR(100) | UNIQUE, NOT NULL | 权限编码 |
| perm_type | VARCHAR(20) | NOT NULL | menu/button |
| level | INT | NOT NULL | 1/2/3 级 |
| path | VARCHAR(255) | | 前端路由 |
| icon | VARCHAR(100) | | 图标 |
| sort_order | INT | NOT NULL | 排序 |
| is_visible | TINYINT(1) | NOT NULL | 是否展示 |
| status | TINYINT(1) | NOT NULL | 1/0 |
| description | TEXT | | 描述 |
| meta | JSON | | 扩展属性 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 2.2 `sys_role_permission` 角色-权限关联表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | INT | PK, AUTO_INCREMENT | 关联ID |
| role_id | INT | INDEX, NOT NULL | 角色ID |
| perm_id | INT | INDEX, NOT NULL | 权限ID |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
| | | UNIQUE(role_id, perm_id) | 唯一约束 |
## 3. 码表
### 3.1 `sys_dict_type` 码表类型
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| dict_type_id | INT | PK, AUTO_INCREMENT | 类型ID |
| type_code | VARCHAR(50) | UNIQUE, NOT NULL | 类型编码 |
| type_name | VARCHAR(50) | NOT NULL | 类型名称 |
| status | TINYINT(1) | NOT NULL | 1/0 |
| remark | TEXT | | 备注 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 3.2 `sys_dict_item` 码表项
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| dict_item_id | INT | PK, AUTO_INCREMENT | 明细ID |
| type_code | VARCHAR(50) | INDEX, NOT NULL | 类型编码 |
| item_label | VARCHAR(100) | NOT NULL | 展示值 |
| item_value | VARCHAR(100) | NOT NULL | 存储值 |
| sort_order | INT | NOT NULL | 排序 |
| status | TINYINT(1) | NOT NULL | 1/0 |
| remark | TEXT | | 备注 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
| | | UNIQUE(type_code, item_value) | 唯一约束 |
## 4. 系统参数
### 4.1 `sys_param` 系统参数表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| param_id | INT | PK, AUTO_INCREMENT | 参数ID |
| param_key | VARCHAR(100) | UNIQUE, NOT NULL | 参数键 |
| param_value | TEXT | NOT NULL | 参数值 |
| param_type | VARCHAR(20) | NOT NULL | string/int/bool/json |
| status | TINYINT(1) | NOT NULL | 1/0 |
| is_system | TINYINT(1) | NOT NULL | 是否系统级 |
| description | TEXT | | 描述 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
## 5. 日志
### 5.1 `sys_log` 操作日志表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGINT | PK, AUTO_INCREMENT | 日志ID |
| user_id | INT | INDEX | 操作用户ID |
| username | VARCHAR(50) | | 用户名 |
| operation_type | VARCHAR(50) | NOT NULL | 操作类型 |
| resource_type | VARCHAR(50) | NOT NULL | 资源类型 |
| resource_id | BIGINT | INDEX | 资源ID |
| detail | TEXT | | 操作详情(JSON) |
| ip_address | VARCHAR(50) | | IP地址 |
| user_agent | VARCHAR(500) | | 用户代理 |
| status | TINYINT | DEFAULT 1 | 状态: 1成功/0失败 |
| error_message | TEXT | | 错误信息 |
| created_at | DATETIME | NOT NULL | 操作时间 |
## 6. 业务 - 提示词管理
### 6.1 `biz_prompt_template` 提示词模板表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | INT | PK, AUTO_INCREMENT | 模板ID |
| name | VARCHAR(100) | NOT NULL | 模板名称 |
| category | VARCHAR(50) | NOT NULL | 分类 |
| content | TEXT | NOT NULL | 提示词内容 |
| description | VARCHAR(255) | | 描述 |
| user_id | INT | INDEX | 创建者ID (系统级为NULL) |
| is_system | TINYINT(1) | NOT NULL | 是否系统内置 |
| status | TINYINT(1) | NOT NULL | 全局状态1启用 / 0禁用 |
| sort_order | INT | NOT NULL | 默认排序 |
| created_at | DATETIME | NOT NULL | 创建时间 |
| updated_at | DATETIME | NOT NULL | 更新时间 |
### 6.2 `biz_user_prompt_config` 用户提示词配置表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | INT | PK, AUTO_INCREMENT | 配置ID |
| user_id | INT | INDEX, NOT NULL | 用户ID |
| template_id | INT | INDEX, NOT NULL | 模板ID |
| is_active | TINYINT(1) | NOT NULL | 用户是否启用 |
| user_sort_order | INT | NOT NULL | 用户自定义排序 |
| updated_at | DATETIME | NOT NULL | 修改时间 |
| | | UNIQUE(user_id, template_id) | 唯一约束 |
## 7. 业务 - 会议管理
### 7.1 `biz_meeting` 会议主表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| meeting_id | INT | PK, AUTO_INCREMENT | 会议ID |
| user_id | INT | | 创建者ID |
| title | VARCHAR(255) | NOT NULL | 会议标题 |
| tags | VARCHAR(255) | | 标签(逗号分隔) |
| meeting_time | TIMESTAMP | | 会议时间 |
| access_password| VARCHAR(10) | | 访问密码 |
| status | VARCHAR(20) | DEFAULT 'draft' | 状态: draft, transcribing, summarizing, completed |
| summary | TEXT | | 最终总结内容 |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
### 7.2 `biz_meeting_attendees` 会议参会人表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| attendee_id | INT | PK, AUTO_INCREMENT | 参会ID |
| meeting_id | INT | INDEX | 会议ID |
| user_id | INT | | 系统用户ID |
### 7.3 `biz_meeting_audio` 会议音频表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| audio_id | INT | PK, AUTO_INCREMENT | 音频ID |
| meeting_id | INT | | 会议ID |
| file_path | VARCHAR(512) | NOT NULL | 文件存储路径 |
| file_name | VARCHAR(200) | | 原始文件名 |
| file_size | DECIMAL(10,0) | | 文件大小(字节) |
| duration | INT | DEFAULT 0 | 音频时长(秒) |
| processing_status| VARCHAR(20) | DEFAULT 'uploaded' | 处理状态 |
| upload_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 上传时间 |
| error_message | TEXT | | 错误信息 |
### 7.4 `biz_transcript_task` 转译任务表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| task_id | VARCHAR(255) | PK | 任务ID |
| meeting_id | INT | INDEX, NOT NULL | 会议ID |
| model_id | INT | | 模型配置ID (关联 biz_ai_model) |
| language | VARCHAR(10) | DEFAULT 'auto' | 识别语言 |
| status | ENUM | INDEX, DEFAULT 'pending' | pending/processing/completed/failed |
| progress | INT | DEFAULT 0 | 处理进度(0-100) |
| completed_at | TIMESTAMP | | 完成时间 |
| error_message | TEXT | | 错误信息 |
| created_at | TIMESTAMP | INDEX, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
### 7.5 `biz_transcript_segment` 转译片段表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| segment_id | INT | PK, AUTO_INCREMENT | 片段ID |
| meeting_id | INT | | 会议ID |
| audio_id | INT | | 音频ID |
| speaker_id | INT | | 发言人ID |
| speaker_tag | VARCHAR(50) | | 发言人标签(如: A, B) |
| start_time_ms | INT | NOT NULL | 开始时间(毫秒) |
| end_time_ms | INT | NOT NULL | 结束时间(毫秒) |
| text_content | TEXT | NOT NULL | 文本内容 |
### 7.6 `biz_summarize_task` 总结任务表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| task_id | VARCHAR(100) | PK | 任务ID |
| meeting_id | INT | INDEX, NOT NULL | 会议ID |
| prompt_id | INT | | 使用的提示词模板ID |
| model_id | INT | | 模型配置ID (关联 biz_ai_model) |
| user_prompt | TEXT | | 任务执行时的最终Prompt |
| status | VARCHAR(50) | INDEX, DEFAULT 'pending' | 任务状态 |
| progress | INT | DEFAULT 0 | 进度 |
| result | TEXT | | 总结结果 |
| error_message | TEXT | | 错误信息 |
| created_at | TIMESTAMP | INDEX, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| completed_at | TIMESTAMP | | 完成时间 |
### 7.7 `biz_ai_model` AI 模型配置表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| model_id | INT | PK, AUTO_INCREMENT | 模型配置ID |
| model_type | VARCHAR(20) | NOT NULL | asr/llm |
| provider | VARCHAR(50) | NOT NULL | 提供商 (Qwen, OpenAI等) |
| model_name | VARCHAR(100) | NOT NULL | 模型标识 |
| api_key | VARCHAR(255) | | API密钥 |
| base_url | VARCHAR(255) | | 基础URL |
| api_path | VARCHAR(100) | | 接口路径 |
| config | JSON | | 差异化参数(temp, top_p等) |
| is_default | TINYINT(1) | NOT NULL | 是否默认 |
| status | TINYINT(1) | NOT NULL | 启用状态 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
## 7. 业务 - 会议管理
### 7.1 `biz_meeting` 会议主表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| meeting_id | INT | PK, AUTO_INCREMENT | 会议ID |
| user_id | INT | | 创建者ID |
| title | VARCHAR(255) | NOT NULL | 会议标题 |
| tags | VARCHAR(255) | | 标签(逗号分隔) |
| meeting_time | TIMESTAMP | | 会议时间 |
| access_password| VARCHAR(10) | | 访问密码 |
| summary | TEXT | | 最终总结内容 |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
### 7.2 `biz_meeting_attendees` 会议参会人表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| attendee_id | INT | PK, AUTO_INCREMENT | 参会ID |
| meeting_id | INT | INDEX | 会议ID |
| user_id | INT | | 系统用户ID |
### 7.3 `biz_meeting_audio` 会议音频表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| audio_id | INT | PK, AUTO_INCREMENT | 音频ID |
| meeting_id | INT | | 会议ID |
| file_path | VARCHAR(512) | NOT NULL | 文件存储路径 |
| file_name | VARCHAR(200) | | 原始文件名 |
| file_size | DECIMAL(10,0) | | 文件大小(字节) |
| processing_status| VARCHAR(20) | DEFAULT 'uploaded' | 处理状态 |
| upload_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 上传时间 |
| error_message | TEXT | | 错误信息 |
### 7.4 `biz_transcript_task` 转译任务表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| task_id | VARCHAR(255) | PK | 任务ID |
| meeting_id | INT | INDEX, NOT NULL | 会议ID |
| status | ENUM | INDEX, DEFAULT 'pending' | pending/processing/completed/failed |
| progress | INT | DEFAULT 0 | 处理进度(0-100) |
| completed_at | TIMESTAMP | | 完成时间 |
| error_message | TEXT | | 错误信息 |
| created_at | TIMESTAMP | INDEX, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
### 7.5 `biz_transcript_segment` 转译片段表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| segment_id | INT | PK, AUTO_INCREMENT | 片段ID |
| meeting_id | INT | | 会议ID |
| speaker_id | INT | | 发言人ID |
| speaker_tag | VARCHAR(50) | | 发言人标签(如: A, B) |
| start_time_ms | INT | NOT NULL | 开始时间(毫秒) |
| end_time_ms | INT | NOT NULL | 结束时间(毫秒) |
| text_content | TEXT | NOT NULL | 文本内容 |
### 7.6 `biz_summarize_task` 总结任务表
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| task_id | VARCHAR(100) | PK | 任务ID |
| meeting_id | INT | INDEX, NOT NULL | 会议ID |
| prompt_id | INT | | 使用的提示词模板ID |
| user_prompt | TEXT | | 任务执行时的最终Prompt |
| status | VARCHAR(50) | INDEX, DEFAULT 'pending' | 任务状态 |
| progress | INT | DEFAULT 0 | 进度 |
| result | TEXT | | 总结结果 |
| error_message | TEXT | | 错误信息 |
| created_at | TIMESTAMP | INDEX, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| completed_at | TIMESTAMP | | 完成时间 |

View File

@ -0,0 +1,148 @@
-- Nex Basse init schema (MySQL 5.7/8.0)
-- Charset: utf8mb4, Collation: utf8mb4_unicode_ci
CREATE TABLE IF NOT EXISTS sys_user (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE,
phone VARCHAR(30) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_role (
role_id INT AUTO_INCREMENT PRIMARY KEY,
role_code VARCHAR(50) NOT NULL UNIQUE,
role_name VARCHAR(50) NOT NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
remark TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_user_role (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_role (user_id, role_id),
INDEX idx_user_role_user (user_id),
INDEX idx_user_role_role (role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_permission (
perm_id INT AUTO_INCREMENT PRIMARY KEY,
parent_id INT NULL,
name VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
perm_type VARCHAR(20) NOT NULL,
level INT NOT NULL,
path VARCHAR(255),
component VARCHAR(255),
icon VARCHAR(100),
sort_order INT NOT NULL DEFAULT 0,
is_visible TINYINT(1) NOT NULL DEFAULT 1,
status TINYINT(1) NOT NULL DEFAULT 1,
description TEXT,
meta JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_perm_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_role_permission (
id INT AUTO_INCREMENT PRIMARY KEY,
role_id INT NOT NULL,
perm_id INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_role_perm (role_id, perm_id),
INDEX idx_role_perm_role (role_id),
INDEX idx_role_perm_perm (perm_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_dict_type (
dict_type_id INT AUTO_INCREMENT PRIMARY KEY,
type_code VARCHAR(50) NOT NULL UNIQUE,
type_name VARCHAR(50) NOT NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
remark TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_dict_item (
dict_item_id INT AUTO_INCREMENT PRIMARY KEY,
type_code VARCHAR(50) NOT NULL,
item_label VARCHAR(100) NOT NULL,
item_value VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
status TINYINT(1) NOT NULL DEFAULT 1,
remark TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_dict_item (type_code, item_value),
INDEX idx_dict_item_type (type_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sys_param (
param_id INT AUTO_INCREMENT PRIMARY KEY,
param_key VARCHAR(100) NOT NULL UNIQUE,
param_value TEXT NOT NULL,
param_type VARCHAR(20) NOT NULL DEFAULT 'string',
status TINYINT(1) NOT NULL DEFAULT 1,
is_system TINYINT(1) NOT NULL DEFAULT 0,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Default system params
INSERT INTO sys_param (param_key, param_value, param_type, status, is_system, description)
VALUES
('security.access_token_minutes', '60', 'int', 1, 1, 'Access token 有效期(分钟)'),
('security.refresh_token_minutes', '10080', 'int', 1, 1, 'Refresh token 有效期(分钟)')
ON DUPLICATE KEY UPDATE param_value=VALUES(param_value);
-- Default admin role
INSERT IGNORE INTO sys_role (role_code, role_name, status, remark)
VALUES ('admin', '管理员', 1, '系统默认管理员');
-- Seed permissions (3-level menu/button)
INSERT IGNORE INTO sys_permission (perm_id, parent_id, name, code, perm_type, level, path, component, icon, sort_order, is_visible, status, description, meta)
VALUES
(1, NULL, 'AI 战略洞察', 'strategy.ai', 'menu', 1, '/strategy', NULL, 'strategy', 1, 1, 1, '一级菜单', NULL),
(2, NULL, '工作空间', 'workspace', 'menu', 1, '/workspace', NULL, 'workspace', 2, 1, 1, '一级菜单', NULL),
(3, NULL, '知识库', 'ai_agent', 'menu', 1, '/ai-agent', NULL, 'ai', 3, 1, 1, '一级菜单', NULL),
(4, NULL, '系统管理', 'system', 'menu', 1, '/system', NULL, 'system', 4, 1, 1, '一级菜单', NULL),
(10, 1, 'AI 战略洞察', 'strategy.ai.home', 'menu', 2, '/strategy/insights', 'StrategyHome', 'insight', 1, 1, 1, '二级菜单', NULL),
(11, 2, '实时会议', 'workspace.realtime', 'menu', 2, '/workspace/meeting', 'MeetingLive', 'meeting', 1, 1, 1, '二级菜单', NULL),
(12, 2, '历史记录', 'workspace.history', 'menu', 2, '/workspace/history', 'MeetingHistory', 'history', 2, 1, 1, '二级菜单', NULL),
(13, 2, '声纹档案', 'workspace.voice', 'menu', 2, '/workspace/voice', 'VoiceProfile', 'voice', 3, 1, 1, '二级菜单', NULL),
(14, 3, '知识库管理', 'ai_agent.kb', 'menu', 2, '/ai-agent/kb', 'KnowledgeBase', 'kb', 1, 1, 1, '二级菜单', NULL),
(15, 3, '总结模板管理', 'ai_agent.templates', 'menu', 2, '/ai-agent/templates', 'PromptTemplates', 'template', 2, 1, 1, '二级菜单', NULL),
(16, 3, '热词管理', 'ai_agent.hotwords', 'menu', 2, '/ai-agent/hotwords', 'Hotwords', 'hot', 3, 1, 1, '二级菜单', NULL),
(17, 4, '系统设置', 'system.setting', 'menu', 2, '/system/settings', 'SystemSettings', 'setting', 1, 1, 1, '二级菜单', NULL),
(18, 4, '用户管理', 'system.users', 'menu', 2, '/system/users', 'UserManage', 'user', 2, 1, 1, '二级菜单', NULL),
(19, 4, '角色管理', 'system.roles', 'menu', 2, '/system/roles', 'RoleManage', 'role', 3, 1, 1, '二级菜单', NULL),
(20, 4, '系统指标', 'system.metrics', 'menu', 2, '/system/metrics', 'SystemMetrics', 'metrics', 4, 1, 1, '二级菜单', NULL),
(101, 18, '创建用户', 'system.users.create', 'button', 3, NULL, NULL, NULL, 1, 1, 1, '按钮权限', NULL),
(102, 18, '编辑用户', 'system.users.edit', 'button', 3, NULL, NULL, NULL, 2, 1, 1, '按钮权限', NULL),
(103, 18, '删除用户', 'system.users.delete', 'button', 3, NULL, NULL, NULL, 3, 1, 1, '按钮权限', NULL),
(111, 19, '创建角色', 'system.roles.create', 'button', 3, NULL, NULL, NULL, 1, 1, 1, '按钮权限', NULL),
(112, 19, '编辑角色', 'system.roles.edit', 'button', 3, NULL, NULL, NULL, 2, 1, 1, '按钮权限', NULL),
(113, 19, '分配权限', 'system.roles.assign', 'button', 3, NULL, NULL, NULL, 3, 1, 1, '按钮权限', NULL);
-- Grant all permissions to admin role
INSERT IGNORE INTO sys_role_permission (role_id, perm_id)
SELECT r.role_id, p.perm_id
FROM sys_role r
JOIN sys_permission p
WHERE r.role_code = 'admin';

View File

@ -0,0 +1,6 @@
-- Add status column to sys_user and backfill from is_active
ALTER TABLE sys_user ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'enabled';
UPDATE sys_user SET status = CASE WHEN is_active = 1 THEN 'enabled' ELSE 'disabled' END;
-- Optional cleanup (uncomment if you want to remove is_active)
-- ALTER TABLE sys_user DROP COLUMN is_active;

View File

@ -0,0 +1,21 @@
-- Convert status columns to TINYINT(1) and backfill (safe order)
UPDATE sys_user SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_user MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
UPDATE sys_role SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_role MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
UPDATE sys_permission SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_permission MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
UPDATE sys_dict_type SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_dict_type MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
UPDATE sys_dict_item SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_dict_item MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
UPDATE sys_param SET status = CASE WHEN status IN ('enabled', '1') THEN '1' ELSE '0' END;
ALTER TABLE sys_param MODIFY COLUMN status TINYINT(1) NOT NULL DEFAULT 1;
-- Optional: drop legacy is_active if exists
-- ALTER TABLE sys_user DROP COLUMN is_active;

View File

@ -0,0 +1,12 @@
-- Drop legacy is_active column if exists (MySQL 5.7/8.0 compatible)
SET @exists := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA='nex_basse'
AND TABLE_NAME='sys_user'
AND COLUMN_NAME='is_active'
);
SET @sql := IF(@exists=1, 'ALTER TABLE sys_user DROP COLUMN is_active', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,8 @@
-- add permission nodes for permissions/dicts/params pages
INSERT IGNORE INTO sys_permission (perm_id, parent_id, name, code, perm_type, level, path, component, icon, sort_order, is_visible, status, description, meta)
VALUES
(21, 4, '权限管理', 'system.permissions', 'menu', 2, '/system/permissions', 'PermissionTree', 'lock', 5, 1, 1, '二级菜单', NULL),
(22, 4, '码表管理', 'system.dicts', 'menu', 2, '/system/dicts', 'DictManage', 'dict', 6, 1, 1, '二级菜单', NULL),
(23, 4, '系统参数', 'system.params', 'menu', 2, '/system/params', 'ParamManage', 'param', 7, 1, 1, '二级菜单', NULL);
-- NOTE: 不自动给角色授权权限,请在角色权限分配页面手动分配

View File

@ -0,0 +1,10 @@
-- Add LOG_OPERATION_TYPE dict
INSERT INTO sys_dict_type (type_code, type_name, status, remark) VALUES ('LOG_OPERATION_TYPE', '操作类型', 1, '系统日志操作类型');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order, status) VALUES
('LOG_OPERATION_TYPE', '登录', 'LOGIN', 1, 1),
('LOG_OPERATION_TYPE', '登出', 'LOGOUT', 2, 1),
('LOG_OPERATION_TYPE', '创建', 'CREATE', 3, 1),
('LOG_OPERATION_TYPE', '更新', 'UPDATE', 4, 1),
('LOG_OPERATION_TYPE', '删除', 'DELETE', 5, 1),
('LOG_OPERATION_TYPE', '查询', 'QUERY', 6, 1);

View File

@ -0,0 +1,2 @@
-- Remove auto-granted permissions for newly added menu items (manual assignment only)
DELETE FROM sys_role_permission WHERE perm_id IN (21,22,23);

View File

@ -0,0 +1 @@
ALTER TABLE sys_user ADD COLUMN avatar VARCHAR(255) DEFAULT NULL;

View File

@ -0,0 +1,32 @@
-- 提示词模板表
CREATE TABLE `biz_prompt_template` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL COMMENT '模板名称',
`category` VARCHAR(50) NOT NULL COMMENT '分类',
`content` TEXT NOT NULL COMMENT '提示词内容',
`description` VARCHAR(255) COMMENT '描述',
`user_id` INT COMMENT '创建者ID (系统级为NULL)',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统内置',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '全局状态1启用 / 0禁用',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '默认排序',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_prompt_user_id` (`user_id`),
FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`user_id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 用户提示词配置表
CREATE TABLE `biz_user_prompt_config` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL COMMENT '用户ID',
`template_id` INT NOT NULL COMMENT '模板ID',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '用户是否启用',
`user_sort_order` INT NOT NULL DEFAULT 0 COMMENT '用户自定义排序',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
UNIQUE KEY `uk_user_template` (`user_id`, `template_id`),
INDEX `idx_config_user_id` (`user_id`),
INDEX `idx_config_template_id` (`template_id`),
FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`user_id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `biz_prompt_template` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,39 @@
-- 2. 创建 AI 模型配置表
CREATE TABLE `biz_ai_model` (
`model_id` INT AUTO_INCREMENT PRIMARY KEY,
`model_type` VARCHAR(20) NOT NULL COMMENT '类型: asr/llm',
`provider` VARCHAR(50) NOT NULL COMMENT '提供商: Qwen, OpenAI, Aliyun等',
`model_name` VARCHAR(100) NOT NULL COMMENT '模型标识/Model Name',
`api_key` VARCHAR(255) COMMENT 'API密钥',
`base_url` VARCHAR(255) COMMENT '接口基础地址',
`api_path` VARCHAR(100) COMMENT '接口路径',
`config` JSON COMMENT '差异化配置(temp, top_p, language等)',
`is_default` TINYINT(1) DEFAULT 0 COMMENT '是否为该类型的默认模型',
`status` TINYINT(1) DEFAULT 1 COMMENT '1启用/0禁用',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. 调整会议主表:增加状态字段
ALTER TABLE `biz_meeting`
ADD COLUMN `status` VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft, transcribing, summarizing, completed' AFTER `access_password`;
-- 4. 调整会议音频表:增加时长
ALTER TABLE `biz_meeting_audio`
ADD COLUMN `duration` INT DEFAULT 0 COMMENT '音频时长(秒)' AFTER `file_size`;
-- 5. 调整转译任务表:关联模型配置、增加语言
ALTER TABLE `biz_transcript_task`
ADD COLUMN `model_id` INT COMMENT '使用的模型配置ID' AFTER `meeting_id`,
ADD COLUMN `language` VARCHAR(10) DEFAULT 'auto' COMMENT '识别语言',
ADD INDEX `idx_task_model` (`model_id`);
-- 6. 调整总结任务表:关联模型配置
ALTER TABLE `biz_summarize_task`
ADD COLUMN `model_id` INT COMMENT '使用的模型配置ID' AFTER `prompt_id`,
ADD INDEX `idx_sum_model` (`model_id`);
-- 7. 调整转译片段表关联音频ID
ALTER TABLE `biz_transcript_segment`
ADD COLUMN `audio_id` INT COMMENT '所属音频ID' AFTER `meeting_id`,
ADD INDEX `idx_seg_audio` (`audio_id`);

View File

@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.30.6
sqlalchemy==2.0.36
pydantic==2.9.2
pydantic-settings==2.6.1
alembic==1.13.3
python-dotenv==1.0.1
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-jose[cryptography]==3.3.0
redis==5.1.1
pymysql==1.1.1
psutil==5.9.8

View File

View File

@ -0,0 +1,56 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import hash_password
from app.models import User, Role, UserRole
def build_db_url() -> str:
settings = get_settings()
return (
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
"?charset=utf8mb4"
)
def main():
username = os.getenv("INIT_ADMIN_USERNAME", "admin")
password = os.getenv("INIT_ADMIN_PASSWORD", "123456")
engine = create_engine(build_db_url(), pool_pre_ping=True)
with Session(engine) as db:
role = db.query(Role).filter(Role.role_code == "admin").first()
if not role:
role = Role(role_code="admin", role_name="管理员")
db.add(role)
db.flush()
user = db.query(User).filter(User.username == username).first()
if not user:
user = User(
username=username,
display_name="Administrator",
email=None,
phone=None,
password_hash=hash_password(password),
status=1,
is_deleted=False,
)
db.add(user)
db.flush()
else:
user.password_hash = hash_password(password)
user.status = 1
exist_link = db.query(UserRole).filter(UserRole.user_id == user.user_id, UserRole.role_id == role.role_id).first()
if not exist_link:
db.add(UserRole(user_id=user.user_id, role_id=role.role_id))
db.commit()
print(f"Admin ready: {username}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath "/Users/jiliu/WorkSpace/nex_meeting/backend/venv")
else
# use the path as-is
export VIRTUAL_ENV="/Users/jiliu/WorkSpace/nex_meeting/backend/venv"
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="(venv) ${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="(venv) "
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV "/Users/jiliu/WorkSpace/nex_meeting/backend/venv"
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = "(venv) $prompt"
setenv VIRTUAL_ENV_PROMPT "(venv) "
endif
alias pydoc python -m pydoc
rehash

View File

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV "/Users/jiliu/WorkSpace/nex_meeting/backend/venv"
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) "(venv) " (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT "(venv) "
end

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from alembic.config import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from httpx import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from mako.cmd import cmdline
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cmdline())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import decrypt
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(decrypt())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import encrypt
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(encrypt())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import keygen
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(keygen())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.util import private_to_public
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(private_to_public())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import sign
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(sign())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from rsa.cli import verify
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(verify())

View File

@ -0,0 +1 @@
python3.12

View File

@ -0,0 +1 @@
python3.12

View File

@ -0,0 +1 @@
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,8 @@
#!/Users/jiliu/WorkSpace/nex_meeting/backend/venv/bin/python3.12
# -*- coding: utf-8 -*-
import re
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

Some files were not shown because too many files have changed in this diff Show More