Convert submodules to regular directories
parent
558fb4ad90
commit
eb465e9b04
1
backend
1
backend
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 61952852d72b8803072fa792eb61553c5986a01c
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Python相关
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# 用户上传文件(最重要!)
|
||||
uploads/
|
||||
|
||||
# 测试和开发文件
|
||||
test/
|
||||
tests/
|
||||
*.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 环境变量
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 文档
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# 其他
|
||||
.DS_Store
|
||||
*.bak
|
||||
*.tmp
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# ==================== 数据库配置 ====================
|
||||
# Docker环境使用容器名称
|
||||
DB_HOST=10.100.51.51
|
||||
DB_USER=root
|
||||
DB_PASSWORD=Unis@123
|
||||
DB_NAME=imeeting_dev
|
||||
DB_PORT=3306
|
||||
|
||||
# ==================== Redis配置 ====================
|
||||
# Docker环境使用容器名称
|
||||
REDIS_HOST=10.100.51.51
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=Unis@123
|
||||
|
||||
# ==================== API配置 ====================
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8001
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
# 应用访问地址(用于生成外部链接、二维码等)
|
||||
# 开发环境: http://localhost
|
||||
# 生产环境: https://your-domain.com
|
||||
BASE_URL=http://imeeting.unisspace.com
|
||||
|
||||
# ==================== LLM配置 ====================
|
||||
# 通义千问API密钥(请替换为实际密钥)
|
||||
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f
|
||||
|
||||
|
||||
# ==================== 转录轮询配置 ====================
|
||||
TRANSCRIPTION_POLL_INTERVAL=10
|
||||
TRANSCRIPTION_MAX_WAIT_TIME=1800
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
uploads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# 使用Python 3.9.6基础镜像
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12.9-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 使用阿里源
|
||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources
|
||||
RUN sed -i 's/security.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装系统依赖、Python依赖,然后清理(一个RUN命令减少层大小)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& pip install --index-url https://mirrors.aliyun.com/pypi/simple --no-cache-dir -r requirements.txt \
|
||||
&& apt-get purge -y gcc pkg-config \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# imetting_backend
|
||||
|
||||
”爱会议“后端代码库
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from app.core.auth import get_current_admin_user, get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.database import get_db_connection
|
||||
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo, UpdateRolePermissionsRequest, RoleInfo
|
||||
from typing import List
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ========== 菜单权限管理接口 ==========
|
||||
|
||||
@router.get("/admin/menus")
|
||||
async def get_all_menus(current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取所有菜单列表
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||
FROM menus
|
||||
ORDER BY sort_order ASC, menu_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
menus = cursor.fetchall()
|
||||
|
||||
menu_list = [MenuInfo(**menu) for menu in menus]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取菜单列表成功",
|
||||
data=MenuListResponse(menus=menu_list, total=len(menu_list))
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
|
||||
|
||||
@router.get("/admin/roles")
|
||||
async def get_all_roles(current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取所有角色列表及其权限统计
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询所有角色及其权限数量
|
||||
query = """
|
||||
SELECT r.role_id, r.role_name, r.created_at,
|
||||
COUNT(rmp.menu_id) as menu_count
|
||||
FROM roles r
|
||||
LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id
|
||||
GROUP BY r.role_id
|
||||
ORDER BY r.role_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
roles = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色列表成功",
|
||||
data={"roles": roles, "total": len(roles)}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
|
||||
|
||||
@router.get("/admin/roles/{role_id}/permissions")
|
||||
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取指定角色的菜单权限
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查角色是否存在
|
||||
cursor.execute("SELECT role_id, role_name FROM roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
if not role:
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
# 查询该角色的所有菜单权限
|
||||
query = """
|
||||
SELECT menu_id
|
||||
FROM role_menu_permissions
|
||||
WHERE role_id = %s
|
||||
"""
|
||||
cursor.execute(query, (role_id,))
|
||||
permissions = cursor.fetchall()
|
||||
|
||||
menu_ids = [p['menu_id'] for p in permissions]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色权限成功",
|
||||
data=RolePermissionInfo(
|
||||
role_id=role['role_id'],
|
||||
role_name=role['role_name'],
|
||||
menu_ids=menu_ids
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
|
||||
|
||||
@router.put("/admin/roles/{role_id}/permissions")
|
||||
async def update_role_permissions(
|
||||
role_id: int,
|
||||
request: UpdateRolePermissionsRequest,
|
||||
current_user=Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新指定角色的菜单权限
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查角色是否存在
|
||||
cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
# 验证所有menu_id是否有效
|
||||
if request.menu_ids:
|
||||
format_strings = ','.join(['%s'] * len(request.menu_ids))
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})",
|
||||
tuple(request.menu_ids)
|
||||
)
|
||||
valid_count = cursor.fetchone()['count']
|
||||
if valid_count != len(request.menu_ids):
|
||||
return create_api_response(code="400", message="包含无效的菜单ID")
|
||||
|
||||
# 删除该角色的所有现有权限
|
||||
cursor.execute("DELETE FROM role_menu_permissions WHERE role_id = %s", (role_id,))
|
||||
|
||||
# 插入新的权限
|
||||
if request.menu_ids:
|
||||
insert_values = [(role_id, menu_id) for menu_id in request.menu_ids]
|
||||
cursor.executemany(
|
||||
"INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
|
||||
insert_values
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="更新角色权限成功",
|
||||
data={"role_id": role_id, "menu_count": len(request.menu_ids)}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
|
||||
|
||||
@router.get("/menus/user")
|
||||
async def get_user_menus(current_user=Depends(get_current_user)):
|
||||
"""
|
||||
获取当前用户可访问的菜单列表(用于渲染下拉菜单)
|
||||
所有登录用户都可以访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 根据用户的role_id查询可访问的菜单
|
||||
query = """
|
||||
SELECT DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
|
||||
m.menu_url, m.menu_type, m.sort_order
|
||||
FROM menus m
|
||||
JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id
|
||||
WHERE rmp.role_id = %s AND m.is_active = 1
|
||||
ORDER BY m.sort_order ASC
|
||||
"""
|
||||
cursor.execute(query, (current_user['role_id'],))
|
||||
menus = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户菜单成功",
|
||||
data={"menus": menus}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")
|
||||
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.config import AUDIO_DIR, REDIS_CONFIG
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
import os
|
||||
import redis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Redis 客户端
|
||||
redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
# 常量定义
|
||||
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg')
|
||||
BYTES_TO_GB = 1024 ** 3
|
||||
|
||||
|
||||
def _build_status_condition(status: str) -> str:
|
||||
"""构建任务状态查询条件"""
|
||||
if status == 'running':
|
||||
return "AND (t.status = 'pending' OR t.status = 'processing')"
|
||||
elif status == 'completed':
|
||||
return "AND t.status = 'completed'"
|
||||
elif status == 'failed':
|
||||
return "AND t.status = 'failed'"
|
||||
return ""
|
||||
|
||||
|
||||
def _get_task_stats_query() -> str:
|
||||
"""获取任务统计的 SQL 查询"""
|
||||
return """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
||||
"""
|
||||
|
||||
|
||||
def _get_online_user_count(redis_client) -> int:
|
||||
"""从 Redis 获取在线用户数"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
user_ids = set()
|
||||
for key in token_keys:
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 2:
|
||||
user_ids.add(parts[1])
|
||||
return len(user_ids)
|
||||
except Exception as e:
|
||||
print(f"获取在线用户数失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_audio_storage() -> Dict[str, float]:
|
||||
"""计算音频文件存储统计"""
|
||||
audio_files_count = 0
|
||||
audio_total_size = 0
|
||||
|
||||
try:
|
||||
if os.path.exists(AUDIO_DIR):
|
||||
for root, _, files in os.walk(AUDIO_DIR):
|
||||
for file in files:
|
||||
if file.endswith(AUDIO_FILE_EXTENSIONS):
|
||||
audio_files_count += 1
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
audio_total_size += os.path.getsize(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"统计音频文件失败: {e}")
|
||||
|
||||
return {
|
||||
"audio_files_count": audio_files_count,
|
||||
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/dashboard/stats")
|
||||
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
|
||||
"""获取管理员 Dashboard 统计数据"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. 用户统计
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as total FROM users")
|
||||
total_users = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM users WHERE created_at >= %s",
|
||||
(today_start,)
|
||||
)
|
||||
today_new_users = cursor.fetchone()['count']
|
||||
|
||||
online_users = _get_online_user_count(redis_client)
|
||||
|
||||
# 2. 会议统计
|
||||
cursor.execute("SELECT COUNT(*) as total FROM meetings")
|
||||
total_meetings = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
|
||||
(today_start,)
|
||||
)
|
||||
today_new_meetings = cursor.fetchone()['count']
|
||||
|
||||
# 3. 任务统计
|
||||
task_stats_query = _get_task_stats_query()
|
||||
|
||||
# 转录任务
|
||||
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
|
||||
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 总结任务
|
||||
cursor.execute(f"{task_stats_query} FROM llm_tasks")
|
||||
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 知识库任务
|
||||
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
|
||||
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 4. 音频存储统计
|
||||
storage_stats = _calculate_audio_storage()
|
||||
|
||||
# 组装返回数据
|
||||
stats = {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"today_new": today_new_users,
|
||||
"online": online_users
|
||||
},
|
||||
"meetings": {
|
||||
"total": total_meetings,
|
||||
"today_new": today_new_meetings
|
||||
},
|
||||
"tasks": {
|
||||
"transcription": {
|
||||
"total": transcription_stats['total'] or 0,
|
||||
"running": transcription_stats['running'] or 0,
|
||||
"completed": transcription_stats['completed'] or 0,
|
||||
"failed": transcription_stats['failed'] or 0
|
||||
},
|
||||
"summary": {
|
||||
"total": summary_stats['total'] or 0,
|
||||
"running": summary_stats['running'] or 0,
|
||||
"completed": summary_stats['completed'] or 0,
|
||||
"failed": summary_stats['failed'] or 0
|
||||
},
|
||||
"knowledge_base": {
|
||||
"total": kb_stats['total'] or 0,
|
||||
"running": kb_stats['running'] or 0,
|
||||
"completed": kb_stats['completed'] or 0,
|
||||
"failed": kb_stats['failed'] or 0
|
||||
}
|
||||
},
|
||||
"storage": storage_stats
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取统计数据成功", data=stats)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取Dashboard统计数据失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/admin/online-users")
|
||||
async def get_online_users(current_user=Depends(get_current_admin_user)):
|
||||
"""获取在线用户列表"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
|
||||
# 提取用户ID并去重
|
||||
user_tokens = {}
|
||||
for key in token_keys:
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 3:
|
||||
user_id = int(parts[1])
|
||||
token = parts[2]
|
||||
if user_id not in user_tokens:
|
||||
user_tokens[user_id] = []
|
||||
user_tokens[user_id].append({'token': token, 'key': key})
|
||||
|
||||
# 查询用户信息
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
online_users_list = []
|
||||
for user_id, tokens in user_tokens.items():
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
ttl_seconds = redis_client.ttl(tokens[0]['key'])
|
||||
online_users_list.append({
|
||||
**user,
|
||||
'token_count': len(tokens),
|
||||
'ttl_seconds': ttl_seconds,
|
||||
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
|
||||
})
|
||||
|
||||
# 按用户ID排序
|
||||
online_users_list.sort(key=lambda x: x['user_id'])
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取在线用户列表成功",
|
||||
data={"users": online_users_list, "total": len(online_users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取在线用户列表失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/admin/kick-user/{user_id}")
|
||||
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
|
||||
"""踢出用户(撤销该用户的所有 token)"""
|
||||
try:
|
||||
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
|
||||
|
||||
if revoked_count > 0:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
|
||||
data={"user_id": user_id, "revoked_count": revoked_count}
|
||||
)
|
||||
else:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="该用户当前不在线或未找到 token"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"踢出用户失败: {e}")
|
||||
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/admin/tasks/monitor")
|
||||
async def monitor_tasks(
|
||||
task_type: str = Query('all', description="任务类型: all, transcription, summary, knowledge_base"),
|
||||
status: str = Query('all', description="任务状态: all, running, completed, failed"),
|
||||
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
|
||||
current_user=Depends(get_current_admin_user)
|
||||
):
|
||||
"""监控任务进度"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
tasks = []
|
||||
status_condition = _build_status_condition(status)
|
||||
|
||||
# 转录任务
|
||||
if task_type in ['all', 'transcription']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'transcription' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM transcript_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 总结任务
|
||||
if task_type in ['all', 'summary']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'summary' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
NULL as progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM llm_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 知识库任务
|
||||
if task_type in ['all', 'knowledge_base']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'knowledge_base' as task_type,
|
||||
t.kb_id as meeting_id,
|
||||
k.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
u.username as creator_name
|
||||
FROM knowledge_base_tasks t
|
||||
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
|
||||
LEFT JOIN users u ON k.creator_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 按创建时间排序并限制返回数量
|
||||
tasks.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
tasks = tasks[:limit]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取任务监控数据成功",
|
||||
data={"tasks": tasks, "total": len(tasks)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取任务监控数据失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/admin/system/resources")
|
||||
async def get_system_resources(current_user=Depends(get_current_admin_user)):
|
||||
"""获取服务器资源使用情况"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# CPU 使用率
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# 内存使用情况
|
||||
memory = psutil.virtual_memory()
|
||||
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
|
||||
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
|
||||
|
||||
# 磁盘使用情况
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
|
||||
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
|
||||
|
||||
resources = {
|
||||
"cpu": {
|
||||
"percent": cpu_percent,
|
||||
"count": cpu_count
|
||||
},
|
||||
"memory": {
|
||||
"total_gb": memory_total_gb,
|
||||
"used_gb": memory_used_gb,
|
||||
"percent": memory.percent
|
||||
},
|
||||
"disk": {
|
||||
"total_gb": disk_total_gb,
|
||||
"used_gb": disk_used_gb,
|
||||
"percent": disk.percent
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取系统资源成功", data=resources)
|
||||
|
||||
except ImportError:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message="psutil 库未安装,请运行: pip install psutil"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"获取系统资源失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/admin/user-stats")
|
||||
async def get_user_stats(current_user=Depends(get_current_admin_user)):
|
||||
"""获取用户统计列表"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
|
||||
query = """
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
(SELECT MAX(created_at) FROM user_logs
|
||||
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
|
||||
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
||||
COALESCE(SUM(af.duration), 0) as total_duration_seconds
|
||||
FROM users u
|
||||
INNER JOIN meetings m ON u.user_id = m.user_id
|
||||
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
||||
GROUP BY u.user_id, u.username, u.caption, u.created_at
|
||||
HAVING meeting_count > 0
|
||||
ORDER BY u.user_id ASC
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
users = cursor.fetchall()
|
||||
|
||||
# 格式化返回数据
|
||||
users_list = []
|
||||
for user in users:
|
||||
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
users_list.append({
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'caption': user['caption'],
|
||||
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
|
||||
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
|
||||
'meeting_count': user['meeting_count'],
|
||||
'total_duration_seconds': total_seconds,
|
||||
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
|
||||
})
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户统计成功",
|
||||
data={"users": users_list, "total": len(users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
|
||||
|
|
@ -0,0 +1,578 @@
|
|||
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, BackgroundTasks
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.config import BASE_DIR, AUDIO_DIR, TEMP_UPLOAD_DIR
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
from app.utils.audio_parser import get_audio_duration
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
# 临时上传目录 - 放在项目目录下
|
||||
TEMP_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 配置常量
|
||||
MAX_CHUNK_SIZE = 2 * 1024 * 1024 # 2MB per chunk
|
||||
MAX_TOTAL_SIZE = 500 * 1024 * 1024 # 500MB total (流式上传不受系统参数控制)
|
||||
MAX_DURATION = 3600 # 1 hour max recording
|
||||
SESSION_EXPIRE_HOURS = 1 # 会话1小时后过期
|
||||
|
||||
# 支持的音频格式
|
||||
SUPPORTED_MIME_TYPES = {
|
||||
'audio/webm;codecs=opus': '.webm',
|
||||
'audio/webm': '.webm',
|
||||
'audio/ogg;codecs=opus': '.ogg',
|
||||
'audio/mp4': '.m4a',
|
||||
'audio/mpeg': '.mp3'
|
||||
}
|
||||
|
||||
|
||||
# ============ Pydantic Models ============
|
||||
|
||||
class InitUploadRequest(BaseModel):
|
||||
meeting_id: int
|
||||
mime_type: str
|
||||
estimated_duration: Optional[int] = None # 预计时长(秒)
|
||||
|
||||
|
||||
class CompleteUploadRequest(BaseModel):
|
||||
session_id: str
|
||||
meeting_id: int
|
||||
total_chunks: int
|
||||
mime_type: str
|
||||
auto_transcribe: bool = True
|
||||
auto_summarize: bool = True
|
||||
prompt_id: Optional[int] = None # 提示词模版ID(可选)
|
||||
|
||||
|
||||
class CancelUploadRequest(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
# ============ 工具函数 ============
|
||||
|
||||
def validate_session_id(session_id: str) -> str:
|
||||
"""验证session_id格式,防止路径注入攻击"""
|
||||
if not re.match(r'^sess_\d+_[a-zA-Z0-9]+$', session_id):
|
||||
raise ValueError("Invalid session_id format")
|
||||
return session_id
|
||||
|
||||
|
||||
def validate_mime_type(mime_type: str) -> str:
|
||||
"""验证MIME类型是否支持"""
|
||||
if mime_type not in SUPPORTED_MIME_TYPES:
|
||||
raise ValueError(f"Unsupported MIME type: {mime_type}")
|
||||
return SUPPORTED_MIME_TYPES[mime_type]
|
||||
|
||||
|
||||
def get_session_dir(session_id: str) -> Path:
|
||||
"""获取会话目录路径"""
|
||||
validate_session_id(session_id)
|
||||
return TEMP_UPLOAD_DIR / session_id
|
||||
|
||||
|
||||
def get_session_metadata_path(session_id: str) -> Path:
|
||||
"""获取会话metadata文件路径"""
|
||||
return get_session_dir(session_id) / "metadata.json"
|
||||
|
||||
|
||||
def create_session_metadata(session_id: str, meeting_id: int, mime_type: str, user_id: int) -> dict:
|
||||
"""创建会话metadata"""
|
||||
now = datetime.now()
|
||||
expires_at = now + timedelta(hours=SESSION_EXPIRE_HOURS)
|
||||
|
||||
metadata = {
|
||||
"session_id": session_id,
|
||||
"meeting_id": meeting_id,
|
||||
"user_id": user_id,
|
||||
"mime_type": mime_type,
|
||||
"total_chunks": None,
|
||||
"received_chunks": [],
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat()
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def save_session_metadata(session_id: str, metadata: dict):
|
||||
"""保存会话metadata"""
|
||||
metadata_path = get_session_metadata_path(session_id)
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_session_metadata(session_id: str) -> dict:
|
||||
"""加载会话metadata"""
|
||||
metadata_path = get_session_metadata_path(session_id)
|
||||
if not metadata_path.exists():
|
||||
raise FileNotFoundError(f"Session {session_id} not found")
|
||||
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def update_session_chunks(session_id: str, chunk_index: int):
|
||||
"""更新已接收的分片列表"""
|
||||
metadata = load_session_metadata(session_id)
|
||||
|
||||
if chunk_index not in metadata['received_chunks']:
|
||||
metadata['received_chunks'].append(chunk_index)
|
||||
metadata['received_chunks'].sort()
|
||||
|
||||
save_session_metadata(session_id, metadata)
|
||||
|
||||
|
||||
def get_session_total_size(session_id: str) -> int:
|
||||
"""获取会话已上传的总大小"""
|
||||
session_dir = get_session_dir(session_id)
|
||||
total_size = 0
|
||||
|
||||
if session_dir.exists():
|
||||
for chunk_file in session_dir.glob("chunk_*.webm"):
|
||||
total_size += chunk_file.stat().st_size
|
||||
|
||||
return total_size
|
||||
|
||||
|
||||
def merge_audio_chunks(session_id: str, meeting_id: int, total_chunks: int, mime_type: str) -> str:
|
||||
"""合并音频分片"""
|
||||
session_dir = get_session_dir(session_id)
|
||||
|
||||
# 1. 验证分片完整性
|
||||
missing = []
|
||||
for i in range(total_chunks):
|
||||
chunk_path = session_dir / f"chunk_{i:04d}.webm"
|
||||
if not chunk_path.exists():
|
||||
missing.append(i)
|
||||
|
||||
if missing:
|
||||
raise ValueError(f"Missing chunks: {missing}")
|
||||
|
||||
# 2. 创建输出目录
|
||||
meeting_audio_dir = AUDIO_DIR / str(meeting_id)
|
||||
meeting_audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 3. 生成输出文件名
|
||||
file_extension = validate_mime_type(mime_type)
|
||||
output_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
output_path = meeting_audio_dir / output_filename
|
||||
|
||||
# 4. 按序合并分片
|
||||
with open(output_path, 'wb') as outfile:
|
||||
for i in range(total_chunks):
|
||||
chunk_path = session_dir / f"chunk_{i:04d}.webm"
|
||||
with open(chunk_path, 'rb') as infile:
|
||||
outfile.write(infile.read())
|
||||
|
||||
# 5. 清理临时文件
|
||||
shutil.rmtree(session_dir)
|
||||
|
||||
# 返回相对路径
|
||||
return f"/{output_path.relative_to(BASE_DIR)}"
|
||||
|
||||
|
||||
def cleanup_session(session_id: str):
|
||||
"""清理会话文件"""
|
||||
session_dir = get_session_dir(session_id)
|
||||
if session_dir.exists():
|
||||
shutil.rmtree(session_dir)
|
||||
|
||||
|
||||
def cleanup_expired_sessions():
|
||||
"""清理过期的会话(可以由定时任务调用)"""
|
||||
now = datetime.now()
|
||||
cleaned_count = 0
|
||||
|
||||
if not TEMP_UPLOAD_DIR.exists():
|
||||
return cleaned_count
|
||||
|
||||
for session_dir in TEMP_UPLOAD_DIR.iterdir():
|
||||
if not session_dir.is_dir():
|
||||
continue
|
||||
|
||||
metadata_path = session_dir / "metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
expires_at = datetime.fromisoformat(metadata['expires_at'])
|
||||
if now > expires_at:
|
||||
shutil.rmtree(session_dir)
|
||||
cleaned_count += 1
|
||||
print(f"Cleaned up expired session: {session_dir.name}")
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up session {session_dir.name}: {e}")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
@router.post("/audio/stream/init")
|
||||
async def init_upload_session(
|
||||
request: InitUploadRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
初始化音频流式上传会话
|
||||
|
||||
创建临时目录,生成session_id,返回给客户端用于后续分片上传
|
||||
"""
|
||||
try:
|
||||
# 1. 验证会议是否存在且属于当前用户
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT user_id FROM meetings WHERE meeting_id = %s",
|
||||
(request.meeting_id,)
|
||||
)
|
||||
meeting = cursor.fetchone()
|
||||
|
||||
if not meeting:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="会议不存在"
|
||||
)
|
||||
|
||||
if meeting['user_id'] != current_user['user_id']:
|
||||
return create_api_response(
|
||||
code="403",
|
||||
message="无权限操作此会议"
|
||||
)
|
||||
|
||||
# 2. 验证MIME类型
|
||||
try:
|
||||
validate_mime_type(request.mime_type)
|
||||
except ValueError as e:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
# 3. 生成session_id
|
||||
timestamp = int(datetime.now().timestamp() * 1000)
|
||||
random_str = uuid.uuid4().hex[:8]
|
||||
session_id = f"sess_{timestamp}_{random_str}"
|
||||
|
||||
# 4. 创建会话目录
|
||||
session_dir = get_session_dir(session_id)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 5. 创建并保存metadata
|
||||
metadata = create_session_metadata(
|
||||
session_id=session_id,
|
||||
meeting_id=request.meeting_id,
|
||||
mime_type=request.mime_type,
|
||||
user_id=current_user['user_id']
|
||||
)
|
||||
save_session_metadata(session_id, metadata)
|
||||
|
||||
# 6. 清理过期会话
|
||||
cleanup_expired_sessions()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="上传会话初始化成功",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_size": MAX_CHUNK_SIZE,
|
||||
"max_chunks": 1000
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error initializing upload session: {e}")
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"初始化上传会话失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/audio/stream/chunk")
|
||||
async def upload_audio_chunk(
|
||||
session_id: str = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
chunk: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
上传音频分片
|
||||
|
||||
接收并保存音频分片文件
|
||||
"""
|
||||
try:
|
||||
# 1. 验证session_id格式
|
||||
try:
|
||||
validate_session_id(session_id)
|
||||
except ValueError:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="Invalid session_id format"
|
||||
)
|
||||
|
||||
# 2. 加载session metadata
|
||||
try:
|
||||
metadata = load_session_metadata(session_id)
|
||||
except FileNotFoundError:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="Session not found"
|
||||
)
|
||||
|
||||
# 3. 验证会话所有权
|
||||
if metadata['user_id'] != current_user['user_id']:
|
||||
return create_api_response(
|
||||
code="403",
|
||||
message="Permission denied"
|
||||
)
|
||||
|
||||
# 4. 验证分片大小
|
||||
chunk_data = await chunk.read()
|
||||
if len(chunk_data) > MAX_CHUNK_SIZE:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"Chunk size exceeds {MAX_CHUNK_SIZE // (1024*1024)}MB limit"
|
||||
)
|
||||
|
||||
# 5. 验证总大小
|
||||
session_total = get_session_total_size(session_id)
|
||||
if session_total + len(chunk_data) > MAX_TOTAL_SIZE:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"Total size exceeds {MAX_TOTAL_SIZE // (1024*1024)}MB limit"
|
||||
)
|
||||
|
||||
# 6. 保存分片文件
|
||||
session_dir = get_session_dir(session_id)
|
||||
chunk_path = session_dir / f"chunk_{chunk_index:04d}.webm"
|
||||
|
||||
with open(chunk_path, 'wb') as f:
|
||||
f.write(chunk_data)
|
||||
|
||||
# 7. 更新metadata
|
||||
update_session_chunks(session_id, chunk_index)
|
||||
|
||||
# 8. 获取已接收分片总数
|
||||
metadata = load_session_metadata(session_id)
|
||||
total_received = len(metadata['received_chunks'])
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="分片上传成功",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_index": chunk_index,
|
||||
"received": True,
|
||||
"total_received": total_received
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error uploading chunk: {e}")
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"分片上传失败: {str(e)}",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_index": chunk_index,
|
||||
"should_retry": True
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/audio/stream/complete")
|
||||
async def complete_upload(
|
||||
request: CompleteUploadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
完成上传并合并分片
|
||||
|
||||
验证分片完整性,合并所有分片,保存最终音频文件,可选启动转录任务和自动总结
|
||||
"""
|
||||
try:
|
||||
# 1. 验证session_id
|
||||
try:
|
||||
validate_session_id(request.session_id)
|
||||
except ValueError:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="Invalid session_id format"
|
||||
)
|
||||
|
||||
# 2. 加载session metadata
|
||||
try:
|
||||
metadata = load_session_metadata(request.session_id)
|
||||
except FileNotFoundError:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="Session not found"
|
||||
)
|
||||
|
||||
# 3. 验证会话所有权
|
||||
if metadata['user_id'] != current_user['user_id']:
|
||||
return create_api_response(
|
||||
code="403",
|
||||
message="Permission denied"
|
||||
)
|
||||
|
||||
# 4. 验证会议ID一致性
|
||||
if metadata['meeting_id'] != request.meeting_id:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="Meeting ID mismatch"
|
||||
)
|
||||
|
||||
# 5. 合并音频分片
|
||||
try:
|
||||
file_path = merge_audio_chunks(
|
||||
session_id=request.session_id,
|
||||
meeting_id=request.meeting_id,
|
||||
total_chunks=request.total_chunks,
|
||||
mime_type=request.mime_type
|
||||
)
|
||||
except ValueError as e:
|
||||
# 分片不完整
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"音频合并失败:{str(e)}",
|
||||
data={
|
||||
"should_retry": True
|
||||
}
|
||||
)
|
||||
|
||||
# 6. 获取文件信息
|
||||
full_path = BASE_DIR / file_path.lstrip('/')
|
||||
file_size = full_path.stat().st_size
|
||||
file_name = full_path.name
|
||||
|
||||
# 6.5 获取音频时长
|
||||
audio_duration = 0
|
||||
try:
|
||||
audio_duration = get_audio_duration(str(full_path))
|
||||
print(f"音频时长: {audio_duration}秒")
|
||||
except Exception as e:
|
||||
print(f"警告: 获取音频时长失败,但不影响后续流程: {e}")
|
||||
|
||||
# 7. 调用 audio_service 处理文件(数据库更新、启动转录和总结)
|
||||
result = handle_audio_upload(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
meeting_id=request.meeting_id,
|
||||
current_user=current_user,
|
||||
auto_summarize=request.auto_summarize,
|
||||
background_tasks=background_tasks,
|
||||
prompt_id=request.prompt_id, # 传递提示词模版ID
|
||||
duration=audio_duration # 传递时长参数
|
||||
)
|
||||
|
||||
# 如果处理失败,返回错误
|
||||
if not result["success"]:
|
||||
return result["response"]
|
||||
|
||||
# 8. 返回成功响应
|
||||
transcription_task_id = result["transcription_task_id"]
|
||||
message_suffix = ""
|
||||
if transcription_task_id:
|
||||
if request.auto_summarize:
|
||||
message_suffix = ",正在进行转录和总结"
|
||||
else:
|
||||
message_suffix = ",正在进行转录"
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="音频上传完成" + message_suffix,
|
||||
data={
|
||||
"meeting_id": request.meeting_id,
|
||||
"file_path": file_path,
|
||||
"file_size": file_size,
|
||||
"duration": audio_duration,
|
||||
"task_id": transcription_task_id,
|
||||
"task_status": "pending" if transcription_task_id else None,
|
||||
"auto_summarize": request.auto_summarize
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error completing upload: {e}")
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"完成上传失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/audio/stream/cancel")
|
||||
async def cancel_upload(
|
||||
request: CancelUploadRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
取消上传会话
|
||||
|
||||
清理会话临时文件和目录
|
||||
"""
|
||||
try:
|
||||
# 1. 验证session_id
|
||||
try:
|
||||
validate_session_id(request.session_id)
|
||||
except ValueError:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="Invalid session_id format"
|
||||
)
|
||||
|
||||
# 2. 加载session metadata(验证所有权)
|
||||
try:
|
||||
metadata = load_session_metadata(request.session_id)
|
||||
|
||||
# 验证会话所有权
|
||||
if metadata['user_id'] != current_user['user_id']:
|
||||
return create_api_response(
|
||||
code="403",
|
||||
message="Permission denied"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# 会话不存在,视为已清理
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="上传会话已取消",
|
||||
data={
|
||||
"session_id": request.session_id,
|
||||
"cleaned": True
|
||||
}
|
||||
)
|
||||
|
||||
# 3. 清理会话文件
|
||||
cleanup_session(request.session_id)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="上传会话已取消",
|
||||
data={
|
||||
"session_id": request.session_id,
|
||||
"cleaned": True
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error canceling upload: {e}")
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"取消上传失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import hashlib
|
||||
from typing import Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db_connection
|
||||
from app.models.models import LoginRequest, LoginResponse
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.response import create_api_response
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
@router.post("/auth/login")
|
||||
def login(request_body: LoginRequest, request: Request):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = "SELECT user_id, username, caption, email, password_hash, role_id FROM users WHERE username = %s"
|
||||
cursor.execute(query, (request_body.username,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return create_api_response(code="401", message="用户名或密码错误")
|
||||
|
||||
hashed_input = hash_password(request_body.password)
|
||||
if user['password_hash'] != hashed_input:
|
||||
return create_api_response(code="401", message="用户名或密码错误")
|
||||
|
||||
# 创建JWT token
|
||||
token_data = {
|
||||
"user_id": user['user_id'],
|
||||
"username": user['username'],
|
||||
"caption": user['caption'],
|
||||
"role_id": user['role_id']
|
||||
}
|
||||
token = jwt_service.create_access_token(token_data)
|
||||
|
||||
# 记录登录日志
|
||||
try:
|
||||
# 获取客户端IP地址(考虑代理)
|
||||
client_ip = request.client.host if request.client else None
|
||||
if "x-forwarded-for" in request.headers:
|
||||
client_ip = request.headers["x-forwarded-for"].split(",")[0].strip()
|
||||
elif "x-real-ip" in request.headers:
|
||||
client_ip = request.headers["x-real-ip"]
|
||||
|
||||
# 获取User-Agent
|
||||
user_agent = request.headers.get("user-agent", None)
|
||||
|
||||
# 插入登录日志
|
||||
log_query = """
|
||||
INSERT INTO user_logs (user_id, action_type, ip_address, user_agent)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(log_query, (user['user_id'], 'login', client_ip, user_agent))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
# 日志记录失败不影响登录流程
|
||||
print(f"Failed to log user login: {e}")
|
||||
|
||||
login_response_data = LoginResponse(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
caption=user['caption'],
|
||||
email=user['email'],
|
||||
token=token,
|
||||
role_id=user['role_id']
|
||||
)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="登录成功",
|
||||
data=login_response_data.dict()
|
||||
)
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def logout(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""登出接口,撤销当前token"""
|
||||
token = credentials.credentials
|
||||
|
||||
payload = jwt_service.verify_token(token)
|
||||
if not payload:
|
||||
return create_api_response(code="401", message="无效或过期的token")
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return create_api_response(code="401", message="无效的token payload")
|
||||
|
||||
revoked = jwt_service.revoke_token(token, user_id)
|
||||
|
||||
if revoked:
|
||||
return create_api_response(code="200", message="登出成功")
|
||||
else:
|
||||
return create_api_response(code="400", message="已经登出或token未找到")
|
||||
|
||||
|
||||
@router.post("/auth/logout-all")
|
||||
def logout_all(current_user: dict = Depends(get_current_user)):
|
||||
"""登出所有设备"""
|
||||
user_id = current_user["user_id"]
|
||||
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
|
||||
return create_api_response(code="200", message=f"从 {revoked_count} 个设备登出")
|
||||
|
||||
|
||||
@router.post("/auth/admin/revoke-user-tokens/{user_id}")
|
||||
def admin_revoke_user_tokens(
|
||||
user_id: int, credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""管理员功能:撤销指定用户的所有token"""
|
||||
token = credentials.credentials
|
||||
|
||||
payload = jwt_service.verify_token(token)
|
||||
if not payload:
|
||||
return create_api_response(code="401", message="无效或过期的token")
|
||||
|
||||
admin_user_id = payload.get("user_id")
|
||||
if not admin_user_id:
|
||||
return create_api_response(code="401", message="无效的token payload")
|
||||
|
||||
# 这里可以添加管理员权限检查,目前暂时允许任何登录用户操作
|
||||
# if payload.get('role_id') != ADMIN_ROLE_ID:
|
||||
# return create_api_response(code="403", message="需要管理员权限")
|
||||
|
||||
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
|
||||
return create_api_response(
|
||||
code="200", message=f"为用户 {user_id} 撤销了 {revoked_count} 个token"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth/me")
|
||||
def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return create_api_response(code="200", message="获取用户信息成功", data=current_user)
|
||||
|
||||
|
||||
@router.post("/auth/refresh")
|
||||
def refresh_token(current_user: dict = Depends(get_current_user)):
|
||||
"""刷新token"""
|
||||
token_data = {
|
||||
"user_id": current_user["user_id"],
|
||||
"username": current_user["username"],
|
||||
"caption": current_user["caption"],
|
||||
"role_id": current_user["role_id"],
|
||||
}
|
||||
new_token = jwt_service.create_access_token(token_data)
|
||||
return create_api_response(
|
||||
code="200", message="Token刷新成功", data={"token": new_token}
|
||||
)
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form
|
||||
from app.models.models import (
|
||||
ClientDownload,
|
||||
CreateClientDownloadRequest,
|
||||
UpdateClientDownloadRequest,
|
||||
ClientDownloadListResponse
|
||||
)
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user, get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import CLIENT_DIR, ALLOWED_CLIENT_EXTENSIONS, MAX_CLIENT_SIZE, APP_CONFIG
|
||||
from app.utils.apk_parser import parse_apk_with_androguard
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/clients", response_model=dict)
|
||||
async def get_client_downloads(
|
||||
platform_code: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
size: int = 50
|
||||
):
|
||||
"""
|
||||
获取客户端下载列表(管理后台接口)
|
||||
|
||||
参数:
|
||||
platform_code: 平台编码(如 WIN, MAC, ANDROID等)
|
||||
is_active: 是否启用
|
||||
page: 页码
|
||||
size: 每页数量
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if platform_code:
|
||||
where_clauses.append("platform_code = %s")
|
||||
params.append(platform_code)
|
||||
|
||||
if is_active is not None:
|
||||
where_clauses.append("is_active = %s")
|
||||
params.append(is_active)
|
||||
|
||||
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# 获取总数
|
||||
count_query = f"SELECT COUNT(*) as total FROM client_downloads WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 获取列表数据 - 按 platform_code 和版本号排序
|
||||
offset = (page - 1) * size
|
||||
list_query = f"""
|
||||
SELECT * FROM client_downloads
|
||||
WHERE {where_clause}
|
||||
ORDER BY platform_code, version_code DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cursor.execute(list_query, params + [size, offset])
|
||||
clients = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"clients": clients,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取客户端下载列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients/latest", response_model=dict)
|
||||
async def get_latest_clients():
|
||||
"""
|
||||
获取所有平台的最新版本客户端(公开接口,用于首页下载)
|
||||
|
||||
返回按平台类型分组的最新客户端,包含平台的中英文名称
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 关联 dict_data 获取平台信息
|
||||
query = """
|
||||
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
||||
FROM client_downloads cd
|
||||
LEFT JOIN dict_data dd ON cd.platform_code = dd.dict_code
|
||||
AND dd.dict_type = 'client_platform'
|
||||
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
||||
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
||||
"""
|
||||
cursor.execute(query)
|
||||
clients = cursor.fetchall()
|
||||
|
||||
# 处理 JSON 字段
|
||||
for client in clients:
|
||||
if client.get('extension_attr'):
|
||||
try:
|
||||
import json
|
||||
client['extension_attr'] = json.loads(client['extension_attr'])
|
||||
except:
|
||||
client['extension_attr'] = {}
|
||||
|
||||
cursor.close()
|
||||
|
||||
# 按 parent_code 分组
|
||||
mobile_clients = []
|
||||
desktop_clients = []
|
||||
terminal_clients = []
|
||||
|
||||
for client in clients:
|
||||
parent_code = client.get('parent_code', '').upper()
|
||||
if parent_code == 'MOBILE':
|
||||
mobile_clients.append(client)
|
||||
elif parent_code == 'DESKTOP':
|
||||
desktop_clients.append(client)
|
||||
elif parent_code == 'TERMINAL':
|
||||
terminal_clients.append(client)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"mobile": mobile_clients,
|
||||
"desktop": desktop_clients,
|
||||
"terminal": terminal_clients
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取最新客户端失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients/latest/by-platform", response_model=dict)
|
||||
async def get_latest_version_by_code(
|
||||
platform_type: Optional[str] = None,
|
||||
platform_name: Optional[str] = None,
|
||||
platform_code: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取最新版本客户端(公开接口,用于客户端版本检查)
|
||||
|
||||
支持两种调用方式:
|
||||
1. 旧版方式:传 platform_type 和 platform_name(兼容已发布的终端)
|
||||
2. 新版方式:传 platform_code(推荐使用)
|
||||
|
||||
参数:
|
||||
platform_type: 平台类型 (mobile, desktop, terminal) - 旧版参数
|
||||
platform_name: 具体平台 (ios, android, windows等) - 旧版参数
|
||||
platform_code: 平台编码 (WIN, MAC, LINUX, IOS, ANDROID等) - 新版参数
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 优先使用 platform_code(新版)
|
||||
if platform_code:
|
||||
query = """
|
||||
SELECT * FROM client_downloads
|
||||
WHERE platform_code = %s
|
||||
AND is_active = TRUE
|
||||
AND is_latest = TRUE
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (platform_code,))
|
||||
client = cursor.fetchone()
|
||||
|
||||
if not client:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message=f"未找到平台编码 {platform_code} 的客户端"
|
||||
)
|
||||
|
||||
# 使用 platform_type 和 platform_name(旧版,兼容)
|
||||
elif platform_type and platform_name:
|
||||
query = """
|
||||
SELECT * FROM client_downloads
|
||||
WHERE platform_type = %s
|
||||
AND platform_name = %s
|
||||
AND is_active = TRUE
|
||||
AND is_latest = TRUE
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (platform_type, platform_name))
|
||||
client = cursor.fetchone()
|
||||
|
||||
if not client:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message=f"未找到平台类型 {platform_type} 下的 {platform_name} 客户端"
|
||||
)
|
||||
|
||||
else:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="请提供 platform_code 参数"
|
||||
)
|
||||
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data=client
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取客户端版本失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients/{id}", response_model=dict)
|
||||
async def get_client_download_by_id(id: int):
|
||||
"""
|
||||
获取指定ID的客户端详情(公开接口)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = "SELECT * FROM client_downloads WHERE id = %s"
|
||||
cursor.execute(query, (id,))
|
||||
client = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not client:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="客户端不存在"
|
||||
)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data=client
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取客户端详情失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/clients", response_model=dict)
|
||||
async def create_client_download(
|
||||
request: CreateClientDownloadRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建新的客户端版本(仅管理员)
|
||||
|
||||
注意: platform_type 和 platform_name 为兼容字段,可不传
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 如果设置为最新版本,先将同平台的其他版本设为非最新
|
||||
if request.is_latest:
|
||||
update_query = """
|
||||
UPDATE client_downloads
|
||||
SET is_latest = FALSE
|
||||
WHERE platform_code = %s
|
||||
"""
|
||||
cursor.execute(update_query, (request.platform_code,))
|
||||
|
||||
# 插入新版本 - platform_type 和 platform_name 允许为 NULL
|
||||
insert_query = """
|
||||
INSERT INTO client_downloads (
|
||||
platform_type, platform_name, platform_code,
|
||||
version, version_code, download_url, file_size,
|
||||
release_notes, is_active, is_latest, min_system_version,
|
||||
created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(insert_query, (
|
||||
request.platform_type, # 可为 None
|
||||
request.platform_name, # 可为 None
|
||||
request.platform_code, # 必填
|
||||
request.version,
|
||||
request.version_code,
|
||||
request.download_url,
|
||||
request.file_size,
|
||||
request.release_notes,
|
||||
request.is_active,
|
||||
request.is_latest,
|
||||
request.min_system_version,
|
||||
current_user['user_id']
|
||||
))
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="客户端版本创建成功",
|
||||
data={"id": new_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"创建客户端版本失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/clients/{id}", response_model=dict)
|
||||
async def update_client_download(
|
||||
id: int,
|
||||
request: UpdateClientDownloadRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新客户端版本信息(仅管理员)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查客户端是否存在
|
||||
cursor.execute("SELECT * FROM client_downloads WHERE id = %s", (id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="客户端不存在"
|
||||
)
|
||||
|
||||
# 如果设置为最新版本,先将同平台的其他版本设为非最新
|
||||
if request.is_latest:
|
||||
# 使用 platform_code (如果有更新) 或现有的 platform_code
|
||||
platform_code = request.platform_code if request.platform_code else existing['platform_code']
|
||||
update_query = """
|
||||
UPDATE client_downloads
|
||||
SET is_latest = FALSE
|
||||
WHERE platform_code = %s AND id != %s
|
||||
"""
|
||||
cursor.execute(update_query, (platform_code, id))
|
||||
|
||||
# 构建更新语句
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if request.platform_type is not None:
|
||||
update_fields.append("platform_type = %s")
|
||||
params.append(request.platform_type)
|
||||
|
||||
if request.platform_name is not None:
|
||||
update_fields.append("platform_name = %s")
|
||||
params.append(request.platform_name)
|
||||
|
||||
if request.platform_code is not None:
|
||||
update_fields.append("platform_code = %s")
|
||||
params.append(request.platform_code)
|
||||
|
||||
if request.version is not None:
|
||||
update_fields.append("version = %s")
|
||||
params.append(request.version)
|
||||
|
||||
if request.version_code is not None:
|
||||
update_fields.append("version_code = %s")
|
||||
params.append(request.version_code)
|
||||
|
||||
if request.download_url is not None:
|
||||
update_fields.append("download_url = %s")
|
||||
params.append(request.download_url)
|
||||
|
||||
if request.file_size is not None:
|
||||
update_fields.append("file_size = %s")
|
||||
params.append(request.file_size)
|
||||
|
||||
if request.release_notes is not None:
|
||||
update_fields.append("release_notes = %s")
|
||||
params.append(request.release_notes)
|
||||
|
||||
if request.is_active is not None:
|
||||
update_fields.append("is_active = %s")
|
||||
params.append(request.is_active)
|
||||
|
||||
if request.is_latest is not None:
|
||||
update_fields.append("is_latest = %s")
|
||||
params.append(request.is_latest)
|
||||
|
||||
if request.min_system_version is not None:
|
||||
update_fields.append("min_system_version = %s")
|
||||
params.append(request.min_system_version)
|
||||
|
||||
if not update_fields:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="没有要更新的字段"
|
||||
)
|
||||
|
||||
# 执行更新
|
||||
update_query = f"""
|
||||
UPDATE client_downloads
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = %s
|
||||
"""
|
||||
params.append(id)
|
||||
cursor.execute(update_query, params)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="客户端版本更新成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"更新客户端版本失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/clients/{id}", response_model=dict)
|
||||
async def delete_client_download(
|
||||
id: int,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除客户端版本(仅管理员)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查是否存在
|
||||
cursor.execute("SELECT * FROM client_downloads WHERE id = %s", (id,))
|
||||
if not cursor.fetchone():
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="客户端不存在"
|
||||
)
|
||||
|
||||
# 执行删除
|
||||
cursor.execute("DELETE FROM client_downloads WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="客户端版本删除成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"删除客户端版本失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/clients/upload", response_model=dict)
|
||||
async def upload_client_installer(
|
||||
platform_code: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
上传客户端安装包(仅管理员)
|
||||
|
||||
参数:
|
||||
platform_code: 平台编码(如 WIN, MAC, ANDROID等)
|
||||
file: 安装包文件
|
||||
|
||||
返回:
|
||||
文件信息,包括文件大小、下载URL,以及APK的版本信息(如果是APK)
|
||||
"""
|
||||
try:
|
||||
# 验证文件扩展名
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
if file_ext not in ALLOWED_CLIENT_EXTENSIONS:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"不支持的文件类型: {file_ext}。支持的类型: {', '.join(ALLOWED_CLIENT_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# 创建平台目录
|
||||
platform_dir = CLIENT_DIR / platform_code
|
||||
platform_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名(保留原始文件名)
|
||||
file_path = platform_dir / file.filename
|
||||
|
||||
# 检查文件大小
|
||||
file.file.seek(0, 2) # 移动到文件末尾
|
||||
file_size = file.file.tell() # 获取文件大小
|
||||
file.file.seek(0) # 移回文件开头
|
||||
|
||||
if file_size > MAX_CLIENT_SIZE:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"文件过大,最大允许 {MAX_CLIENT_SIZE / 1024 / 1024} MB"
|
||||
)
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# 构建下载URL
|
||||
base_url = APP_CONFIG['base_url'].rstrip('/')
|
||||
download_url = f"{base_url}/uploads/clients/{platform_code}/{file.filename}"
|
||||
|
||||
# 准备返回数据
|
||||
result = {
|
||||
"file_name": file.filename,
|
||||
"file_size": file_size,
|
||||
"download_url": download_url,
|
||||
"platform_code": platform_code
|
||||
}
|
||||
|
||||
# 如果是APK文件,尝试解析版本信息
|
||||
if file_ext == '.apk':
|
||||
apk_info = parse_apk_with_androguard(str(file_path))
|
||||
if apk_info:
|
||||
result['version_code'] = apk_info.get('version_code')
|
||||
result['version_name'] = apk_info.get('version_name')
|
||||
result['note'] = apk_info.get('note', '')
|
||||
else:
|
||||
# APK解析失败,给出提示
|
||||
result['note'] = 'APK解析失败,请检查后台日志。您可以手动输入版本信息。'
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="文件上传成功",
|
||||
data=result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"文件上传失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user, get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DictDataItem(BaseModel):
|
||||
"""码表数据项"""
|
||||
id: int
|
||||
dict_type: str
|
||||
dict_code: str
|
||||
parent_code: str
|
||||
tree_path: Optional[str] = None
|
||||
label_cn: str
|
||||
label_en: Optional[str] = None
|
||||
sort_order: int
|
||||
extension_attr: Optional[dict] = None
|
||||
is_default: int
|
||||
status: int
|
||||
create_time: str
|
||||
|
||||
|
||||
class CreateDictDataRequest(BaseModel):
|
||||
"""创建码表数据请求"""
|
||||
dict_type: str = "client_platform"
|
||||
dict_code: str
|
||||
parent_code: str = "ROOT"
|
||||
label_cn: str
|
||||
label_en: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
extension_attr: Optional[dict] = None
|
||||
is_default: int = 0
|
||||
status: int = 1
|
||||
|
||||
|
||||
class UpdateDictDataRequest(BaseModel):
|
||||
"""更新码表数据请求"""
|
||||
parent_code: Optional[str] = None
|
||||
label_cn: Optional[str] = None
|
||||
label_en: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
extension_attr: Optional[dict] = None
|
||||
is_default: Optional[int] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/dict/types", response_model=dict)
|
||||
async def get_dict_types():
|
||||
"""
|
||||
获取所有字典类型(公开接口)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT DISTINCT dict_type
|
||||
FROM dict_data
|
||||
WHERE status = 1
|
||||
ORDER BY dict_type
|
||||
"""
|
||||
cursor.execute(query)
|
||||
types = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={"types": [t['dict_type'] for t in types]}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取字典类型失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dict/{dict_type}", response_model=dict)
|
||||
async def get_dict_data_by_type(dict_type: str):
|
||||
"""
|
||||
获取指定类型的所有码表数据(公开接口)
|
||||
支持树形结构
|
||||
|
||||
参数:
|
||||
dict_type: 字典类型,如 'client_platform'
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||
label_cn, label_en, sort_order, extension_attr,
|
||||
is_default, status, create_time
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND status = 1
|
||||
ORDER BY parent_code, sort_order, dict_code
|
||||
"""
|
||||
cursor.execute(query, (dict_type,))
|
||||
items = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
# 处理JSON字段
|
||||
for item in items:
|
||||
if item.get('extension_attr'):
|
||||
try:
|
||||
item['extension_attr'] = json.loads(item['extension_attr'])
|
||||
except:
|
||||
item['extension_attr'] = {}
|
||||
|
||||
# 构建树形结构
|
||||
tree_data = []
|
||||
nodes_map = {}
|
||||
|
||||
# 第一遍:创建所有节点
|
||||
for item in items:
|
||||
nodes_map[item['dict_code']] = {
|
||||
**item,
|
||||
'children': []
|
||||
}
|
||||
|
||||
# 第二遍:构建树形关系
|
||||
for item in items:
|
||||
node = nodes_map[item['dict_code']]
|
||||
parent_code = item['parent_code']
|
||||
|
||||
if parent_code == 'ROOT':
|
||||
tree_data.append(node)
|
||||
elif parent_code in nodes_map:
|
||||
nodes_map[parent_code]['children'].append(node)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"items": items, # 平铺数据
|
||||
"tree": tree_data # 树形数据
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取码表数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dict/{dict_type}/{dict_code}", response_model=dict)
|
||||
async def get_dict_data_by_code(dict_type: str, dict_code: str):
|
||||
"""
|
||||
获取指定编码的码表数据(公开接口)
|
||||
|
||||
参数:
|
||||
dict_type: 字典类型
|
||||
dict_code: 字典编码
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||
label_cn, label_en, sort_order, extension_attr,
|
||||
is_default, status, create_time, update_time
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND dict_code = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (dict_type, dict_code))
|
||||
item = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not item:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message=f"未找到编码 {dict_code} 的数据"
|
||||
)
|
||||
|
||||
# 处理JSON字段
|
||||
if item.get('extension_attr'):
|
||||
try:
|
||||
item['extension_attr'] = json.loads(item['extension_attr'])
|
||||
except:
|
||||
item['extension_attr'] = {}
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data=item
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取码表数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dict", response_model=dict)
|
||||
async def create_dict_data(
|
||||
request: CreateDictDataRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建码表数据(仅管理员)
|
||||
"""
|
||||
try:
|
||||
# 验证extension_attr的JSON格式
|
||||
if request.extension_attr:
|
||||
try:
|
||||
# 尝试序列化,验证是否为有效的JSON对象
|
||||
json.dumps(request.extension_attr)
|
||||
except (TypeError, ValueError) as e:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"扩展属性格式错误,必须是有效的JSON对象: {str(e)}"
|
||||
)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查是否已存在
|
||||
cursor.execute(
|
||||
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
|
||||
(request.dict_type, request.dict_code)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"编码 {request.dict_code} 已存在"
|
||||
)
|
||||
|
||||
# 插入数据
|
||||
query = """
|
||||
INSERT INTO dict_data (
|
||||
dict_type, dict_code, parent_code, label_cn, label_en,
|
||||
sort_order, extension_attr, is_default, status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
extension_json = json.dumps(request.extension_attr, ensure_ascii=False) if request.extension_attr else None
|
||||
|
||||
cursor.execute(query, (
|
||||
request.dict_type,
|
||||
request.dict_code,
|
||||
request.parent_code,
|
||||
request.label_cn,
|
||||
request.label_en,
|
||||
request.sort_order,
|
||||
extension_json,
|
||||
request.is_default,
|
||||
request.status
|
||||
))
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="创建成功",
|
||||
data={"id": new_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"创建码表数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/dict/{id}", response_model=dict)
|
||||
async def update_dict_data(
|
||||
id: int,
|
||||
request: UpdateDictDataRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新码表数据(仅管理员)
|
||||
"""
|
||||
try:
|
||||
# 验证extension_attr的JSON格式
|
||||
if request.extension_attr is not None:
|
||||
try:
|
||||
# 尝试序列化,验证是否为有效的JSON对象
|
||||
json.dumps(request.extension_attr)
|
||||
except (TypeError, ValueError) as e:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"扩展属性格式错误,必须是有效的JSON对象: {str(e)}"
|
||||
)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查是否存在
|
||||
cursor.execute("SELECT * FROM dict_data WHERE id = %s", (id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="码表数据不存在"
|
||||
)
|
||||
|
||||
# 构建更新语句
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if request.parent_code is not None:
|
||||
update_fields.append("parent_code = %s")
|
||||
params.append(request.parent_code)
|
||||
|
||||
if request.label_cn is not None:
|
||||
update_fields.append("label_cn = %s")
|
||||
params.append(request.label_cn)
|
||||
|
||||
if request.label_en is not None:
|
||||
update_fields.append("label_en = %s")
|
||||
params.append(request.label_en)
|
||||
|
||||
if request.sort_order is not None:
|
||||
update_fields.append("sort_order = %s")
|
||||
params.append(request.sort_order)
|
||||
|
||||
if request.extension_attr is not None:
|
||||
update_fields.append("extension_attr = %s")
|
||||
params.append(json.dumps(request.extension_attr, ensure_ascii=False))
|
||||
|
||||
if request.is_default is not None:
|
||||
update_fields.append("is_default = %s")
|
||||
params.append(request.is_default)
|
||||
|
||||
if request.status is not None:
|
||||
update_fields.append("status = %s")
|
||||
params.append(request.status)
|
||||
|
||||
if not update_fields:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="没有要更新的字段"
|
||||
)
|
||||
|
||||
# 执行更新
|
||||
update_query = f"""
|
||||
UPDATE dict_data
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = %s
|
||||
"""
|
||||
params.append(id)
|
||||
cursor.execute(update_query, params)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="更新成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"更新码表数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/dict/{id}", response_model=dict)
|
||||
async def delete_dict_data(
|
||||
id: int,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除码表数据(仅管理员)
|
||||
注意:如果有子节点或被引用,应该拒绝删除
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查是否存在
|
||||
cursor.execute("SELECT dict_code FROM dict_data WHERE id = %s", (id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="码表数据不存在"
|
||||
)
|
||||
|
||||
# 检查是否有子节点
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM dict_data WHERE parent_code = %s",
|
||||
(existing['dict_code'],)
|
||||
)
|
||||
if cursor.fetchone()['count'] > 0:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="该节点存在子节点,无法删除"
|
||||
)
|
||||
|
||||
# 检查是否被client_downloads引用
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM client_downloads WHERE platform_code = %s",
|
||||
(existing['dict_code'],)
|
||||
)
|
||||
if cursor.fetchone()['count'] > 0:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="该平台编码已被客户端下载记录引用,无法删除"
|
||||
)
|
||||
|
||||
# 执行删除
|
||||
cursor.execute("DELETE FROM dict_data WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="删除成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"删除码表数据失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import BASE_DIR, EXTERNAL_APPS_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE
|
||||
from app.utils.apk_parser import parse_apk_with_androguard
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# APK上传配置
|
||||
ALLOWED_APK_EXTENSIONS = {'.apk'}
|
||||
MAX_APK_SIZE = 200 * 1024 * 1024 # 200MB
|
||||
|
||||
|
||||
class CreateExternalAppRequest(BaseModel):
|
||||
app_name: str
|
||||
app_type: str # 'native' or 'web'
|
||||
app_info: str # JSON string
|
||||
icon_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UpdateExternalAppRequest(BaseModel):
|
||||
app_name: Optional[str] = None
|
||||
app_type: Optional[str] = None
|
||||
app_info: Optional[str] = None
|
||||
icon_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/external-apps", response_model=dict)
|
||||
async def get_external_apps(
|
||||
app_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取外部应用列表(管理后台接口)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if app_type:
|
||||
where_clauses.append("app_type = %s")
|
||||
params.append(app_type)
|
||||
|
||||
if is_active is not None:
|
||||
where_clauses.append("is_active = %s")
|
||||
params.append(is_active)
|
||||
|
||||
where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# 获取总数
|
||||
count_query = f"SELECT COUNT(*) as total FROM external_apps WHERE {where_clause}"
|
||||
cursor.execute(count_query, params)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 获取列表数据
|
||||
offset = (page - 1) * size
|
||||
list_query = f"""
|
||||
SELECT ea.*, u.username as creator_username
|
||||
FROM external_apps ea
|
||||
LEFT JOIN users u ON ea.created_by = u.user_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ea.sort_order ASC, ea.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cursor.execute(list_query, params + [size, offset])
|
||||
apps = cursor.fetchall()
|
||||
|
||||
# 解析 app_info JSON
|
||||
for app in apps:
|
||||
if app.get('app_info'):
|
||||
try:
|
||||
app['app_info'] = json.loads(app['app_info'])
|
||||
except:
|
||||
app['app_info'] = {}
|
||||
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"apps": apps,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取外部应用列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/external-apps/active", response_model=dict)
|
||||
async def get_active_external_apps():
|
||||
"""
|
||||
获取所有启用的外部应用(公开接口,供客户端调用)
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT id, app_name, app_type, app_info, icon_url, description, sort_order
|
||||
FROM external_apps
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order ASC, created_at DESC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
apps = cursor.fetchall()
|
||||
|
||||
# 解析 app_info JSON
|
||||
for app in apps:
|
||||
if app.get('app_info'):
|
||||
try:
|
||||
app['app_info'] = json.loads(app['app_info'])
|
||||
except:
|
||||
app['app_info'] = {}
|
||||
|
||||
cursor.close()
|
||||
|
||||
# 按类型分组
|
||||
native_apps = [app for app in apps if app['app_type'] == 'native']
|
||||
web_apps = [app for app in apps if app['app_type'] == 'web']
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取成功",
|
||||
data={
|
||||
"native": native_apps,
|
||||
"web": web_apps,
|
||||
"all": apps
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"获取外部应用失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/external-apps", response_model=dict)
|
||||
async def create_external_app(
|
||||
request: CreateExternalAppRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
创建外部应用
|
||||
"""
|
||||
try:
|
||||
# 验证 app_info 是否为有效JSON
|
||||
try:
|
||||
json.loads(request.app_info)
|
||||
except:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="app_info 必须是有效的JSON格式"
|
||||
)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
INSERT INTO external_apps
|
||||
(app_name, app_type, app_info, icon_url, description, sort_order, is_active, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(query, (
|
||||
request.app_name,
|
||||
request.app_type,
|
||||
request.app_info,
|
||||
request.icon_url,
|
||||
request.description,
|
||||
request.sort_order,
|
||||
request.is_active,
|
||||
current_user['user_id']
|
||||
))
|
||||
|
||||
app_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="创建成功",
|
||||
data={"id": app_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"创建外部应用失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/external-apps/{app_id}", response_model=dict)
|
||||
async def update_external_app(
|
||||
app_id: int,
|
||||
request: UpdateExternalAppRequest,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
更新外部应用
|
||||
"""
|
||||
try:
|
||||
# 验证 app_info 是否为有效JSON
|
||||
if request.app_info:
|
||||
try:
|
||||
json.loads(request.app_info)
|
||||
except:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="app_info 必须是有效的JSON格式"
|
||||
)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 构建更新字段
|
||||
update_fields = []
|
||||
params = []
|
||||
|
||||
if request.app_name is not None:
|
||||
update_fields.append("app_name = %s")
|
||||
params.append(request.app_name)
|
||||
|
||||
if request.app_type is not None:
|
||||
update_fields.append("app_type = %s")
|
||||
params.append(request.app_type)
|
||||
|
||||
if request.app_info is not None:
|
||||
update_fields.append("app_info = %s")
|
||||
params.append(request.app_info)
|
||||
|
||||
if request.icon_url is not None:
|
||||
update_fields.append("icon_url = %s")
|
||||
params.append(request.icon_url)
|
||||
|
||||
if request.description is not None:
|
||||
update_fields.append("description = %s")
|
||||
params.append(request.description)
|
||||
|
||||
if request.sort_order is not None:
|
||||
update_fields.append("sort_order = %s")
|
||||
params.append(request.sort_order)
|
||||
|
||||
if request.is_active is not None:
|
||||
update_fields.append("is_active = %s")
|
||||
params.append(request.is_active)
|
||||
|
||||
if not update_fields:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message="没有需要更新的字段"
|
||||
)
|
||||
|
||||
params.append(app_id)
|
||||
query = f"""
|
||||
UPDATE external_apps
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = %s
|
||||
"""
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="更新成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"更新外部应用失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/external-apps/{app_id}", response_model=dict)
|
||||
async def delete_external_app(
|
||||
app_id: int,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
删除外部应用
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 获取应用信息(用于删除APK文件)
|
||||
cursor.execute("SELECT * FROM external_apps WHERE id = %s", (app_id,))
|
||||
app = cursor.fetchone()
|
||||
|
||||
if not app:
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="应用不存在"
|
||||
)
|
||||
|
||||
# 删除数据库记录
|
||||
cursor.execute("DELETE FROM external_apps WHERE id = %s", (app_id,))
|
||||
conn.commit()
|
||||
|
||||
# 删除相关文件
|
||||
files_to_delete = []
|
||||
|
||||
# 如果是原生应用,添加APK文件到删除列表
|
||||
if app['app_type'] == 'native' and app.get('app_info'):
|
||||
try:
|
||||
app_info = json.loads(app['app_info']) if isinstance(app['app_info'], str) else app['app_info']
|
||||
apk_url = app_info.get('apk_url', '')
|
||||
if apk_url and apk_url.startswith('/uploads/'):
|
||||
apk_path = BASE_DIR / apk_url.lstrip('/')
|
||||
files_to_delete.append(('APK', apk_path))
|
||||
except Exception as e:
|
||||
print(f"Failed to parse app_info for APK deletion: {e}")
|
||||
|
||||
# 添加图标文件到删除列表
|
||||
if app.get('icon_url'):
|
||||
icon_url = app['icon_url']
|
||||
if icon_url and icon_url.startswith('/uploads/'):
|
||||
icon_path = BASE_DIR / icon_url.lstrip('/')
|
||||
files_to_delete.append(('Icon', icon_path))
|
||||
|
||||
# 执行文件删除
|
||||
for file_type, file_path in files_to_delete:
|
||||
try:
|
||||
if file_path.exists():
|
||||
os.remove(file_path)
|
||||
print(f"Deleted {file_type} file: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"Failed to delete {file_type} file: {e}")
|
||||
|
||||
cursor.close()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="删除成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"删除外部应用失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/external-apps/upload-apk", response_model=dict)
|
||||
async def upload_apk(
|
||||
apk_file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
上传APK文件并解析信息
|
||||
"""
|
||||
try:
|
||||
# 验证文件类型
|
||||
file_extension = os.path.splitext(apk_file.filename)[1].lower()
|
||||
if file_extension not in ALLOWED_APK_EXTENSIONS:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"不支持的文件类型。仅支持: {', '.join(ALLOWED_APK_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# 验证文件大小
|
||||
apk_file.file.seek(0, 2) # 移动到文件末尾
|
||||
file_size = apk_file.file.tell()
|
||||
apk_file.file.seek(0) # 重置到文件开头
|
||||
|
||||
if file_size > MAX_APK_SIZE:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"文件大小超过 {MAX_APK_SIZE // (1024 * 1024)}MB 限制"
|
||||
)
|
||||
|
||||
# 使用原始文件名
|
||||
original_filename = apk_file.filename
|
||||
file_path = EXTERNAL_APPS_DIR / original_filename
|
||||
|
||||
# 如果同名文件已存在,先删除
|
||||
if file_path.exists():
|
||||
try:
|
||||
os.remove(file_path)
|
||||
print(f"删除已存在的同名文件: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"删除同名文件失败: {e}")
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(apk_file.file, buffer)
|
||||
|
||||
# 计算MD5
|
||||
md5_hash = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
md5_hash.update(chunk)
|
||||
apk_md5 = md5_hash.hexdigest()
|
||||
|
||||
# 解析APK
|
||||
try:
|
||||
apk_info = parse_apk_with_androguard(str(file_path))
|
||||
print(f"APK解析成功: {apk_info}")
|
||||
except Exception as e:
|
||||
# 删除已上传的文件
|
||||
if file_path.exists():
|
||||
os.remove(file_path)
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"APK解析失败: {str(e)}"
|
||||
)
|
||||
|
||||
# 计算相对路径
|
||||
relative_path = file_path.relative_to(EXTERNAL_APPS_DIR.parent.parent)
|
||||
|
||||
# 返回解析结果
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="APK上传并解析成功",
|
||||
data={
|
||||
"apk_url": "/" + str(relative_path).replace("\\", "/"),
|
||||
"apk_size": file_size,
|
||||
"apk_md5": apk_md5,
|
||||
"package_name": apk_info.get('package_name'),
|
||||
"version_name": apk_info.get('version_name'),
|
||||
"version_code": apk_info.get('version_code'),
|
||||
"app_name": apk_info.get('app_name'),
|
||||
"min_sdk_version": apk_info.get('min_sdk_version'),
|
||||
"target_sdk_version": apk_info.get('target_sdk_version')
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"上传APK失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/external-apps/upload-icon", response_model=dict)
|
||||
async def upload_icon(
|
||||
icon_file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
上传应用图标
|
||||
"""
|
||||
try:
|
||||
# 验证文件类型
|
||||
file_extension = os.path.splitext(icon_file.filename)[1].lower()
|
||||
if file_extension not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"不支持的文件类型。仅支持: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# 验证文件大小
|
||||
icon_file.file.seek(0, 2)
|
||||
file_size = icon_file.file.tell()
|
||||
icon_file.file.seek(0)
|
||||
|
||||
if file_size > MAX_IMAGE_SIZE:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"文件大小超过 {MAX_IMAGE_SIZE // (1024 * 1024)}MB 限制"
|
||||
)
|
||||
|
||||
# 生成唯一文件名(图标使用UUID避免冲突)
|
||||
unique_filename = f"icon_{uuid.uuid4()}{file_extension}"
|
||||
file_path = EXTERNAL_APPS_DIR / unique_filename
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(icon_file.file, buffer)
|
||||
|
||||
# 计算相对路径
|
||||
relative_path = file_path.relative_to(EXTERNAL_APPS_DIR.parent.parent)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="图标上传成功",
|
||||
data={
|
||||
"icon_url": "/" + str(relative_path).replace("\\", "/"),
|
||||
"file_size": file_size
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message=f"上传图标失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import QWEN_API_KEY
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import dashscope
|
||||
from dashscope.audio.asr import VocabularyService
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class HotWordItem(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
weight: int
|
||||
lang: str
|
||||
status: int
|
||||
create_time: datetime
|
||||
update_time: datetime
|
||||
|
||||
class CreateHotWordRequest(BaseModel):
|
||||
text: str
|
||||
weight: int = 4
|
||||
lang: str = "zh"
|
||||
status: int = 1
|
||||
|
||||
class UpdateHotWordRequest(BaseModel):
|
||||
text: Optional[str] = None
|
||||
weight: Optional[int] = None
|
||||
lang: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
@router.get("/admin/hot-words", response_model=dict)
|
||||
async def list_hot_words(current_user: dict = Depends(get_current_admin_user)):
|
||||
"""获取热词列表"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
|
||||
items = cursor.fetchall()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="获取成功", data=items)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取失败: {str(e)}")
|
||||
|
||||
@router.post("/admin/hot-words", response_model=dict)
|
||||
async def create_hot_word(request: CreateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""创建热词"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
|
||||
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="创建成功", data={"id": new_id})
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建失败: {str(e)}")
|
||||
|
||||
@router.put("/admin/hot-words/{id}", response_model=dict)
|
||||
async def update_hot_word(id: int, request: UpdateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""更新热词"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
update_fields = []
|
||||
params = []
|
||||
if request.text is not None:
|
||||
update_fields.append("text = %s")
|
||||
params.append(request.text)
|
||||
if request.weight is not None:
|
||||
update_fields.append("weight = %s")
|
||||
params.append(request.weight)
|
||||
if request.lang is not None:
|
||||
update_fields.append("lang = %s")
|
||||
params.append(request.lang)
|
||||
if request.status is not None:
|
||||
update_fields.append("status = %s")
|
||||
params.append(request.status)
|
||||
|
||||
if not update_fields:
|
||||
return create_api_response(code="400", message="无更新内容")
|
||||
|
||||
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
|
||||
params.append(id)
|
||||
cursor.execute(query, tuple(params))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="更新成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新失败: {str(e)}")
|
||||
|
||||
@router.delete("/admin/hot-words/{id}", response_model=dict)
|
||||
async def delete_hot_word(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""删除热词"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM hot_words WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="删除成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除失败: {str(e)}")
|
||||
|
||||
@router.post("/admin/hot-words/sync", response_model=dict)
|
||||
async def sync_hot_words(current_user: dict = Depends(get_current_admin_user)):
|
||||
"""同步热词到阿里云 DashScope"""
|
||||
try:
|
||||
dashscope.api_key = QWEN_API_KEY
|
||||
|
||||
# 1. 获取所有启用的热词
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT text, weight, lang FROM hot_words WHERE status = 1")
|
||||
hot_words = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
# 2. 获取现有的 vocabulary_id
|
||||
existing_vocab_id = SystemConfigService.get_asr_vocabulary_id()
|
||||
|
||||
# 构建热词列表
|
||||
vocabulary_list = [{"text": hw['text'], "weight": hw['weight'], "lang": hw['lang']} for hw in hot_words]
|
||||
|
||||
if not vocabulary_list:
|
||||
return create_api_response(code="400", message="没有启用的热词可同步")
|
||||
|
||||
# 3. 调用阿里云 API
|
||||
service = VocabularyService()
|
||||
vocab_id = existing_vocab_id
|
||||
|
||||
try:
|
||||
if existing_vocab_id:
|
||||
# 尝试更新现有的热词表
|
||||
try:
|
||||
service.update_vocabulary(
|
||||
vocabulary_id=existing_vocab_id,
|
||||
vocabulary=vocabulary_list
|
||||
)
|
||||
# 更新成功,保持原有ID
|
||||
except Exception as update_error:
|
||||
# 如果更新失败(如资源不存在),尝试创建新的
|
||||
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
|
||||
existing_vocab_id = None # 重置,触发创建逻辑
|
||||
|
||||
if not existing_vocab_id:
|
||||
# 创建新的热词表
|
||||
vocab_id = service.create_vocabulary(
|
||||
prefix='imeeting',
|
||||
target_model='paraformer-v2',
|
||||
vocabulary=vocabulary_list
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
|
||||
|
||||
# 4. 更新数据库中的 vocabulary_id
|
||||
if vocab_id:
|
||||
SystemConfigService.set_config(
|
||||
SystemConfigService.ASR_VOCABULARY_ID,
|
||||
vocab_id
|
||||
)
|
||||
|
||||
return create_api_response(code="200", message="同步成功", data={"vocabulary_id": vocab_id})
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"同步异常: {str(e)}")
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from typing import Optional, List
|
||||
from app.models.models import KnowledgeBase, KnowledgeBaseListResponse, CreateKnowledgeBaseRequest, UpdateKnowledgeBaseRequest, Tag
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.async_knowledge_base_service import async_kb_service
|
||||
import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]:
|
||||
"""
|
||||
处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签
|
||||
"""
|
||||
if not tag_string:
|
||||
return []
|
||||
tag_names = [name.strip() for name in tag_string.split(',') if name.strip()]
|
||||
if not tag_names:
|
||||
return []
|
||||
|
||||
# 如果提供了 creator_id,则创建不存在的标签
|
||||
if creator_id:
|
||||
insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)"
|
||||
cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names])
|
||||
|
||||
# 查询所有标签信息
|
||||
format_strings = ', '.join(['%s'] * len(tag_names))
|
||||
cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names))
|
||||
tags_data = cursor.fetchall()
|
||||
return [Tag(**tag) for tag in tags_data]
|
||||
|
||||
|
||||
@router.get("/knowledge-bases", response_model=KnowledgeBaseListResponse)
|
||||
def get_knowledge_bases(
|
||||
page: int = 1,
|
||||
size: int = 10,
|
||||
is_shared: Optional[bool] = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
base_query = "FROM knowledge_bases kb JOIN users u ON kb.creator_id = u.user_id"
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if is_shared is not None:
|
||||
if is_shared:
|
||||
where_clauses.append("kb.is_shared = 1")
|
||||
else: # Personal
|
||||
where_clauses.append("kb.is_shared = 0 AND kb.creator_id = %s")
|
||||
params.append(current_user['user_id'])
|
||||
else: # Both personal and shared
|
||||
where_clauses.append("(kb.is_shared = 1 OR kb.creator_id = %s)")
|
||||
params.append(current_user['user_id'])
|
||||
|
||||
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||
|
||||
count_query = "SELECT COUNT(*) as total " + base_query + where_sql
|
||||
cursor.execute(count_query, tuple(params))
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
offset = (page - 1) * size
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
kb.kb_id, kb.title, kb.content, kb.creator_id, u.caption as creator_caption,
|
||||
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at
|
||||
{base_query}
|
||||
{where_sql}
|
||||
ORDER BY kb.updated_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
|
||||
query_params = params + [size, offset]
|
||||
cursor.execute(query, tuple(query_params))
|
||||
kbs_data = cursor.fetchall()
|
||||
|
||||
kb_list = []
|
||||
for kb_data in kbs_data:
|
||||
# 列表页不需要处理 tags,直接使用字符串
|
||||
# kb_data['tags'] 保持原样(逗号分隔的标签名称字符串)
|
||||
# Count source meetings - filter empty strings
|
||||
if kb_data.get('source_meeting_ids'):
|
||||
meeting_ids = [mid.strip() for mid in kb_data['source_meeting_ids'].split(',') if mid.strip()]
|
||||
kb_data['source_meeting_count'] = len(meeting_ids)
|
||||
else:
|
||||
kb_data['source_meeting_count'] = 0
|
||||
# Add created_by_name for consistency
|
||||
kb_data['created_by_name'] = kb_data.get('creator_caption')
|
||||
kb_list.append(KnowledgeBase(**kb_data))
|
||||
|
||||
return KnowledgeBaseListResponse(kbs=kb_list, total=total)
|
||||
|
||||
@router.post("/knowledge-bases")
|
||||
def create_knowledge_base(
|
||||
request: CreateKnowledgeBaseRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 自动生成标题,格式为: YYYY-MM-DD 知识条目
|
||||
if not request.title:
|
||||
now = datetime.datetime.now()
|
||||
request.title = now.strftime("%Y-%m-%d") + " 知识条目"
|
||||
|
||||
# Create the knowledge base entry first
|
||||
insert_kb_query = """
|
||||
INSERT INTO knowledge_bases (title, creator_id, is_shared, source_meeting_ids, user_prompt, tags, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
now = datetime.datetime.utcnow()
|
||||
cursor.execute(insert_kb_query, (
|
||||
request.title,
|
||||
current_user['user_id'],
|
||||
request.is_shared,
|
||||
request.source_meeting_ids,
|
||||
request.user_prompt,
|
||||
request.tags, # 创建时 tags 应该为 None 或空字符串
|
||||
now,
|
||||
now
|
||||
))
|
||||
kb_id = cursor.lastrowid
|
||||
|
||||
# Start the async task
|
||||
task_id = async_kb_service.start_generation(
|
||||
user_id=current_user['user_id'],
|
||||
kb_id=kb_id,
|
||||
user_prompt=request.user_prompt,
|
||||
source_meeting_ids=request.source_meeting_ids,
|
||||
prompt_id=request.prompt_id, # 传递 prompt_id 参数
|
||||
cursor=cursor
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
||||
# Add the background task to process the knowledge base generation
|
||||
background_tasks.add_task(async_kb_service._process_task, task_id)
|
||||
|
||||
return {"task_id": task_id, "kb_id": kb_id}
|
||||
|
||||
@router.get("/knowledge-bases/{kb_id}")
|
||||
def get_knowledge_base_detail(
|
||||
kb_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
kb.kb_id, kb.title, kb.content, kb.creator_id, u.caption as creator_caption,
|
||||
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_name
|
||||
FROM knowledge_bases kb
|
||||
JOIN users u ON kb.creator_id = u.user_id
|
||||
WHERE kb.kb_id = %s
|
||||
"""
|
||||
cursor.execute(query, (kb_id,))
|
||||
kb_data = cursor.fetchone()
|
||||
|
||||
if not kb_data:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
|
||||
# Check access permissions
|
||||
if not kb_data['is_shared'] and kb_data['creator_id'] != current_user['user_id']:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Process tags - 获取标签的完整信息(包括颜色)
|
||||
# 详情页不需要创建新标签,所以不传 creator_id
|
||||
kb_data['tags'] = _process_tags(cursor, kb_data.get('tags'))
|
||||
|
||||
# Get source meetings details
|
||||
source_meetings = []
|
||||
if kb_data.get('source_meeting_ids'):
|
||||
meeting_ids = [mid.strip() for mid in kb_data['source_meeting_ids'].split(',') if mid.strip()]
|
||||
if meeting_ids:
|
||||
placeholders = ','.join(['%s'] * len(meeting_ids))
|
||||
meeting_query = f"""
|
||||
SELECT meeting_id, title
|
||||
FROM meetings
|
||||
WHERE meeting_id IN ({placeholders})
|
||||
"""
|
||||
cursor.execute(meeting_query, tuple(meeting_ids))
|
||||
meetings_data = cursor.fetchall()
|
||||
source_meetings = [{'meeting_id': m['meeting_id'], 'title': m['title']} for m in meetings_data]
|
||||
kb_data['source_meeting_count'] = len(source_meetings)
|
||||
else:
|
||||
kb_data['source_meeting_count'] = 0
|
||||
else:
|
||||
kb_data['source_meeting_count'] = 0
|
||||
|
||||
kb_data['source_meetings'] = source_meetings
|
||||
|
||||
return kb_data
|
||||
|
||||
@router.put("/knowledge-bases/{kb_id}")
|
||||
def update_knowledge_base(
|
||||
kb_id: int,
|
||||
request: UpdateKnowledgeBaseRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Check if knowledge base exists and user has permission
|
||||
cursor.execute(
|
||||
"SELECT kb_id, creator_id FROM knowledge_bases WHERE kb_id = %s",
|
||||
(kb_id,)
|
||||
)
|
||||
kb = cursor.fetchone()
|
||||
|
||||
if not kb:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
|
||||
if kb['creator_id'] != current_user['user_id']:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can update this knowledge base")
|
||||
|
||||
# 使用 _process_tags 处理标签(会自动创建新标签)
|
||||
if request.tags:
|
||||
_process_tags(cursor, request.tags, current_user['user_id'])
|
||||
|
||||
# Update the knowledge base
|
||||
now = datetime.datetime.utcnow()
|
||||
update_query = """
|
||||
UPDATE knowledge_bases
|
||||
SET title = %s, content = %s, tags = %s, updated_at = %s
|
||||
WHERE kb_id = %s
|
||||
"""
|
||||
cursor.execute(update_query, (
|
||||
request.title,
|
||||
request.content,
|
||||
request.tags,
|
||||
now,
|
||||
kb_id
|
||||
))
|
||||
connection.commit()
|
||||
|
||||
return {"message": "Knowledge base updated successfully"}
|
||||
|
||||
@router.delete("/knowledge-bases/{kb_id}")
|
||||
def delete_knowledge_base(
|
||||
kb_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Check if knowledge base exists and user has permission
|
||||
cursor.execute(
|
||||
"SELECT kb_id, creator_id FROM knowledge_bases WHERE kb_id = %s",
|
||||
(kb_id,)
|
||||
)
|
||||
kb = cursor.fetchone()
|
||||
|
||||
if not kb:
|
||||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||
|
||||
if kb['creator_id'] != current_user['user_id']:
|
||||
raise HTTPException(status_code=403, detail="Only the creator can delete this knowledge base")
|
||||
|
||||
# Delete the knowledge base
|
||||
cursor.execute("DELETE FROM knowledge_bases WHERE kb_id = %s", (kb_id,))
|
||||
connection.commit()
|
||||
|
||||
return {"message": "Knowledge base deleted successfully"}
|
||||
|
||||
@router.get("/knowledge-bases/tasks/{task_id}")
|
||||
def get_task_status(task_id: str):
|
||||
"""获取知识库生成任务状态"""
|
||||
task_status = async_kb_service.get_task_status(task_id)
|
||||
|
||||
if task_status.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
return {
|
||||
"status": task_status.get('status'),
|
||||
"progress": task_status.get('progress', 0),
|
||||
"error": task_status.get('error_message')
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,240 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Pydantic Models
|
||||
class PromptIn(BaseModel):
|
||||
name: str
|
||||
task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK'
|
||||
content: str
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
class PromptOut(PromptIn):
|
||||
id: int
|
||||
creator_id: int
|
||||
created_at: str
|
||||
|
||||
class PromptListResponse(BaseModel):
|
||||
prompts: List[PromptOut]
|
||||
total: int
|
||||
|
||||
@router.post("/prompts")
|
||||
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
||||
"""Create a new prompt."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
||||
if prompt.is_default:
|
||||
cursor.execute(
|
||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s",
|
||||
(prompt.task_type,)
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
||||
prompt.is_active, current_user["user_id"])
|
||||
)
|
||||
connection.commit()
|
||||
new_id = cursor.lastrowid
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="提示词创建成功",
|
||||
data={"id": new_id, **prompt.dict()}
|
||||
)
|
||||
except Exception as e:
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="提示词名称已存在")
|
||||
return create_api_response(code="500", message=f"创建提示词失败: {e}")
|
||||
|
||||
@router.get("/prompts/active/{task_type}")
|
||||
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get all active prompts for a specific task type."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC""",
|
||||
(task_type,)
|
||||
)
|
||||
prompts = cursor.fetchall()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取启用模版列表成功",
|
||||
data={"prompts": prompts}
|
||||
)
|
||||
|
||||
@router.get("/prompts")
|
||||
def get_prompts(
|
||||
task_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get a paginated list of prompts filtered by current user and optionally by task_type."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 构建 WHERE 条件
|
||||
where_conditions = ["creator_id = %s"]
|
||||
params = [current_user["user_id"]]
|
||||
|
||||
if task_type:
|
||||
where_conditions.append("task_type = %s")
|
||||
params.append(task_type)
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
# 获取总数
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
|
||||
tuple(params)
|
||||
)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
# 获取分页数据
|
||||
offset = (page - 1) * size
|
||||
cursor.execute(
|
||||
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
||||
FROM prompts
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
tuple(params + [size, offset])
|
||||
)
|
||||
prompts = cursor.fetchall()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取提示词列表成功",
|
||||
data={"prompts": prompts, "total": total}
|
||||
)
|
||||
|
||||
@router.get("/prompts/{prompt_id}")
|
||||
def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get a single prompt by its ID."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
||||
FROM prompts WHERE id = %s""",
|
||||
(prompt_id,)
|
||||
)
|
||||
prompt = cursor.fetchone()
|
||||
if not prompt:
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
return create_api_response(code="200", message="获取提示词成功", data=prompt)
|
||||
|
||||
@router.put("/prompts/{prompt_id}")
|
||||
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
||||
"""Update an existing prompt."""
|
||||
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
|
||||
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
|
||||
print(f"[UPDATE PROMPT] data: name={prompt.name}, task_type={prompt.task_type}, content_len={len(prompt.content)}, is_default={prompt.is_default}, is_active={prompt.is_active}")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
# 先检查记录是否存在
|
||||
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
|
||||
existing = cursor.fetchone()
|
||||
print(f"[UPDATE PROMPT] existing record: {existing}")
|
||||
|
||||
if not existing:
|
||||
print(f"[UPDATE PROMPT] Prompt {prompt_id} not found in database")
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
|
||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
||||
if prompt.is_default:
|
||||
print(f"[UPDATE PROMPT] Setting as default, clearing other defaults for task_type={prompt.task_type}")
|
||||
cursor.execute(
|
||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
|
||||
(prompt.task_type, prompt_id)
|
||||
)
|
||||
print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts")
|
||||
|
||||
print(f"[UPDATE PROMPT] Executing UPDATE query")
|
||||
cursor.execute(
|
||||
"""UPDATE prompts
|
||||
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
|
||||
WHERE id = %s""",
|
||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
||||
prompt.is_active, prompt_id)
|
||||
)
|
||||
rows_affected = cursor.rowcount
|
||||
print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)")
|
||||
|
||||
# 注意:rowcount=0 不代表记录不存在,可能是所有字段值都相同
|
||||
# 我们已经在上面确认了记录存在,所以这里直接提交即可
|
||||
connection.commit()
|
||||
print(f"[UPDATE PROMPT] Success! Committed changes")
|
||||
return create_api_response(code="200", message="提示词更新成功")
|
||||
except Exception as e:
|
||||
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="提示词名称已存在")
|
||||
return create_api_response(code="500", message=f"更新提示词失败: {e}")
|
||||
|
||||
@router.delete("/prompts/{prompt_id}")
|
||||
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Delete a prompt. Only the creator can delete their own prompts."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
# 首先检查提示词是否存在以及是否属于当前用户
|
||||
cursor.execute(
|
||||
"SELECT creator_id FROM prompts WHERE id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
prompt = cursor.fetchone()
|
||||
|
||||
if not prompt:
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
|
||||
if prompt['creator_id'] != current_user["user_id"]:
|
||||
return create_api_response(code="403", message="无权删除其他用户的提示词")
|
||||
|
||||
# 检查是否有会议引用了该提示词
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM meetings WHERE prompt_id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
meeting_count = cursor.fetchone()['count']
|
||||
|
||||
# 检查是否有知识库引用了该提示词
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM knowledge_bases WHERE prompt_id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
kb_count = cursor.fetchone()['count']
|
||||
|
||||
# 如果有引用,不允许删除
|
||||
if meeting_count > 0 or kb_count > 0:
|
||||
references = []
|
||||
if meeting_count > 0:
|
||||
references.append(f"{meeting_count}个会议")
|
||||
if kb_count > 0:
|
||||
references.append(f"{kb_count}个知识库")
|
||||
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"无法删除:该提示词被{' 和 '.join(references)}引用",
|
||||
data={
|
||||
"meeting_count": meeting_count,
|
||||
"kb_count": kb_count
|
||||
}
|
||||
)
|
||||
|
||||
# 删除提示词
|
||||
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
|
||||
connection.commit()
|
||||
return create_api_response(code="200", message="提示词删除成功")
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
from app.core.auth import get_current_user, get_optional_current_user
|
||||
from app.models.models import Tag
|
||||
from typing import List, Optional
|
||||
import mysql.connector
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/tags")
|
||||
def get_user_tags(current_user: Optional[dict] = Depends(get_optional_current_user)):
|
||||
"""_summary_
|
||||
获取当前用户创建的所有标签(如果未认证则返回空列表)
|
||||
"""
|
||||
# 如果未认证,返回空标签列表
|
||||
if not current_user:
|
||||
return create_api_response(code="200", message="获取标签列表成功", data=[])
|
||||
|
||||
query = "SELECT id, name, color FROM tags WHERE creator_id = %s ORDER BY name"
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor(dictionary=True) as cursor:
|
||||
cursor.execute(query, (current_user["user_id"],))
|
||||
tags = cursor.fetchall()
|
||||
return create_api_response(code="200", message="获取标签列表成功", data=tags)
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Error: {err}")
|
||||
return create_api_response(code="500", message="获取标签失败")
|
||||
|
||||
@router.post("/tags")
|
||||
def create_tag(tag_in: Tag, current_user: dict = Depends(get_current_user)):
|
||||
"""_summary_
|
||||
创建一个新标签,并记录创建者
|
||||
"""
|
||||
query = "INSERT INTO tags (name, color, creator_id) VALUES (%s, %s, %s)"
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor(dictionary=True) as cursor:
|
||||
try:
|
||||
cursor.execute(query, (tag_in.name, tag_in.color, current_user["user_id"]))
|
||||
connection.commit()
|
||||
tag_id = cursor.lastrowid
|
||||
new_tag = {"id": tag_id, "name": tag_in.name, "color": tag_in.color}
|
||||
return create_api_response(code="200", message="标签创建成功", data=new_tag)
|
||||
except mysql.connector.IntegrityError:
|
||||
connection.rollback()
|
||||
return create_api_response(code="400", message=f"标签 '{tag_in.name}' 已存在")
|
||||
except mysql.connector.Error as err:
|
||||
print(f"Error: {err}")
|
||||
return create_api_response(code="500", message="创建标签失败")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
@router.get("/tasks/transcription/{task_id}/status")
|
||||
def get_transcription_task_status(task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""获取转录任务状态"""
|
||||
try:
|
||||
status_info = transcription_service.get_task_status(task_id)
|
||||
if not status_info or status_info.get('status') == 'not_found':
|
||||
return create_api_response(code="404", message="Transcription task not found")
|
||||
return create_api_response(code="200", message="Task status retrieved", data=status_info)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"Failed to get task status: {str(e)}")
|
||||
|
||||
@router.get("/tasks/summaries/{task_id}/status")
|
||||
def get_llm_task_status(task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""获取LLM总结任务状态(包括进度)"""
|
||||
try:
|
||||
status = async_meeting_service.get_task_status(task_id)
|
||||
if status.get('status') == 'not_found':
|
||||
return create_api_response(code="404", message="Task not found")
|
||||
return create_api_response(code="200", message="Task status retrieved", data=status)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"Failed to get task status: {str(e)}")
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from typing import Optional
|
||||
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
import app.core.config as config_module
|
||||
from app.core.config import UPLOAD_DIR, AVATAR_DIR
|
||||
import hashlib
|
||||
import datetime
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Basic email validation"""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
@router.get("/roles")
|
||||
def get_all_roles(current_user: dict = Depends(get_current_user)):
|
||||
"""获取所有角色列表"""
|
||||
if current_user['role_id'] != 1: # 1 is admin
|
||||
return create_api_response(code="403", message="仅管理员有权限查看角色列表")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id")
|
||||
roles = cursor.fetchall()
|
||||
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(request: CreateUserRequest, current_user: dict = Depends(get_current_user)):
|
||||
if current_user['role_id'] != 1: # 1 is admin
|
||||
return create_api_response(code="403", message="仅管理员有权限创建用户")
|
||||
|
||||
if not validate_email(request.email):
|
||||
return create_api_response(code="400", message="邮箱格式不正确")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="用户名已存在")
|
||||
|
||||
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
||||
hashed_password = hash_password(password)
|
||||
|
||||
query = "INSERT INTO users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
created_at = datetime.datetime.utcnow()
|
||||
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="用户创建成功")
|
||||
|
||||
@router.put("/users/{user_id}")
|
||||
def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = Depends(get_current_user)):
|
||||
# Allow admin (role_id=1) or self
|
||||
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
|
||||
return create_api_response(code="403", message="没有权限修改此用户信息")
|
||||
|
||||
if request.email and not validate_email(request.email):
|
||||
return create_api_response(code="400", message="邮箱格式不正确")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM users WHERE user_id = %s", (user_id,))
|
||||
existing_user = cursor.fetchone()
|
||||
if not existing_user:
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
if request.username and request.username != existing_user['username']:
|
||||
cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="用户名已存在")
|
||||
|
||||
# Restrict role_id update to admins only
|
||||
target_role_id = existing_user['role_id']
|
||||
if current_user['role_id'] == 1 and request.role_id is not None:
|
||||
target_role_id = request.role_id
|
||||
|
||||
update_data = {
|
||||
'username': request.username if request.username else existing_user['username'],
|
||||
'caption': request.caption if request.caption else existing_user['caption'],
|
||||
'email': request.email if request.email else existing_user['email'],
|
||||
'avatar_url': request.avatar_url if request.avatar_url is not None else existing_user.get('avatar_url'),
|
||||
'role_id': target_role_id
|
||||
}
|
||||
|
||||
query = "UPDATE users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
|
||||
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
|
||||
connection.commit()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
WHERE u.user_id = %s
|
||||
''', (user_id,))
|
||||
updated_user = cursor.fetchone()
|
||||
|
||||
user_info = UserInfo(
|
||||
user_id=updated_user['user_id'],
|
||||
username=updated_user['username'],
|
||||
caption=updated_user['caption'],
|
||||
email=updated_user['email'],
|
||||
avatar_url=updated_user['avatar_url'],
|
||||
created_at=updated_user['created_at'],
|
||||
role_id=updated_user['role_id'],
|
||||
role_name=updated_user['role_name'],
|
||||
meetings_created=0,
|
||||
meetings_attended=0
|
||||
)
|
||||
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
if current_user['role_id'] != 1: # 1 is admin
|
||||
return create_api_response(code="403", message="仅管理员有权限删除用户")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="用户删除成功")
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
def reset_password(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
if current_user['role_id'] != 1: # 1 is admin
|
||||
return create_api_response(code="403", message="仅管理员有权限重置密码")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
|
||||
|
||||
query = "UPDATE users SET password_hash = %s WHERE user_id = %s"
|
||||
cursor.execute(query, (hashed_password, user_id))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message=f"用户 {user_id} 的密码已重置")
|
||||
|
||||
@router.get("/users")
|
||||
def get_all_users(
|
||||
page: int = 1,
|
||||
size: int = 10,
|
||||
role_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 构建WHERE条件
|
||||
where_conditions = []
|
||||
count_params = []
|
||||
|
||||
if role_id is not None:
|
||||
where_conditions.append("u.role_id = %s")
|
||||
count_params.append(role_id)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
where_conditions.append("(username LIKE %s OR caption LIKE %s)")
|
||||
count_params.extend([search_pattern, search_pattern])
|
||||
|
||||
# 统计查询
|
||||
count_query = "SELECT COUNT(*) as total FROM users u"
|
||||
if where_conditions:
|
||||
count_query += " WHERE " + " AND ".join(where_conditions)
|
||||
|
||||
cursor.execute(count_query, tuple(count_params))
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
offset = (page - 1) * size
|
||||
|
||||
# 主查询
|
||||
query = '''
|
||||
SELECT
|
||||
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
||||
r.role_name,
|
||||
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
|
||||
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
'''
|
||||
|
||||
query_params = []
|
||||
if where_conditions:
|
||||
query += " WHERE " + " AND ".join(where_conditions)
|
||||
query_params.extend(count_params)
|
||||
|
||||
query += '''
|
||||
ORDER BY u.user_id ASC
|
||||
LIMIT %s OFFSET %s
|
||||
'''
|
||||
|
||||
query_params.extend([size, offset])
|
||||
|
||||
cursor.execute(query, tuple(query_params))
|
||||
users = cursor.fetchall()
|
||||
|
||||
user_list = [UserInfo(**user) for user in users]
|
||||
|
||||
response_data = UserListResponse(users=user_list, total=total)
|
||||
return create_api_response(code="200", message="获取用户列表成功", data=response_data.dict())
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
user_query = '''
|
||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
WHERE u.user_id = %s
|
||||
'''
|
||||
cursor.execute(user_query, (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s"
|
||||
cursor.execute(created_query, (user_id,))
|
||||
meetings_created = cursor.fetchone()['count']
|
||||
|
||||
attended_query = "SELECT COUNT(*) as count FROM attendees WHERE user_id = %s"
|
||||
cursor.execute(attended_query, (user_id,))
|
||||
meetings_attended = cursor.fetchone()['count']
|
||||
|
||||
user_info = UserInfo(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
caption=user['caption'],
|
||||
email=user['email'],
|
||||
avatar_url=user['avatar_url'],
|
||||
created_at=user['created_at'],
|
||||
role_id=user['role_id'],
|
||||
role_name=user['role_name'],
|
||||
meetings_created=meetings_created,
|
||||
meetings_attended=meetings_attended
|
||||
)
|
||||
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
|
||||
|
||||
@router.put("/users/{user_id}/password")
|
||||
def update_password(user_id: int, request: PasswordChangeRequest, current_user: dict = Depends(get_current_user)):
|
||||
if user_id != current_user['user_id'] and current_user['role_id'] != 1:
|
||||
return create_api_response(code="403", message="没有权限修改其他用户的密码")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT password_hash FROM users WHERE user_id = %s", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
if current_user['role_id'] != 1:
|
||||
if user['password_hash'] != hash_password(request.old_password):
|
||||
return create_api_response(code="400", message="旧密码错误")
|
||||
|
||||
new_password_hash = hash_password(request.new_password)
|
||||
cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="密码修改成功")
|
||||
|
||||
@router.post("/users/{user_id}/avatar")
|
||||
def upload_user_avatar(
|
||||
user_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
# Allow admin or self
|
||||
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
|
||||
return create_api_response(code="403", message="没有权限上传此用户头像")
|
||||
|
||||
# Validate file type
|
||||
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
if file_ext not in ALLOWED_EXTENSIONS:
|
||||
return create_api_response(code="400", message="不支持的文件类型")
|
||||
|
||||
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
|
||||
user_avatar_dir = AVATAR_DIR / str(user_id)
|
||||
if not user_avatar_dir.exists():
|
||||
os.makedirs(user_avatar_dir)
|
||||
|
||||
# Generate unique filename
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
file_path = user_avatar_dir / unique_filename
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Generate URL (relative)
|
||||
# AVATAR_DIR is uploads/user/avatar
|
||||
# file path is uploads/user/avatar/{user_id}/{filename}
|
||||
# URL should be /uploads/user/avatar/{user_id}/{filename}
|
||||
avatar_url = f"/uploads/user/avatar/{user_id}/{unique_filename}"
|
||||
|
||||
# Update database
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("UPDATE users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"""
|
||||
声纹采集API接口
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
|
||||
from app.models.models import VoiceprintStatus, VoiceprintTemplate
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.voiceprint_service import voiceprint_service
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
import app.core.config as config_module
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/voiceprint/template", response_model=None)
|
||||
def get_voiceprint_template(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
获取声纹采集朗读模板配置
|
||||
|
||||
权限:需要登录
|
||||
"""
|
||||
try:
|
||||
template_data = VoiceprintTemplate(
|
||||
template_text=SystemConfigService.get_voiceprint_template(),
|
||||
duration_seconds=SystemConfigService.get_voiceprint_duration(),
|
||||
sample_rate=SystemConfigService.get_voiceprint_sample_rate(),
|
||||
channels=SystemConfigService.get_voiceprint_channels()
|
||||
)
|
||||
return create_api_response(code="200", message="获取朗读模板成功", data=template_data.dict())
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/voiceprint/{user_id}", response_model=None)
|
||||
def get_voiceprint_status(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
获取用户声纹采集状态
|
||||
|
||||
权限:用户只能查询自己的声纹状态,管理员可查询所有
|
||||
"""
|
||||
# 权限检查:只能查询自己的声纹,或者是管理员
|
||||
if current_user['user_id'] != user_id and current_user['role_id'] != 1:
|
||||
return create_api_response(code="403", message="无权限查询其他用户的声纹状态")
|
||||
|
||||
try:
|
||||
status_data = voiceprint_service.get_user_voiceprint_status(user_id)
|
||||
return create_api_response(code="200", message="获取声纹状态成功", data=status_data)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取声纹状态失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/voiceprint/{user_id}", response_model=None)
|
||||
async def upload_voiceprint(
|
||||
user_id: int,
|
||||
audio_file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
上传声纹音频文件(同步处理)
|
||||
|
||||
权限:用户只能上传自己的声纹,管理员可操作所有
|
||||
"""
|
||||
# 权限检查
|
||||
if current_user['user_id'] != user_id and current_user['role_id'] != 1:
|
||||
return create_api_response(code="403", message="无权限上传其他用户的声纹")
|
||||
|
||||
# 检查文件格式
|
||||
file_ext = Path(audio_file.filename).suffix.lower()
|
||||
if file_ext not in config_module.ALLOWED_VOICEPRINT_EXTENSIONS:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"不支持的文件格式,仅支持: {', '.join(config_module.ALLOWED_VOICEPRINT_EXTENSIONS)}"
|
||||
)
|
||||
|
||||
# 检查文件大小
|
||||
max_size = SystemConfigService.get_voiceprint_max_size()
|
||||
content = await audio_file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > max_size:
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"文件过大,最大允许 {max_size / 1024 / 1024:.1f}MB"
|
||||
)
|
||||
|
||||
try:
|
||||
# 确保用户目录存在
|
||||
user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id)
|
||||
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名:时间戳.wav
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{timestamp}.wav"
|
||||
file_path = user_voiceprint_dir / filename
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 调用服务处理声纹(提取特征向量,保存到数据库)
|
||||
result = voiceprint_service.save_voiceprint(user_id, str(file_path), file_size)
|
||||
|
||||
return create_api_response(code="200", message="声纹采集成功", data=result)
|
||||
|
||||
except Exception as e:
|
||||
# 如果出错,删除已上传的文件
|
||||
if 'file_path' in locals() and Path(file_path).exists():
|
||||
Path(file_path).unlink()
|
||||
|
||||
return create_api_response(code="500", message=f"声纹采集失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/voiceprint/{user_id}", response_model=None)
|
||||
def delete_voiceprint(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
删除用户声纹数据,允许重新采集
|
||||
|
||||
权限:用户只能删除自己的声纹,管理员可操作所有
|
||||
"""
|
||||
# 权限检查
|
||||
if current_user['user_id'] != user_id and current_user['role_id'] != 1:
|
||||
return create_api_response(code="403", message="无权限删除其他用户的声纹")
|
||||
|
||||
try:
|
||||
success = voiceprint_service.delete_voiceprint(user_id)
|
||||
|
||||
if success:
|
||||
return create_api_response(code="200", message="声纹删除成功")
|
||||
else:
|
||||
return create_api_response(code="404", message="未找到该用户的声纹数据")
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除声纹失败: {str(e)}")
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
from fastapi import HTTPException, status, Request, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""获取当前用户信息的依赖函数"""
|
||||
token = credentials.credentials
|
||||
|
||||
# 验证JWT token
|
||||
payload = jwt_service.verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 从数据库验证用户是否仍然存在且有效
|
||||
user_id = payload.get("user_id")
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def get_current_admin_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""获取当前管理员用户信息的依赖函数"""
|
||||
user = get_current_user(credentials)
|
||||
|
||||
# 检查用户是否是管理员 (role_id = 1)
|
||||
if user.get('role_id') != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def get_optional_current_user(request: Request) -> Optional[dict]:
|
||||
"""可选的用户认证(不强制要求登录)"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
try:
|
||||
token = auth_header.split(" ")[1]
|
||||
payload = jwt_service.verify_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
return cursor.fetchone()
|
||||
except:
|
||||
return None
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载 .env 文件
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
# 基础路径配置
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||
AUDIO_DIR = UPLOAD_DIR / "audio"
|
||||
TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio"
|
||||
MARKDOWN_DIR = UPLOAD_DIR / "markdown"
|
||||
CLIENT_DIR = UPLOAD_DIR / "clients"
|
||||
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
||||
USER_DIR = UPLOAD_DIR / "user"
|
||||
VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
||||
AVATAR_DIR = USER_DIR / "avatar"
|
||||
|
||||
# 文件上传配置
|
||||
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
|
||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
ALLOWED_VOICEPRINT_EXTENSIONS = {".wav"}
|
||||
ALLOWED_CLIENT_EXTENSIONS = {".apk", ".exe", ".dmg", ".deb", ".rpm", ".pkg", ".msi", ".zip", ".tar.gz"}
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_CLIENT_SIZE = 500 * 1024 * 1024 # 500MB for client installers
|
||||
|
||||
# 确保上传目录存在
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
AUDIO_DIR.mkdir(exist_ok=True)
|
||||
MARKDOWN_DIR.mkdir(exist_ok=True)
|
||||
CLIENT_DIR.mkdir(exist_ok=True)
|
||||
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
||||
USER_DIR.mkdir(exist_ok=True)
|
||||
VOICEPRINT_DIR.mkdir(exist_ok=True)
|
||||
AVATAR_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_CONFIG = {
|
||||
'host': os.getenv('DB_HOST', '127.0.0.1'),
|
||||
'user': os.getenv('DB_USER', 'root'),
|
||||
'password': os.getenv('DB_PASSWORD', ''),
|
||||
'database': os.getenv('DB_NAME', 'imeeting'),
|
||||
'port': int(os.getenv('DB_PORT', '3306')),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
'host': os.getenv('API_HOST', '0.0.0.0'),
|
||||
'port': int(os.getenv('API_PORT', '8000'))
|
||||
}
|
||||
|
||||
# 七牛云配置
|
||||
# QINIU_ACCESS_KEY = os.getenv('QINIU_ACCESS_KEY', 'A0tp96HCtg-wZCughTgi5vc2pJnw3btClwxRE_e8')
|
||||
# QINIU_SECRET_KEY = os.getenv('QINIU_SECRET_KEY', 'Lj-MSHpaVbmzpS86kMIjmwikvYOT9iPBjCk9hm6k')
|
||||
# QINIU_BUCKET = os.getenv('QINIU_BUCKET', 'imeeting_dev')
|
||||
# QINIU_DOMAIN = os.getenv('QINIU_DOMAIN', 't0vogyxkz.hn-bkt.clouddn.com')
|
||||
|
||||
# 应用配置
|
||||
APP_CONFIG = {
|
||||
'base_url': os.getenv('BASE_URL', 'http://imeeting.unisspace.com')
|
||||
}
|
||||
|
||||
# Redis配置
|
||||
REDIS_CONFIG = {
|
||||
'host': os.getenv('REDIS_HOST', '127.0.0.1'),
|
||||
'port': int(os.getenv('REDIS_PORT', '6379')),
|
||||
'db': int(os.getenv('REDIS_DB', '0')),
|
||||
'password': os.getenv('REDIS_PASSWORD', ''),
|
||||
'decode_responses': True
|
||||
}
|
||||
|
||||
# Dashscope (Tongyi Qwen) API Key
|
||||
QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-c2bf06ea56b4491ea3d1e37fdb472b8f')
|
||||
|
||||
# 转录轮询配置 - 用于 upload-audio-complete 接口
|
||||
TRANSCRIPTION_POLL_CONFIG = {
|
||||
'poll_interval': int(os.getenv('TRANSCRIPTION_POLL_INTERVAL', '10')), # 轮询间隔:10秒
|
||||
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
from fastapi import HTTPException
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
from app.core.config import DATABASE_CONFIG
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
connection = None
|
||||
try:
|
||||
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
||||
yield connection
|
||||
except Error as e:
|
||||
print(f"数据库连接错误: {e}")
|
||||
raise HTTPException(status_code=500, detail="数据库连接失败")
|
||||
finally:
|
||||
if connection and connection.is_connected():
|
||||
try:
|
||||
# 确保清理任何未读结果
|
||||
if connection.unread_result:
|
||||
connection.consume_results()
|
||||
connection.close()
|
||||
except Exception as e:
|
||||
print(f"关闭数据库连接时出错: {e}")
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
from typing import Union
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
def create_api_response(code: str, message: str, data: Union[dict, list, None] = None) -> JSONResponse:
|
||||
"""Creates a standardized API JSON response."""
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"code": str(code),
|
||||
"message": message,
|
||||
"data": jsonable_encoder(data) if data is not None else {},
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
# 无论从哪里运行,都能正确找到 app 模块
|
||||
current_file = Path(__file__).resolve()
|
||||
project_root = current_file.parent.parent # backend/
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps
|
||||
from app.core.config import UPLOAD_DIR, API_CONFIG
|
||||
|
||||
app = FastAPI(
|
||||
title="iMeeting API",
|
||||
description="智慧会议系统API",
|
||||
version="1.0.2"
|
||||
)
|
||||
|
||||
# 添加CORS中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 静态文件服务 - 提供音频文件下载
|
||||
if UPLOAD_DIR.exists():
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
# 包含API路由
|
||||
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api", tags=["Users"])
|
||||
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
||||
app.include_router(tags.router, prefix="/api", tags=["Tags"])
|
||||
app.include_router(admin.router, prefix="/api", tags=["Admin"])
|
||||
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
|
||||
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
|
||||
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
|
||||
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
|
||||
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
|
||||
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
|
||||
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
|
||||
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
|
||||
app.include_router(audio.router, prefix="/api", tags=["Audio"])
|
||||
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to iMeeting API"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""健康检查端点"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "iMeeting API",
|
||||
"version": "1.0.2"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单的uvicorn配置,避免参数冲突
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=API_CONFIG['host'],
|
||||
port=API_CONFIG['port'],
|
||||
limit_max_requests=1000,
|
||||
timeout_keep_alive=30,
|
||||
reload=True,
|
||||
)
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, Union, List
|
||||
import datetime
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
caption: str
|
||||
email: EmailStr
|
||||
avatar_url: Optional[str] = None
|
||||
token: str
|
||||
role_id: int
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
role_id: int
|
||||
role_name: str
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
caption: str
|
||||
email: EmailStr
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
meetings_created: int
|
||||
meetings_attended: int
|
||||
role_id: int
|
||||
role_name: str
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserInfo]
|
||||
total: int
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: Optional[str] = None
|
||||
caption: str
|
||||
email: EmailStr
|
||||
avatar_url: Optional[str] = None
|
||||
role_id: int
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
caption: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
|
||||
class UserLog(BaseModel):
|
||||
log_id: int
|
||||
user_id: int
|
||||
action_type: str
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
created_at: datetime.datetime
|
||||
|
||||
class AttendeeInfo(BaseModel):
|
||||
user_id: int
|
||||
caption: str
|
||||
|
||||
class Tag(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
class TranscriptionTaskStatus(BaseModel):
|
||||
task_id: str
|
||||
status: str # 'pending', 'processing', 'completed', 'failed'
|
||||
progress: int # 0-100
|
||||
meeting_id: int
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
class Meeting(BaseModel):
|
||||
meeting_id: int
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
summary: Optional[str]
|
||||
created_at: datetime.datetime
|
||||
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
|
||||
creator_id: int
|
||||
creator_username: str
|
||||
audio_file_path: Optional[str] = None
|
||||
transcription_status: Optional[TranscriptionTaskStatus] = None
|
||||
tags: Optional[List[Tag]] = []
|
||||
access_password: Optional[str] = None
|
||||
|
||||
class TranscriptSegment(BaseModel):
|
||||
segment_id: int
|
||||
meeting_id: int
|
||||
speaker_id: Optional[int] = None # AI解析的原始结果
|
||||
speaker_tag: str
|
||||
start_time_ms: int
|
||||
end_time_ms: int
|
||||
text_content: str
|
||||
|
||||
class CreateMeetingRequest(BaseModel):
|
||||
user_id: int
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
attendee_ids: list[int]
|
||||
tags: Optional[str] = None
|
||||
|
||||
class UpdateMeetingRequest(BaseModel):
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
summary: Optional[str]
|
||||
attendee_ids: list[int]
|
||||
tags: Optional[str] = None
|
||||
|
||||
class SpeakerTagUpdateRequest(BaseModel):
|
||||
speaker_id: int # 使用原始speaker_id(整数)
|
||||
new_tag: str
|
||||
|
||||
class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||
updates: List[SpeakerTagUpdateRequest]
|
||||
|
||||
class TranscriptUpdateRequest(BaseModel):
|
||||
segment_id: int
|
||||
text_content: str
|
||||
|
||||
class BatchTranscriptUpdateRequest(BaseModel):
|
||||
updates: List[TranscriptUpdateRequest]
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
class KnowledgeBase(BaseModel):
|
||||
kb_id: int
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
creator_id: int
|
||||
creator_caption: str # To show in the UI
|
||||
is_shared: bool
|
||||
source_meeting_ids: Optional[str] = None
|
||||
user_prompt: Optional[str] = None
|
||||
tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
source_meeting_count: Optional[int] = 0
|
||||
created_by_name: Optional[str] = None
|
||||
|
||||
class KnowledgeBaseTask(BaseModel):
|
||||
task_id: str
|
||||
user_id: int
|
||||
kb_id: int
|
||||
user_prompt: Optional[str] = None
|
||||
status: str
|
||||
progress: int
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
completed_at: Optional[datetime.datetime] = None
|
||||
|
||||
class CreateKnowledgeBaseRequest(BaseModel):
|
||||
title: Optional[str] = None # 改为可选,后台自动生成
|
||||
is_shared: bool
|
||||
user_prompt: Optional[str] = None
|
||||
source_meeting_ids: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
||||
|
||||
class UpdateKnowledgeBaseRequest(BaseModel):
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class KnowledgeBaseListResponse(BaseModel):
|
||||
kbs: List[KnowledgeBase]
|
||||
total: int
|
||||
|
||||
# 客户端下载相关模型
|
||||
class ClientDownload(BaseModel):
|
||||
id: int
|
||||
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
|
||||
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
|
||||
platform_code: str # 新版平台编码,关联 dict_data.dict_code
|
||||
version: str
|
||||
version_code: int
|
||||
download_url: str
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
is_active: bool
|
||||
is_latest: bool
|
||||
min_system_version: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
created_by: Optional[int] = None
|
||||
|
||||
class CreateClientDownloadRequest(BaseModel):
|
||||
platform_type: Optional[str] = None # 兼容旧版
|
||||
platform_name: Optional[str] = None # 兼容旧版
|
||||
platform_code: str # 必填,关联 dict_data
|
||||
version: str
|
||||
version_code: int
|
||||
download_url: str
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_latest: bool = False
|
||||
min_system_version: Optional[str] = None
|
||||
|
||||
class UpdateClientDownloadRequest(BaseModel):
|
||||
platform_type: Optional[str] = None
|
||||
platform_name: Optional[str] = None
|
||||
platform_code: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
version_code: Optional[int] = None
|
||||
download_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_latest: Optional[bool] = None
|
||||
min_system_version: Optional[str] = None
|
||||
|
||||
class ClientDownloadListResponse(BaseModel):
|
||||
clients: List[ClientDownload]
|
||||
total: int
|
||||
|
||||
# 声纹采集相关模型
|
||||
class VoiceprintInfo(BaseModel):
|
||||
vp_id: int
|
||||
user_id: int
|
||||
file_path: str
|
||||
file_size: Optional[int] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
collected_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
class VoiceprintStatus(BaseModel):
|
||||
has_voiceprint: bool
|
||||
vp_id: Optional[int] = None
|
||||
file_path: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
collected_at: Optional[datetime.datetime] = None
|
||||
|
||||
class VoiceprintTemplate(BaseModel):
|
||||
template_text: str
|
||||
duration_seconds: int
|
||||
sample_rate: int
|
||||
channels: int
|
||||
|
||||
# 菜单权限相关模型
|
||||
class MenuInfo(BaseModel):
|
||||
menu_id: int
|
||||
menu_code: str
|
||||
menu_name: str
|
||||
menu_icon: Optional[str] = None
|
||||
menu_url: Optional[str] = None
|
||||
menu_type: str # 'action', 'link', 'divider'
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
description: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
class MenuListResponse(BaseModel):
|
||||
menus: List[MenuInfo]
|
||||
total: int
|
||||
|
||||
class RolePermissionInfo(BaseModel):
|
||||
role_id: int
|
||||
role_name: str
|
||||
menu_ids: List[int]
|
||||
|
||||
class UpdateRolePermissionsRequest(BaseModel):
|
||||
menu_ids: List[int]
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
"""
|
||||
异步知识库服务 - 处理知识库生成的异步任务
|
||||
采用FastAPI BackgroundTasks模式
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import redis
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.llm_service import LLMService
|
||||
|
||||
class AsyncKnowledgeBaseService:
|
||||
"""异步知识库服务类 - 处理知识库相关的异步任务"""
|
||||
|
||||
def __init__(self):
|
||||
from app.core.config import REDIS_CONFIG
|
||||
if 'decode_responses' not in REDIS_CONFIG:
|
||||
REDIS_CONFIG['decode_responses'] = True
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
self.llm_service = LLMService()
|
||||
|
||||
def start_generation(self, user_id: int, kb_id: int, user_prompt: Optional[str], source_meeting_ids: Optional[str], prompt_id: Optional[int] = None, cursor=None) -> str:
|
||||
"""
|
||||
创建异步知识库生成任务
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
kb_id: 知识库ID
|
||||
user_prompt: 用户提示词
|
||||
source_meeting_ids: 源会议ID列表
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
cursor: 数据库游标(可选)
|
||||
|
||||
Returns:
|
||||
str: 任务ID
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# If a cursor is passed, use it directly to avoid creating a new transaction
|
||||
if cursor:
|
||||
query = """
|
||||
INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, prompt_id, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id))
|
||||
else:
|
||||
# Fallback to the old method if no cursor is provided
|
||||
self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id)
|
||||
|
||||
current_time = datetime.now().isoformat()
|
||||
task_data = {
|
||||
'task_id': task_id,
|
||||
'user_id': str(user_id),
|
||||
'kb_id': str(kb_id),
|
||||
'user_prompt': user_prompt if user_prompt else "",
|
||||
'prompt_id': str(prompt_id) if prompt_id else '',
|
||||
'status': 'pending',
|
||||
'progress': '0',
|
||||
'created_at': current_time,
|
||||
'updated_at': current_time
|
||||
}
|
||||
self.redis_client.hset(f"kb_task:{task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"kb_task:{task_id}", 86400)
|
||||
|
||||
print(f"Knowledge base generation task created: {task_id} for kb_id: {kb_id}, prompt_id: {prompt_id}")
|
||||
return task_id
|
||||
|
||||
def _process_task(self, task_id: str):
|
||||
"""
|
||||
处理单个异步任务的函数,设计为由BackgroundTasks调用。
|
||||
"""
|
||||
print(f"Background task started for knowledge base task: {task_id}")
|
||||
try:
|
||||
# 从Redis获取任务数据
|
||||
task_data = self.redis_client.hgetall(f"kb_task:{task_id}")
|
||||
if not task_data:
|
||||
print(f"Error: Task {task_id} not found in Redis for processing.")
|
||||
return
|
||||
|
||||
kb_id = int(task_data['kb_id'])
|
||||
user_prompt = task_data.get('user_prompt', '')
|
||||
prompt_id_str = task_data.get('prompt_id', '')
|
||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
||||
|
||||
# 1. 更新状态为processing
|
||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
||||
|
||||
# 2. 获取关联的会议总结
|
||||
self._update_task_status_in_redis(task_id, 'processing', 20, message="获取关联会议纪要...")
|
||||
source_text = self._get_meeting_summaries(kb_id)
|
||||
|
||||
# 3. 构建提示词
|
||||
self._update_task_status_in_redis(task_id, 'processing', 30, message="准备AI提示词...")
|
||||
full_prompt = self._build_prompt(source_text, user_prompt, prompt_id)
|
||||
|
||||
# 4. 调用LLM API
|
||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...")
|
||||
generated_content = self.llm_service._call_llm_api(full_prompt)
|
||||
if not generated_content:
|
||||
raise Exception("LLM API调用失败或返回空内容")
|
||||
|
||||
# 5. 保存结果到数据库
|
||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...")
|
||||
self._save_result_to_db(kb_id, generated_content, prompt_id)
|
||||
|
||||
# 6. 任务完成
|
||||
self._update_task_in_db(task_id, 'completed', 100)
|
||||
self._update_task_status_in_redis(task_id, 'completed', 100)
|
||||
|
||||
print(f"Task {task_id} completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
print(f"Task {task_id} failed: {error_msg}")
|
||||
# 更新失败状态
|
||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
||||
|
||||
# --- 知识库相关方法 ---
|
||||
|
||||
def _get_meeting_summaries(self, kb_id: int) -> str:
|
||||
"""
|
||||
从数据库获取知识库关联的会议总结
|
||||
|
||||
Args:
|
||||
kb_id: 知识库ID
|
||||
|
||||
Returns:
|
||||
str: 拼接后的会议总结文本
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取知识库的源会议ID列表
|
||||
cursor.execute("SELECT source_meeting_ids FROM knowledge_bases WHERE kb_id = %s", (kb_id,))
|
||||
kb_info = cursor.fetchone()
|
||||
|
||||
if not kb_info or not kb_info['source_meeting_ids']:
|
||||
return ""
|
||||
|
||||
# 解析会议ID列表
|
||||
meeting_ids = [int(m_id) for m_id in kb_info['source_meeting_ids'].split(',') if m_id.isdigit()]
|
||||
if not meeting_ids:
|
||||
return ""
|
||||
|
||||
# 获取所有会议的总结
|
||||
summaries = []
|
||||
for meeting_id in meeting_ids:
|
||||
cursor.execute("SELECT summary FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||
summary = cursor.fetchone()
|
||||
if summary and summary['summary']:
|
||||
summaries.append(summary['summary'])
|
||||
|
||||
# 用分隔符拼接多个会议总结
|
||||
return "\n\n---\n\n".join(summaries)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取会议总结错误: {e}")
|
||||
return ""
|
||||
|
||||
def _build_prompt(self, source_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
构建完整的提示词
|
||||
使用数据库中配置的KNOWLEDGE_TASK提示词模板
|
||||
|
||||
Args:
|
||||
source_text: 源会议总结文本
|
||||
user_prompt: 用户自定义提示词
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
|
||||
Returns:
|
||||
str: 完整的提示词
|
||||
"""
|
||||
# 从数据库获取知识库任务的提示词模板(支持指定prompt_id)
|
||||
system_prompt = self.llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id=prompt_id)
|
||||
|
||||
prompt = f"{system_prompt}\n\n"
|
||||
|
||||
if source_text:
|
||||
prompt += f"请参考以下会议纪要内容:\n{source_text}\n\n"
|
||||
|
||||
prompt += f"用户要求:{user_prompt}"
|
||||
|
||||
return prompt
|
||||
|
||||
def _save_result_to_db(self, kb_id: int, content: str, prompt_id: Optional[int] = None) -> Optional[int]:
|
||||
"""
|
||||
保存生成结果到数据库
|
||||
|
||||
Args:
|
||||
kb_id: 知识库ID
|
||||
content: 生成的内容
|
||||
prompt_id: 提示词模版ID
|
||||
|
||||
Returns:
|
||||
Optional[int]: 知识库ID,失败返回None
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
query = "UPDATE knowledge_bases SET content = %s, prompt_id = %s, updated_at = NOW() WHERE kb_id = %s"
|
||||
cursor.execute(query, (content, prompt_id, kb_id))
|
||||
connection.commit()
|
||||
|
||||
print(f"成功保存知识库内容,kb_id: {kb_id}, prompt_id: {prompt_id}")
|
||||
return kb_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存知识库内容错误: {e}")
|
||||
return None
|
||||
|
||||
# --- 状态查询和数据库操作方法 ---
|
||||
|
||||
def get_task_status(self, task_id: str) -> Dict[str, Any]:
|
||||
"""获取任务状态"""
|
||||
try:
|
||||
task_data = self.redis_client.hgetall(f"kb_task:{task_id}")
|
||||
if not task_data:
|
||||
task_data = self._get_task_from_db(task_id)
|
||||
if not task_data:
|
||||
return {'task_id': task_id, 'status': 'not_found', 'error_message': 'Task not found'}
|
||||
|
||||
return {
|
||||
'task_id': task_id,
|
||||
'status': task_data.get('status', 'unknown'),
|
||||
'progress': int(task_data.get('progress', 0)),
|
||||
'kb_id': int(task_data.get('kb_id', 0)),
|
||||
'created_at': task_data.get('created_at'),
|
||||
'updated_at': task_data.get('updated_at'),
|
||||
'error_message': task_data.get('error_message')
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting task status: {e}")
|
||||
return {'task_id': task_id, 'status': 'error', 'error_message': str(e)}
|
||||
|
||||
def _update_task_status_in_redis(self, task_id: str, status: str, progress: int, message: str = None, error_message: str = None):
|
||||
"""更新Redis中的任务状态"""
|
||||
try:
|
||||
update_data = {
|
||||
'status': status,
|
||||
'progress': str(progress),
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
if message: update_data['message'] = message
|
||||
if error_message: update_data['error_message'] = error_message
|
||||
self.redis_client.hset(f"kb_task:{task_id}", mapping=update_data)
|
||||
except Exception as e:
|
||||
print(f"Error updating task status in Redis: {e}")
|
||||
|
||||
def _save_task_to_db(self, task_id: str, user_id: int, kb_id: int, user_prompt: str, prompt_id: Optional[int] = None):
|
||||
"""保存任务到数据库
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
user_id: 用户ID
|
||||
kb_id: 知识库ID
|
||||
user_prompt: 用户提示词
|
||||
prompt_id: 提示词模版ID(可选),如果为None则使用默认模版
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 如果没有指定 prompt_id,获取默认的知识库总结模版ID
|
||||
if prompt_id is None:
|
||||
cursor.execute(
|
||||
"SELECT id FROM prompts WHERE task_type = 'KNOWLEDGE_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1"
|
||||
)
|
||||
default_prompt = cursor.fetchone()
|
||||
if default_prompt:
|
||||
prompt_id = default_prompt[0]
|
||||
|
||||
insert_query = "INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, prompt_id, status, progress, created_at) VALUES (%s, %s, %s, %s, %s, 'pending', 0, NOW())"
|
||||
cursor.execute(insert_query, (task_id, user_id, kb_id, user_prompt, prompt_id))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Error saving task to database: {e}")
|
||||
raise
|
||||
|
||||
def _update_task_in_db(self, task_id: str, status: str, progress: int, error_message: str = None):
|
||||
"""更新数据库中的任务状态"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
query = "UPDATE knowledge_base_tasks SET status = %s, progress = %s, error_message = %s, updated_at = NOW(), completed_at = IF(%s = 'completed', NOW(), completed_at) WHERE task_id = %s"
|
||||
cursor.execute(query, (status, progress, error_message, status, task_id))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Error updating task in database: {e}")
|
||||
|
||||
def _get_task_from_db(self, task_id: str) -> Optional[Dict[str, str]]:
|
||||
"""从数据库获取任务信息"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = "SELECT * FROM knowledge_base_tasks WHERE task_id = %s"
|
||||
cursor.execute(query, (task_id,))
|
||||
task = cursor.fetchone()
|
||||
if task:
|
||||
# 确保所有字段都是字符串,以匹配Redis的行为
|
||||
return {k: v.isoformat() if isinstance(v, datetime) else str(v) for k, v in task.items()}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error getting task from database: {e}")
|
||||
return None
|
||||
|
||||
# 创建全局实例
|
||||
async_kb_service = AsyncKnowledgeBaseService()
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
"""
|
||||
异步会议服务 - 处理会议总结生成的异步任务
|
||||
采用FastAPI BackgroundTasks模式
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import redis
|
||||
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.llm_service import LLMService
|
||||
|
||||
class AsyncMeetingService:
|
||||
"""异步会议服务类 - 处理会议相关的异步任务"""
|
||||
|
||||
def __init__(self):
|
||||
# 确保redis客户端自动解码响应,代码更简洁
|
||||
if 'decode_responses' not in REDIS_CONFIG:
|
||||
REDIS_CONFIG['decode_responses'] = True
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
||||
|
||||
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||
|
||||
Returns:
|
||||
str: 任务ID
|
||||
"""
|
||||
|
||||
try:
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 在数据库中创建任务记录
|
||||
self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id)
|
||||
|
||||
# 将任务详情存入Redis,用于快速查询状态
|
||||
current_time = datetime.now().isoformat()
|
||||
task_data = {
|
||||
'task_id': task_id,
|
||||
'meeting_id': str(meeting_id),
|
||||
'user_prompt': user_prompt,
|
||||
'prompt_id': str(prompt_id) if prompt_id else '',
|
||||
'status': 'pending',
|
||||
'progress': '0',
|
||||
'created_at': current_time,
|
||||
'updated_at': current_time
|
||||
}
|
||||
self.redis_client.hset(f"llm_task:{task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"llm_task:{task_id}", 86400)
|
||||
|
||||
return task_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error starting summary generation: {e}")
|
||||
raise e
|
||||
|
||||
def _process_task(self, task_id: str):
|
||||
"""
|
||||
处理单个异步任务的函数,设计为由BackgroundTasks调用。
|
||||
"""
|
||||
print(f"Background task started for meeting summary task: {task_id}")
|
||||
try:
|
||||
# 从Redis获取任务数据
|
||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
||||
if not task_data:
|
||||
print(f"Error: Task {task_id} not found in Redis for processing.")
|
||||
return
|
||||
|
||||
meeting_id = int(task_data['meeting_id'])
|
||||
user_prompt = task_data.get('user_prompt', '')
|
||||
prompt_id_str = task_data.get('prompt_id', '')
|
||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
||||
|
||||
# 1. 更新状态为processing
|
||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
||||
|
||||
# 2. 获取会议转录内容
|
||||
self._update_task_status_in_redis(task_id, 'processing', 30, message="获取会议转录内容...")
|
||||
transcript_text = self._get_meeting_transcript(meeting_id)
|
||||
if not transcript_text:
|
||||
raise Exception("无法获取会议转录内容")
|
||||
|
||||
# 3. 构建提示词
|
||||
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
||||
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
|
||||
|
||||
# 4. 调用LLM API
|
||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
|
||||
summary_content = self.llm_service._call_llm_api(full_prompt)
|
||||
if not summary_content:
|
||||
raise Exception("LLM API调用失败或返回空内容")
|
||||
|
||||
# 5. 保存结果到主表
|
||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...")
|
||||
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
|
||||
|
||||
# 6. 任务完成
|
||||
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
|
||||
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
|
||||
print(f"Task {task_id} completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
print(f"Task {task_id} failed: {error_msg}")
|
||||
# 更新失败状态
|
||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
||||
|
||||
def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str, prompt_id: Optional[int] = None):
|
||||
"""
|
||||
监控转录任务,完成后自动生成总结
|
||||
此方法设计为由BackgroundTasks调用,在后台运行
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
transcription_task_id: 转录任务ID
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
|
||||
流程:
|
||||
1. 循环轮询转录任务状态
|
||||
2. 转录成功后自动启动总结任务
|
||||
3. 转录失败或超时则停止轮询并记录日志
|
||||
"""
|
||||
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}")
|
||||
|
||||
# 获取配置参数
|
||||
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
||||
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
||||
max_polls = max_wait_time // poll_interval
|
||||
|
||||
# 延迟导入以避免循环导入
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
poll_count = 0
|
||||
|
||||
try:
|
||||
while poll_count < max_polls:
|
||||
poll_count += 1
|
||||
elapsed_time = poll_count * poll_interval
|
||||
|
||||
try:
|
||||
# 查询转录任务状态
|
||||
status_info = transcription_service.get_task_status(transcription_task_id)
|
||||
current_status = status_info.get('status', 'unknown')
|
||||
progress = status_info.get('progress', 0)
|
||||
|
||||
print(f"[Monitor] Poll {poll_count}/{max_polls} - Status: {current_status}, Progress: {progress}%, Elapsed: {elapsed_time}s")
|
||||
|
||||
# 检查转录是否完成
|
||||
if current_status == 'completed':
|
||||
print(f"[Monitor] Transcription completed successfully for meeting {meeting_id}")
|
||||
|
||||
# 防止并发:检查是否已经有总结任务存在
|
||||
existing_task = self._get_existing_summary_task(meeting_id)
|
||||
if existing_task:
|
||||
print(f"[Monitor] Summary task already exists for meeting {meeting_id}, task_id: {existing_task}, skipping duplicate task creation")
|
||||
else:
|
||||
# 启动总结任务
|
||||
try:
|
||||
summary_task_id = self.start_summary_generation(meeting_id, user_prompt="", prompt_id=prompt_id)
|
||||
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
|
||||
|
||||
# 在后台执行总结任务
|
||||
self._process_task(summary_task_id)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to start summary generation: {e}"
|
||||
print(f"[Monitor] {error_msg}")
|
||||
|
||||
# 监控任务完成,退出循环
|
||||
break
|
||||
|
||||
# 检查转录是否失败
|
||||
elif current_status == 'failed':
|
||||
error_msg = status_info.get('error_message', 'Unknown error')
|
||||
print(f"[Monitor] Transcription failed for meeting {meeting_id}: {error_msg}")
|
||||
# 转录失败,停止监控
|
||||
break
|
||||
|
||||
# 转录还在进行中(pending/processing),继续等待
|
||||
elif current_status in ['pending', 'processing']:
|
||||
# 等待一段时间后继续轮询
|
||||
time.sleep(poll_interval)
|
||||
|
||||
else:
|
||||
# 未知状态
|
||||
print(f"[Monitor] Unknown transcription status: {current_status}")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Monitor] Error checking transcription status: {e}")
|
||||
# 出错后等待一段时间继续尝试
|
||||
time.sleep(poll_interval)
|
||||
|
||||
# 检查是否超时
|
||||
if poll_count >= max_polls:
|
||||
print(f"[Monitor] Transcription monitoring timed out after {max_wait_time}s for meeting {meeting_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}")
|
||||
|
||||
# --- 会议相关方法 ---
|
||||
|
||||
def _get_meeting_transcript(self, meeting_id: int) -> str:
|
||||
"""从数据库获取会议转录内容"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
query = """
|
||||
SELECT speaker_tag, start_time_ms, end_time_ms, text_content
|
||||
FROM transcript_segments
|
||||
WHERE meeting_id = %s
|
||||
ORDER BY start_time_ms
|
||||
"""
|
||||
cursor.execute(query, (meeting_id,))
|
||||
segments = cursor.fetchall()
|
||||
|
||||
if not segments:
|
||||
return ""
|
||||
|
||||
# 组装转录文本
|
||||
transcript_lines = []
|
||||
for speaker_tag, start_time, end_time, text in segments:
|
||||
# 将毫秒转换为分:秒格式
|
||||
start_min = start_time // 60000
|
||||
start_sec = (start_time % 60000) // 1000
|
||||
transcript_lines.append(f"[{start_min:02d}:{start_sec:02d}] 说话人{speaker_tag}: {text}")
|
||||
|
||||
return "\n".join(transcript_lines)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取会议转录内容错误: {e}")
|
||||
return ""
|
||||
|
||||
def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
构建完整的提示词
|
||||
使用数据库中配置的MEETING_TASK提示词模板
|
||||
|
||||
Args:
|
||||
transcript_text: 会议转录文本
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||
"""
|
||||
# 从数据库获取会议任务的提示词模板(支持指定prompt_id)
|
||||
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
||||
|
||||
prompt = f"{system_prompt}\n\n"
|
||||
|
||||
if user_prompt:
|
||||
prompt += f"用户额外要求:{user_prompt}\n\n"
|
||||
|
||||
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"
|
||||
|
||||
return prompt
|
||||
|
||||
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> Optional[int]:
|
||||
"""保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 更新meetings表的summary、user_prompt、prompt_id和updated_at字段
|
||||
update_query = """
|
||||
UPDATE meetings
|
||||
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
|
||||
WHERE meeting_id = %s
|
||||
"""
|
||||
cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id))
|
||||
connection.commit()
|
||||
|
||||
print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}")
|
||||
return meeting_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存总结到数据库错误: {e}")
|
||||
return None
|
||||
|
||||
# --- 状态查询和数据库操作方法 ---
|
||||
|
||||
def get_task_status(self, task_id: str) -> Dict[str, Any]:
|
||||
"""获取任务状态"""
|
||||
try:
|
||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
||||
if not task_data:
|
||||
task_data = self._get_task_from_db(task_id)
|
||||
if not task_data:
|
||||
return {'task_id': task_id, 'status': 'not_found', 'error_message': 'Task not found'}
|
||||
|
||||
return {
|
||||
'task_id': task_id,
|
||||
'status': task_data.get('status', 'unknown'),
|
||||
'progress': int(task_data.get('progress', 0)),
|
||||
'meeting_id': int(task_data.get('meeting_id', 0)),
|
||||
'created_at': task_data.get('created_at'),
|
||||
'updated_at': task_data.get('updated_at'),
|
||||
'result': task_data.get('result'),
|
||||
'error_message': task_data.get('error_message')
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting task status: {e}")
|
||||
return {'task_id': task_id, 'status': 'error', 'error_message': str(e)}
|
||||
|
||||
def get_meeting_llm_tasks(self, meeting_id: int) -> List[Dict[str, Any]]:
|
||||
"""获取会议的所有LLM任务"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = "SELECT task_id, status, progress, user_prompt, created_at, completed_at, error_message FROM llm_tasks WHERE meeting_id = %s ORDER BY created_at DESC"
|
||||
cursor.execute(query, (meeting_id,))
|
||||
tasks = cursor.fetchall()
|
||||
for task in tasks:
|
||||
if task.get('created_at'): task['created_at'] = task['created_at'].isoformat()
|
||||
if task.get('completed_at'): task['completed_at'] = task['completed_at'].isoformat()
|
||||
return tasks
|
||||
except Exception as e:
|
||||
print(f"Error getting meeting LLM tasks: {e}")
|
||||
return []
|
||||
|
||||
def get_meeting_llm_status(self, meeting_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取会议的最新LLM任务状态(与transcription对齐)
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 任务状态信息,如果没有任务返回None
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询最新的LLM任务
|
||||
query = """
|
||||
SELECT task_id, status, progress, created_at, completed_at, error_message
|
||||
FROM llm_tasks
|
||||
WHERE meeting_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (meeting_id,))
|
||||
task_record = cursor.fetchone()
|
||||
|
||||
cursor.close()
|
||||
|
||||
if not task_record:
|
||||
return None
|
||||
|
||||
# 如果任务还在进行中,获取最新状态
|
||||
if task_record['status'] in ['pending', 'processing']:
|
||||
try:
|
||||
return self.get_task_status(task_record['task_id'])
|
||||
except Exception as e:
|
||||
print(f"Failed to get latest LLM task status for meeting {meeting_id}, returning DB status. Error: {e}")
|
||||
|
||||
return {
|
||||
'task_id': task_record['task_id'],
|
||||
'status': task_record['status'],
|
||||
'progress': task_record['progress'] or 0,
|
||||
'meeting_id': meeting_id,
|
||||
'created_at': task_record['created_at'].isoformat() if task_record['created_at'] else None,
|
||||
'completed_at': task_record['completed_at'].isoformat() if task_record['completed_at'] else None,
|
||||
'error_message': task_record['error_message']
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting meeting LLM status: {e}")
|
||||
return None
|
||||
|
||||
def _update_task_status_in_redis(self, task_id: str, status: str, progress: int, message: str = None, result: str = None, error_message: str = None):
|
||||
"""更新Redis中的任务状态"""
|
||||
try:
|
||||
update_data = {
|
||||
'status': status,
|
||||
'progress': str(progress),
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
if message: update_data['message'] = message
|
||||
if result: update_data['result'] = result
|
||||
if error_message: update_data['error_message'] = error_message
|
||||
self.redis_client.hset(f"llm_task:{task_id}", mapping=update_data)
|
||||
except Exception as e:
|
||||
print(f"Error updating task status in Redis: {e}")
|
||||
|
||||
def _save_task_to_db(self, task_id: str, meeting_id: int, user_prompt: str, prompt_id: Optional[int] = None):
|
||||
"""保存任务到数据库
|
||||
|
||||
Args:
|
||||
task_id: 任务ID
|
||||
meeting_id: 会议ID
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果为None则使用默认模版
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
insert_query = "INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, prompt_id, status, progress, created_at) VALUES (%s, %s, %s, %s, 'pending', 0, NOW())"
|
||||
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id))
|
||||
connection.commit()
|
||||
print(f"[Meeting Service] Task saved successfully to database")
|
||||
except Exception as e:
|
||||
print(f"Error saving task to database: {e}")
|
||||
raise
|
||||
|
||||
def _update_task_in_db(self, task_id: str, status: str, progress: int, result: str = None, error_message: str = None):
|
||||
"""更新数据库中的任务状态"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
params = [status, progress, error_message, task_id]
|
||||
if status == 'completed':
|
||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
|
||||
params.insert(2, result)
|
||||
else:
|
||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
|
||||
|
||||
cursor.execute(query, tuple(params))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Error updating task in database: {e}")
|
||||
|
||||
def _get_task_from_db(self, task_id: str) -> Optional[Dict[str, str]]:
|
||||
"""从数据库获取任务信息"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = "SELECT * FROM llm_tasks WHERE task_id = %s"
|
||||
cursor.execute(query, (task_id,))
|
||||
task = cursor.fetchone()
|
||||
if task:
|
||||
# 确保所有字段都是字符串,以匹配Redis的行为
|
||||
return {k: v.isoformat() if isinstance(v, datetime) else str(v) for k, v in task.items()}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error getting task from database: {e}")
|
||||
return None
|
||||
|
||||
def _get_existing_summary_task(self, meeting_id: int) -> Optional[str]:
|
||||
"""
|
||||
检查会议是否已经有总结任务(用于并发控制)
|
||||
返回最新的pending或processing状态的任务ID,如果没有则返回None
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT task_id FROM llm_tasks
|
||||
WHERE meeting_id = %s AND status IN ('pending', 'processing')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (meeting_id,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if result:
|
||||
return result['task_id']
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking existing summary task: {e}")
|
||||
return None
|
||||
|
||||
# 创建全局实例
|
||||
async_meeting_service = AsyncMeetingService()
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
import uuid
|
||||
import json
|
||||
import redis
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from http import HTTPStatus
|
||||
|
||||
import dashscope
|
||||
from dashscope.audio.asr import Transcription
|
||||
|
||||
from app.core.config import QWEN_API_KEY, REDIS_CONFIG, APP_CONFIG
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
class AsyncTranscriptionService:
|
||||
"""异步转录服务类"""
|
||||
|
||||
def __init__(self):
|
||||
dashscope.api_key = QWEN_API_KEY
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
self.base_url = APP_CONFIG['base_url']
|
||||
|
||||
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
|
||||
"""
|
||||
启动异步转录任务
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
audio_file_path: 音频文件相对路径
|
||||
|
||||
Returns:
|
||||
str: 业务任务ID
|
||||
"""
|
||||
try:
|
||||
# 1. 删除该会议的旧转录数据和任务记录,并清空会议总结
|
||||
print(f"Cleaning old transcription data for meeting_id: {meeting_id}")
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 删除旧的转录文本段落
|
||||
cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,))
|
||||
deleted_segments = cursor.rowcount
|
||||
print(f"Deleted {deleted_segments} old transcript segments")
|
||||
|
||||
# 删除旧的转录任务记录
|
||||
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
|
||||
deleted_tasks = cursor.rowcount
|
||||
print(f"Deleted {deleted_tasks} old transcript tasks")
|
||||
|
||||
# 清空会议总结内容
|
||||
cursor.execute("UPDATE meetings SET summary = NULL WHERE meeting_id = %s", (meeting_id,))
|
||||
print(f"Cleared summary for meeting_id: {meeting_id}")
|
||||
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
||||
# 2. 构造完整的文件URL
|
||||
file_url = f"{self.base_url}{audio_file_path}"
|
||||
|
||||
# 获取热词表ID (asr_vocabulary_id)
|
||||
vocabulary_id = SystemConfigService.get_asr_vocabulary_id()
|
||||
|
||||
print(f"Starting transcription for meeting_id: {meeting_id}, file_url: {file_url}, vocabulary_id: {vocabulary_id}")
|
||||
|
||||
# 3. 调用Paraformer异步API
|
||||
call_params = {
|
||||
'model': 'paraformer-v2',
|
||||
'file_urls': [file_url],
|
||||
'language_hints': ['zh', 'en'],
|
||||
'disfluency_removal_enabled': True,
|
||||
'diarization_enabled': True,
|
||||
'speaker_count': 10
|
||||
}
|
||||
if vocabulary_id:
|
||||
call_params['vocabulary_id'] = vocabulary_id
|
||||
|
||||
task_response = Transcription.async_call(**call_params)
|
||||
|
||||
if task_response.status_code != HTTPStatus.OK:
|
||||
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
||||
raise Exception(f"Transcription API error: {task_response.message}")
|
||||
|
||||
paraformer_task_id = task_response.output.task_id
|
||||
business_task_id = str(uuid.uuid4())
|
||||
|
||||
# 4. 在Redis中存储任务映射
|
||||
current_time = datetime.now().isoformat()
|
||||
task_data = {
|
||||
'business_task_id': business_task_id,
|
||||
'paraformer_task_id': paraformer_task_id,
|
||||
'meeting_id': str(meeting_id),
|
||||
'file_url': file_url,
|
||||
'status': 'pending',
|
||||
'progress': '0',
|
||||
'created_at': current_time,
|
||||
'updated_at': current_time
|
||||
}
|
||||
|
||||
# 存储到Redis,过期时间24小时
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
||||
|
||||
# 5. 在数据库中创建任务记录
|
||||
self._save_task_to_db(business_task_id, paraformer_task_id, meeting_id, audio_file_path)
|
||||
|
||||
print(f"Transcription task created: {business_task_id}")
|
||||
return business_task_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error starting transcription: {e}")
|
||||
raise e
|
||||
|
||||
def get_task_status(self, business_task_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取任务状态
|
||||
|
||||
Args:
|
||||
business_task_id: 业务任务ID
|
||||
|
||||
Returns:
|
||||
Dict: 任务状态信息
|
||||
"""
|
||||
task_data = None
|
||||
current_status = 'failed'
|
||||
progress = 0
|
||||
error_message = "An unknown error occurred."
|
||||
|
||||
try:
|
||||
# 1. 获取任务数据(优先Redis,回源DB)
|
||||
task_data = self._get_task_data(business_task_id)
|
||||
paraformer_task_id = task_data['paraformer_task_id']
|
||||
|
||||
# 2. 查询外部API获取状态
|
||||
try:
|
||||
paraformer_response = Transcription.fetch(task=paraformer_task_id)
|
||||
if paraformer_response.status_code != HTTPStatus.OK:
|
||||
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
||||
|
||||
paraformer_status = paraformer_response.output.task_status
|
||||
current_status = self._map_paraformer_status(paraformer_status)
|
||||
progress = self._calculate_progress(paraformer_status)
|
||||
error_message = None #执行成功,清除初始状态
|
||||
|
||||
except Exception as e:
|
||||
current_status = 'failed'
|
||||
progress = 0
|
||||
error_message = f"Error fetching status from provider: {e}"
|
||||
# 直接进入finally块更新状态后返回
|
||||
return
|
||||
|
||||
# 3. 如果任务完成,处理结果
|
||||
if current_status == 'completed' and paraformer_response.output.get('results'):
|
||||
# 防止并发处理:先检查数据库中的状态
|
||||
db_task_status = self._get_task_status_from_db(business_task_id)
|
||||
if db_task_status != 'completed':
|
||||
# 只有当数据库中状态不是completed时才处理
|
||||
# 先将状态更新为completed,作为分布式锁
|
||||
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
|
||||
|
||||
try:
|
||||
self._process_transcription_result(
|
||||
business_task_id,
|
||||
int(task_data['meeting_id']),
|
||||
paraformer_response.output
|
||||
)
|
||||
except Exception as e:
|
||||
current_status = 'failed'
|
||||
progress = 100 # 进度为100,但状态是失败
|
||||
error_message = f"Error processing transcription result: {e}"
|
||||
print(error_message)
|
||||
else:
|
||||
print(f"Task {business_task_id} already processed, skipping duplicate processing")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error getting task status: {e}"
|
||||
print(error_message)
|
||||
current_status = 'failed'
|
||||
progress = 0
|
||||
|
||||
finally:
|
||||
# 4. 更新Redis和数据库状态
|
||||
updated_at = datetime.now().isoformat()
|
||||
|
||||
# 更新Redis
|
||||
update_data = {
|
||||
'status': current_status,
|
||||
'progress': str(progress),
|
||||
'updated_at': updated_at
|
||||
}
|
||||
if error_message:
|
||||
update_data['error_message'] = error_message
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
|
||||
|
||||
# 更新数据库
|
||||
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
|
||||
|
||||
# 5. 构造并返回最终结果
|
||||
result = {
|
||||
'task_id': business_task_id,
|
||||
'status': current_status,
|
||||
'progress': progress,
|
||||
'error_message': error_message,
|
||||
'updated_at': updated_at,
|
||||
'meeting_id': None,
|
||||
'created_at': None,
|
||||
}
|
||||
if task_data:
|
||||
result['meeting_id'] = int(task_data['meeting_id'])
|
||||
result['created_at'] = task_data.get('created_at')
|
||||
|
||||
return result
|
||||
|
||||
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
||||
"""从Redis或数据库获取任务数据"""
|
||||
# 尝试从Redis获取
|
||||
task_data_bytes = self.redis_client.hgetall(f"task:{business_task_id}")
|
||||
if task_data_bytes and task_data_bytes.get(b'paraformer_task_id'):
|
||||
# Redis返回的是bytes,需要解码
|
||||
return {k.decode('utf-8'): v.decode('utf-8') for k, v in task_data_bytes.items()}
|
||||
|
||||
# 如果Redis没有,从数据库回源
|
||||
task_data_from_db = self._get_task_from_db(business_task_id)
|
||||
if not task_data_from_db or not task_data_from_db.get('paraformer_task_id'):
|
||||
raise Exception("Task not found in DB or paraformer_task_id is missing")
|
||||
|
||||
# 将从DB获取的数据缓存回Redis
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data_from_db)
|
||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
||||
|
||||
return task_data_from_db
|
||||
|
||||
def get_meeting_transcription_status(self, meeting_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取会议的转录任务状态
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 任务状态信息,如果没有任务返回None
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询最新的转录任务
|
||||
query = """
|
||||
SELECT task_id, status, progress, created_at, completed_at, error_message
|
||||
FROM transcript_tasks
|
||||
WHERE meeting_id = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (meeting_id,))
|
||||
task_record = cursor.fetchone()
|
||||
|
||||
# 关闭游标
|
||||
cursor.close()
|
||||
|
||||
if not task_record:
|
||||
return None
|
||||
|
||||
# 如果任务还在进行中,获取最新状态
|
||||
if task_record['status'] in ['pending', 'processing']:
|
||||
try:
|
||||
return self.get_task_status(task_record['task_id'])
|
||||
except Exception as e:
|
||||
print(f"Failed to get latest task status for meeting {meeting_id}, returning DB status. Error: {e}")
|
||||
|
||||
return {
|
||||
'task_id': task_record['task_id'],
|
||||
'status': task_record['status'],
|
||||
'progress': task_record['progress'] or 0,
|
||||
'meeting_id': meeting_id,
|
||||
'created_at': task_record['created_at'].isoformat() if task_record['created_at'] else None,
|
||||
'completed_at': task_record['completed_at'].isoformat() if task_record['completed_at'] else None,
|
||||
'error_message': task_record['error_message']
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting meeting transcription status: {e}")
|
||||
return None
|
||||
|
||||
def _map_paraformer_status(self, paraformer_status: str) -> str:
|
||||
"""映射Paraformer状态到业务状态"""
|
||||
status_mapping = {
|
||||
'PENDING': 'pending',
|
||||
'RUNNING': 'processing',
|
||||
'SUCCEEDED': 'completed',
|
||||
'FAILED': 'failed'
|
||||
}
|
||||
return status_mapping.get(paraformer_status, 'unknown')
|
||||
|
||||
def _calculate_progress(self, paraformer_status: str) -> int:
|
||||
"""根据Paraformer状态计算进度"""
|
||||
progress_mapping = {
|
||||
'PENDING': 10,
|
||||
'RUNNING': 50,
|
||||
'SUCCEEDED': 100,
|
||||
'FAILED': 0
|
||||
}
|
||||
return progress_mapping.get(paraformer_status, 0)
|
||||
|
||||
def _save_task_to_db(self, business_task_id: str, paraformer_task_id: str, meeting_id: int, audio_file_path: str):
|
||||
"""保存任务记录到数据库"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 插入转录任务记录
|
||||
insert_task_query = """
|
||||
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
|
||||
VALUES (%s, %s, %s, 'pending', 0, NOW())
|
||||
"""
|
||||
cursor.execute(insert_task_query, (business_task_id, paraformer_task_id, meeting_id))
|
||||
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving task to database: {e}")
|
||||
raise e
|
||||
|
||||
def _update_task_status_in_db(self, business_task_id: str, status: str, progress: int, error_message: Optional[str] = None):
|
||||
"""更新数据库中的任务状态"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
params = [status, progress, error_message, business_task_id]
|
||||
if status == 'completed':
|
||||
update_query = """
|
||||
UPDATE transcript_tasks
|
||||
SET status = %s, progress = %s, completed_at = NOW(), error_message = %s
|
||||
WHERE task_id = %s
|
||||
"""
|
||||
else:
|
||||
update_query = """
|
||||
UPDATE transcript_tasks
|
||||
SET status = %s, progress = %s, error_message = %s
|
||||
WHERE task_id = %s
|
||||
"""
|
||||
|
||||
cursor.execute(update_query, tuple(params))
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating task status in database: {e}")
|
||||
|
||||
def _get_task_status_from_db(self, business_task_id: str) -> Optional[str]:
|
||||
"""从数据库获取任务状态(用于并发控制)"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = "SELECT status FROM transcript_tasks WHERE task_id = %s"
|
||||
cursor.execute(query, (business_task_id,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if result:
|
||||
return result['status']
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting task status from database: {e}")
|
||||
return None
|
||||
|
||||
def _get_task_from_db(self, business_task_id: str) -> Optional[Dict[str, str]]:
|
||||
"""从数据库获取任务信息"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.created_at
|
||||
FROM transcript_tasks tt
|
||||
WHERE tt.task_id = %s
|
||||
"""
|
||||
cursor.execute(query, (business_task_id,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if result:
|
||||
# 转换为与Redis一致的字符串格式
|
||||
return {
|
||||
'business_task_id': result['business_task_id'],
|
||||
'paraformer_task_id': result['paraformer_task_id'],
|
||||
'meeting_id': str(result['meeting_id']),
|
||||
'status': result['status'],
|
||||
'created_at': result['created_at'].isoformat() if result['created_at'] else None
|
||||
}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting task from database: {e}")
|
||||
return None
|
||||
|
||||
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
|
||||
"""
|
||||
处理转录结果.
|
||||
如果处理失败,此函数会抛出异常.
|
||||
"""
|
||||
try:
|
||||
if not paraformer_output.get('results'):
|
||||
raise Exception("No transcription results found in the provider response.")
|
||||
|
||||
transcription_url = paraformer_output['results'][0]['transcription_url']
|
||||
print(f"Fetching transcription from URL: {transcription_url}")
|
||||
|
||||
response = requests.get(transcription_url)
|
||||
response.raise_for_status()
|
||||
transcription_data = response.json()
|
||||
|
||||
# 保存转录内容到数据库
|
||||
self._save_segments_to_db(transcription_data, meeting_id)
|
||||
|
||||
print(f"Transcription result processed for task: {business_task_id}")
|
||||
|
||||
except Exception as e:
|
||||
# 记录具体错误并重新抛出,以便上层捕获
|
||||
print(f"Error processing transcription result for task {business_task_id}: {e}")
|
||||
raise
|
||||
|
||||
def _save_segments_to_db(self, data: dict, meeting_id: int):
|
||||
"""保存转录分段到数据库"""
|
||||
segments_to_insert = []
|
||||
for transcript in data.get('transcripts', []):
|
||||
for sentence in transcript.get('sentences', []):
|
||||
speaker_id = sentence.get('speaker_id', -1)
|
||||
segments_to_insert.append((
|
||||
meeting_id,
|
||||
speaker_id,
|
||||
f"发言人 {speaker_id}", # 默认speaker_tag
|
||||
sentence.get('begin_time'),
|
||||
sentence.get('end_time'),
|
||||
sentence.get('text')
|
||||
))
|
||||
|
||||
if not segments_to_insert:
|
||||
print("No segments to save.")
|
||||
return
|
||||
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 清除该会议的现有转录分段(防止重复插入)
|
||||
delete_query = "DELETE FROM transcript_segments WHERE meeting_id = %s"
|
||||
cursor.execute(delete_query, (meeting_id,))
|
||||
print(f"Deleted existing segments for meeting_id: {meeting_id}")
|
||||
|
||||
# 插入新的转录分段
|
||||
insert_query = '''
|
||||
INSERT INTO transcript_segments (meeting_id, speaker_id, speaker_tag, start_time_ms, end_time_ms, text_content)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
'''
|
||||
cursor.executemany(insert_query, segments_to_insert)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
print(f"Successfully saved {len(segments_to_insert)} segments to the database for meeting_id: {meeting_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Database error when saving segments: {e}")
|
||||
raise
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"""
|
||||
音频处理服务
|
||||
|
||||
处理已保存的完整音频文件:数据库更新、转录、自动总结
|
||||
"""
|
||||
from fastapi import BackgroundTasks
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import BASE_DIR
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
|
||||
def handle_audio_upload(
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
file_size: int,
|
||||
meeting_id: int,
|
||||
current_user: dict,
|
||||
auto_summarize: bool = True,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
prompt_id: int = None,
|
||||
duration: int = 0
|
||||
) -> dict:
|
||||
"""
|
||||
处理已保存的完整音频文件
|
||||
|
||||
职责:
|
||||
1. 权限检查
|
||||
2. 检查已有文件和转录记录
|
||||
3. 更新数据库(audio_files 表)
|
||||
4. 启动转录任务
|
||||
5. 可选启动自动总结监控
|
||||
|
||||
Args:
|
||||
file_path: 已保存的文件路径(相对于 BASE_DIR 的路径,如 /uploads/audio/123/xxx.webm)
|
||||
file_name: 原始文件名
|
||||
file_size: 文件大小(字节)
|
||||
meeting_id: 会议ID
|
||||
current_user: 当前用户信息
|
||||
auto_summarize: 是否自动生成总结(默认True)
|
||||
background_tasks: FastAPI 后台任务对象
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
duration: 音频时长(秒)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"success": bool, # 是否成功
|
||||
"response": dict, # 如果需要返回,这里是响应数据
|
||||
"file_info": dict, # 文件信息 (成功时)
|
||||
"transcription_task_id": str, # 转录任务ID (成功时)
|
||||
"replaced_existing": bool, # 是否替换了现有文件 (成功时)
|
||||
"has_transcription": bool # 原来是否有转录记录 (成功时)
|
||||
}
|
||||
"""
|
||||
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}")
|
||||
|
||||
# 1. 权限和已有文件检查
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查会议是否存在及权限
|
||||
cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||
meeting = cursor.fetchone()
|
||||
if not meeting:
|
||||
return {
|
||||
"success": False,
|
||||
"response": create_api_response(code="404", message="会议不存在")
|
||||
}
|
||||
if meeting['user_id'] != current_user['user_id']:
|
||||
return {
|
||||
"success": False,
|
||||
"response": create_api_response(code="403", message="无权限操作此会议")
|
||||
}
|
||||
|
||||
# 检查已有音频文件
|
||||
cursor.execute(
|
||||
"SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s",
|
||||
(meeting_id,)
|
||||
)
|
||||
existing_info = cursor.fetchone()
|
||||
|
||||
# 检查是否有转录记录
|
||||
has_transcription = False
|
||||
if existing_info:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s",
|
||||
(meeting_id,)
|
||||
)
|
||||
has_transcription = cursor.fetchone()['segment_count'] > 0
|
||||
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"response": create_api_response(code="500", message=f"检查已有文件失败: {str(e)}")
|
||||
}
|
||||
|
||||
# 2. 删除旧的音频文件(如果存在)
|
||||
replaced_existing = existing_info is not None
|
||||
if replaced_existing and existing_info['file_path']:
|
||||
old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/')
|
||||
if old_file_path.exists():
|
||||
try:
|
||||
os.remove(old_file_path)
|
||||
print(f"Deleted old audio file: {old_file_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete old file {old_file_path}: {e}")
|
||||
|
||||
transcription_task_id = None
|
||||
|
||||
try:
|
||||
# 3. 更新数据库记录
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
if replaced_existing:
|
||||
cursor.execute(
|
||||
'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, duration = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s',
|
||||
(file_name, file_path, file_size, duration, meeting_id)
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, duration, upload_time) VALUES (%s, %s, %s, %s, %s, NOW())',
|
||||
(meeting_id, file_name, file_path, file_size, duration)
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
||||
# 4. 启动转录任务
|
||||
try:
|
||||
transcription_task_id = transcription_service.start_transcription(meeting_id, file_path)
|
||||
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
|
||||
|
||||
# 5. 如果启用自动总结且提供了 background_tasks,添加监控任务
|
||||
if auto_summarize and transcription_task_id and background_tasks:
|
||||
background_tasks.add_task(
|
||||
async_meeting_service.monitor_and_auto_summarize,
|
||||
meeting_id,
|
||||
transcription_task_id,
|
||||
prompt_id # 传递 prompt_id 给自动总结监控任务
|
||||
)
|
||||
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to start transcription: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# 出错时的处理(文件已保存,不删除)
|
||||
return {
|
||||
"success": False,
|
||||
"response": create_api_response(code="500", message=f"处理失败: {str(e)}")
|
||||
}
|
||||
|
||||
# 6. 返回成功结果
|
||||
return {
|
||||
"success": True,
|
||||
"file_info": {
|
||||
"file_name": file_name,
|
||||
"file_path": file_path,
|
||||
"file_size": file_size
|
||||
},
|
||||
"transcription_task_id": transcription_task_id,
|
||||
"replaced_existing": replaced_existing,
|
||||
"has_transcription": has_transcription
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import jwt
|
||||
import redis
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.config import REDIS_CONFIG
|
||||
import os
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-super-secret-key-change-in-production')
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7天
|
||||
|
||||
class JWTService:
|
||||
def __init__(self):
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
def create_access_token(self, data: Dict[str, Any]) -> str:
|
||||
"""创建JWT访问令牌"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
||||
# 将token存储到Redis,用于管理和撤销
|
||||
user_id = data.get("user_id")
|
||||
if user_id:
|
||||
self.redis_client.setex(
|
||||
f"token:{user_id}:{encoded_jwt}",
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Redis需要秒
|
||||
"active"
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""验证JWT令牌"""
|
||||
try:
|
||||
# 解码JWT
|
||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
# 检查token类型
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
# 检查token是否在Redis中且未被撤销
|
||||
redis_key = f"token:{user_id}:{token}"
|
||||
if not self.redis_client.exists(redis_key):
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def revoke_token(self, token: str, user_id: int) -> bool:
|
||||
"""撤销token"""
|
||||
try:
|
||||
redis_key = f"token:{user_id}:{token}"
|
||||
return self.redis_client.delete(redis_key) > 0
|
||||
except:
|
||||
return False
|
||||
|
||||
def revoke_all_user_tokens(self, user_id: int) -> int:
|
||||
"""撤销用户的所有token"""
|
||||
try:
|
||||
pattern = f"token:{user_id}:*"
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
return self.redis_client.delete(*keys)
|
||||
return 0
|
||||
except:
|
||||
return 0
|
||||
|
||||
def refresh_token(self, token: str) -> Optional[str]:
|
||||
"""刷新token(可选功能)"""
|
||||
payload = self.verify_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# 撤销旧token
|
||||
user_id = payload.get("user_id")
|
||||
self.revoke_token(token, user_id)
|
||||
|
||||
# 创建新token
|
||||
new_data = {
|
||||
"user_id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"caption": payload.get("caption")
|
||||
}
|
||||
return self.create_access_token(new_data)
|
||||
|
||||
# 全局实例
|
||||
jwt_service = JWTService()
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import json
|
||||
import dashscope
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Dict, List, Generator
|
||||
import app.core.config as config_module
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
||||
|
||||
def __init__(self):
|
||||
# 设置dashscope API key
|
||||
dashscope.api_key = config_module.QWEN_API_KEY
|
||||
|
||||
@property
|
||||
def model_name(self):
|
||||
"""动态获取模型名称"""
|
||||
return SystemConfigService.get_llm_model_name(default="qwen-plus")
|
||||
|
||||
@property
|
||||
def system_prompt(self):
|
||||
"""动态获取系统提示词(fallback,优先使用prompts表)"""
|
||||
# 保留config中的system_prompt作为后备
|
||||
return config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。")
|
||||
|
||||
@property
|
||||
def time_out(self):
|
||||
"""动态获取超时时间"""
|
||||
return SystemConfigService.get_llm_timeout(default=120)
|
||||
|
||||
@property
|
||||
def temperature(self):
|
||||
"""动态获取temperature"""
|
||||
return SystemConfigService.get_llm_temperature(default=0.7)
|
||||
|
||||
@property
|
||||
def top_p(self):
|
||||
"""动态获取top_p"""
|
||||
return SystemConfigService.get_llm_top_p(default=0.9)
|
||||
|
||||
def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
统一的提示词获取方法
|
||||
|
||||
Args:
|
||||
task_type: 任务类型,如 'MEETING_TASK', 'KNOWLEDGE_TASK' 等
|
||||
cursor: 数据库游标,如果传入则使用,否则创建新连接
|
||||
prompt_id: 可选的提示词ID,如果指定则使用该提示词,否则使用默认提示词
|
||||
|
||||
Returns:
|
||||
str: 提示词内容,如果未找到返回默认提示词
|
||||
"""
|
||||
# 如果指定了 prompt_id,直接获取该提示词
|
||||
if prompt_id:
|
||||
query = """
|
||||
SELECT content
|
||||
FROM prompts
|
||||
WHERE id = %s AND task_type = %s AND is_active = TRUE
|
||||
LIMIT 1
|
||||
"""
|
||||
params = (prompt_id, task_type)
|
||||
else:
|
||||
# 否则获取默认提示词
|
||||
query = """
|
||||
SELECT content
|
||||
FROM prompts
|
||||
WHERE task_type = %s
|
||||
AND is_default = TRUE
|
||||
AND is_active = TRUE
|
||||
LIMIT 1
|
||||
"""
|
||||
params = (task_type,)
|
||||
|
||||
if cursor:
|
||||
cursor.execute(query, params)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
return result['content'] if isinstance(result, dict) else result[0]
|
||||
else:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
return result['content']
|
||||
|
||||
# 返回默认提示词
|
||||
return self._get_default_prompt(task_type)
|
||||
|
||||
def _get_default_prompt(self, task_name: str) -> str:
|
||||
"""获取默认提示词"""
|
||||
default_prompts = {
|
||||
'MEETING_TASK': self.system_prompt, # 使用配置文件中的系统提示词
|
||||
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
|
||||
}
|
||||
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
|
||||
|
||||
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
|
||||
"""流式调用阿里Qwen3大模型API"""
|
||||
try:
|
||||
responses = dashscope.Generation.call(
|
||||
model=self.model_name,
|
||||
prompt=prompt,
|
||||
stream=True,
|
||||
timeout=self.time_out,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p,
|
||||
incremental_output=True # 开启增量输出模式
|
||||
)
|
||||
|
||||
for response in responses:
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
# 增量输出内容
|
||||
new_content = response.output.get('text', '')
|
||||
if new_content:
|
||||
yield new_content
|
||||
else:
|
||||
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"流式调用大模型API错误: {e}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
|
||||
def _call_llm_api(self, prompt: str) -> Optional[str]:
|
||||
"""调用阿里Qwen3大模型API(非流式)"""
|
||||
try:
|
||||
response = dashscope.Generation.call(
|
||||
model=self.model_name,
|
||||
prompt=prompt,
|
||||
timeout=self.time_out,
|
||||
temperature=self.temperature,
|
||||
top_p=self.top_p
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
return response.output.get('text', '')
|
||||
else:
|
||||
print(f"API调用失败: {response.status_code}, {response.message}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"调用大模型API错误: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == '__main__':
|
||||
print("--- 运行LLM服务测试 ---")
|
||||
llm_service = LLMService()
|
||||
|
||||
# 测试获取任务提示词
|
||||
meeting_prompt = llm_service.get_task_prompt('MEETING_TASK')
|
||||
print(f"会议任务提示词: {meeting_prompt[:100]}...")
|
||||
|
||||
knowledge_prompt = llm_service.get_task_prompt('KNOWLEDGE_TASK')
|
||||
print(f"知识库任务提示词: {knowledge_prompt[:100]}...")
|
||||
|
||||
print("--- LLM服务测试完成 ---")
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
from qiniu import Auth, put_file_v2, etag, BucketManager
|
||||
import qiniu.config
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, Tuple
|
||||
from app.core.config import QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
|
||||
|
||||
|
||||
class QiniuService:
|
||||
def __init__(self):
|
||||
self.access_key = QINIU_ACCESS_KEY
|
||||
self.secret_key = QINIU_SECRET_KEY
|
||||
self.bucket_name = QINIU_BUCKET
|
||||
self.domain = QINIU_DOMAIN
|
||||
|
||||
if not self.access_key or not self.secret_key:
|
||||
print("ERROR: 七牛云Access Key或Secret Key为空!")
|
||||
if not self.bucket_name:
|
||||
print("ERROR: 七牛云Bucket名称为空!")
|
||||
if not self.domain:
|
||||
print("ERROR: 七牛云Domain为空!")
|
||||
|
||||
self.q = Auth(self.access_key, self.secret_key)
|
||||
|
||||
|
||||
def upload_audio_file(self, file_path: str, meeting_id: int, original_filename: str) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Upload audio file to Qiniu cloud storage
|
||||
|
||||
Args:
|
||||
file_path: Local file path
|
||||
meeting_id: Meeting ID for directory structure
|
||||
original_filename: Original filename
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, url: str, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
# Validate file exists
|
||||
if not os.path.exists(file_path):
|
||||
return False, "", f"File not found: {file_path}"
|
||||
|
||||
file_extension = os.path.splitext(original_filename)[1]
|
||||
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
key = f"record/{meeting_id}/{unique_filename}"
|
||||
|
||||
# Generate upload token
|
||||
token = self.q.upload_token(self.bucket_name, key, 3600)
|
||||
print(f"DEBUG: 生成音频上传token成功, key: {key}")
|
||||
print(f"DEBUG: token前50位: {token[:50] if token else 'None'}")
|
||||
|
||||
# Upload file with retry mechanism
|
||||
ret, info = put_file_v2(token, key, file_path)
|
||||
|
||||
print(f"DEBUG: Qiniu upload response - ret: {ret}, info: {info}")
|
||||
print(f"DEBUG: Qiniu upload info details - status_code: {info.status_code}, text_body: {info.text_body}")
|
||||
|
||||
# Check if upload was successful
|
||||
# For put_file_v2, we need to check the info.status_code
|
||||
if info.status_code == 200 and ret and 'key' in ret and ret['key'] == key:
|
||||
url = f"http://{self.domain}/{key}"
|
||||
return True, url, None
|
||||
else:
|
||||
# Extract error information properly
|
||||
error_msg = f"Upload failed: status_code={info.status_code}"
|
||||
if hasattr(info, 'text_body') and info.text_body:
|
||||
error_msg += f", response={info.text_body}"
|
||||
return False, "", error_msg
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False, "", f"Upload error: {str(e)}"
|
||||
|
||||
def upload_markdown_image(self, file_path: str, meeting_id: int, original_filename: str) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Upload markdown image to Qiniu cloud storage
|
||||
|
||||
Args:
|
||||
file_path: Local file path
|
||||
meeting_id: Meeting ID for directory structure
|
||||
original_filename: Original filename
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, url: str, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
# Validate file exists
|
||||
if not os.path.exists(file_path):
|
||||
return False, "", f"File not found: {file_path}"
|
||||
|
||||
file_extension = os.path.splitext(original_filename)[1]
|
||||
unique_filename = f"{uuid.uuid4()}{file_extension}"
|
||||
key = f"markdown/{meeting_id}/{unique_filename}"
|
||||
|
||||
# Generate upload token
|
||||
token = self.q.upload_token(self.bucket_name, key, 3600)
|
||||
|
||||
# Upload file with retry mechanism
|
||||
ret, info = put_file_v2(token, key, file_path)
|
||||
|
||||
print(f"DEBUG: Qiniu image upload response - ret: {ret}, info: {info}")
|
||||
print(f"DEBUG: Qiniu image upload info details - status_code: {info.status_code}, text_body: {info.text_body}")
|
||||
|
||||
# Check if upload was successful
|
||||
# For put_file_v2, we need to check the info.status_code
|
||||
if info.status_code == 200 and ret and 'key' in ret and ret['key'] == key:
|
||||
url = f"http://{self.domain}/{key}"
|
||||
return True, url, None
|
||||
else:
|
||||
# Extract error information properly
|
||||
error_msg = f"Upload failed: status_code={info.status_code}"
|
||||
if hasattr(info, 'text_body') and info.text_body:
|
||||
error_msg += f", response={info.text_body}"
|
||||
return False, "", error_msg
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False, "", f"Upload error: {str(e)}"
|
||||
|
||||
def delete_file(self, key: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Delete file from Qiniu cloud storage
|
||||
|
||||
Args:
|
||||
key: File key in Qiniu storage
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
from qiniu import BucketManager
|
||||
bucket = BucketManager(self.q)
|
||||
ret, info = bucket.delete(self.bucket_name, key)
|
||||
|
||||
if ret is None:
|
||||
return True, None
|
||||
else:
|
||||
return False, f"Delete failed: {info}"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Delete error: {str(e)}"
|
||||
|
||||
|
||||
# Global instance
|
||||
qiniu_service = QiniuService()
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,279 @@
|
|||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
|
||||
class SystemConfigService:
|
||||
"""系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置"""
|
||||
|
||||
DICT_TYPE = 'system_config'
|
||||
|
||||
# 配置键常量
|
||||
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
|
||||
TIMELINE_PAGESIZE = 'timeline_pagesize'
|
||||
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
||||
MAX_AUDIO_SIZE = 'max_audio_size'
|
||||
|
||||
# 声纹配置
|
||||
VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text'
|
||||
VOICEPRINT_MAX_SIZE = 'voiceprint_max_size'
|
||||
VOICEPRINT_DURATION = 'voiceprint_duration'
|
||||
VOICEPRINT_SAMPLE_RATE = 'voiceprint_sample_rate'
|
||||
VOICEPRINT_CHANNELS = 'voiceprint_channels'
|
||||
|
||||
# LLM模型配置
|
||||
LLM_MODEL_NAME = 'llm_model_name'
|
||||
LLM_TIMEOUT = 'llm_timeout'
|
||||
LLM_TEMPERATURE = 'llm_temperature'
|
||||
LLM_TOP_P = 'llm_top_p'
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
||||
"""
|
||||
获取指定配置项的值
|
||||
|
||||
Args:
|
||||
dict_code: 配置项编码
|
||||
default_value: 默认值,如果配置不存在则返回此值
|
||||
|
||||
Returns:
|
||||
配置项的值
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT extension_attr
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (cls.DICT_TYPE, dict_code))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if result and result['extension_attr']:
|
||||
try:
|
||||
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
|
||||
return ext_attr.get('value', default_value)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
return default_value
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting config {dict_code}: {e}")
|
||||
return default_value
|
||||
|
||||
@classmethod
|
||||
def set_config(cls, dict_code: str, value: Any, label_cn: str = None) -> bool:
|
||||
"""
|
||||
设置指定配置项的值
|
||||
|
||||
Args:
|
||||
dict_code: 配置项编码
|
||||
value: 配置值
|
||||
label_cn: 配置项中文名称(仅在配置不存在时需要)
|
||||
|
||||
Returns:
|
||||
是否设置成功
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查配置是否存在
|
||||
cursor.execute(
|
||||
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
|
||||
(cls.DICT_TYPE, dict_code)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
extension_attr = json.dumps({"value": value}, ensure_ascii=False)
|
||||
|
||||
if existing:
|
||||
# 更新现有配置
|
||||
update_query = """
|
||||
UPDATE dict_data
|
||||
SET extension_attr = %s, update_time = NOW()
|
||||
WHERE dict_type = %s AND dict_code = %s
|
||||
"""
|
||||
cursor.execute(update_query, (extension_attr, cls.DICT_TYPE, dict_code))
|
||||
else:
|
||||
# 插入新配置
|
||||
if not label_cn:
|
||||
label_cn = dict_code
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO dict_data (
|
||||
dict_type, dict_code, parent_code, label_cn,
|
||||
extension_attr, status, sort_order
|
||||
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
|
||||
"""
|
||||
cursor.execute(insert_query, (cls.DICT_TYPE, dict_code, label_cn, extension_attr))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error setting config {dict_code}: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_all_configs(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
获取所有系统配置
|
||||
|
||||
Returns:
|
||||
配置字典 {dict_code: value}
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT dict_code, label_cn, extension_attr
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND status = 1
|
||||
ORDER BY sort_order
|
||||
"""
|
||||
cursor.execute(query, (cls.DICT_TYPE,))
|
||||
results = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
configs = {}
|
||||
for row in results:
|
||||
if row['extension_attr']:
|
||||
try:
|
||||
ext_attr = json.loads(row['extension_attr']) if isinstance(row['extension_attr'], str) else row['extension_attr']
|
||||
configs[row['dict_code']] = ext_attr.get('value')
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
configs[row['dict_code']] = None
|
||||
else:
|
||||
configs[row['dict_code']] = None
|
||||
|
||||
return configs
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting all configs: {e}")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def batch_set_configs(cls, configs: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
批量设置配置项
|
||||
|
||||
Args:
|
||||
configs: 配置字典 {dict_code: value}
|
||||
|
||||
Returns:
|
||||
是否全部设置成功
|
||||
"""
|
||||
success = True
|
||||
for dict_code, value in configs.items():
|
||||
if not cls.set_config(dict_code, value):
|
||||
success = False
|
||||
return success
|
||||
|
||||
# 便捷方法:获取特定配置
|
||||
@classmethod
|
||||
def get_asr_vocabulary_id(cls) -> Optional[str]:
|
||||
"""获取ASR热词字典ID"""
|
||||
return cls.get_config(cls.ASR_VOCABULARY_ID)
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
|
||||
"""获取声纹采集模版"""
|
||||
return cls.get_config(cls.VOICEPRINT_TEMPLATE_TEXT, default)
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
||||
"""获取声纹文件大小限制 (bytes), 默认5MB"""
|
||||
value = cls.get_config(cls.VOICEPRINT_MAX_SIZE, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_duration(cls, default: int = 12) -> int:
|
||||
"""获取声纹采集最短时长 (秒)"""
|
||||
value = cls.get_config(cls.VOICEPRINT_DURATION, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
|
||||
"""获取声纹采样率"""
|
||||
value = cls.get_config(cls.VOICEPRINT_SAMPLE_RATE, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_channels(cls, default: int = 1) -> int:
|
||||
"""获取声纹通道数"""
|
||||
value = cls.get_config(cls.VOICEPRINT_CHANNELS, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_timeline_pagesize(cls, default: int = 10) -> int:
|
||||
"""获取会议时间轴每页数量"""
|
||||
value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default))
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_default_reset_password(cls, default: str = "111111") -> str:
|
||||
"""获取默认重置密码"""
|
||||
return cls.get_config(cls.DEFAULT_RESET_PASSWORD, default)
|
||||
|
||||
@classmethod
|
||||
def get_max_audio_size(cls, default: int = 100) -> int:
|
||||
"""获取上传音频文件大小限制(MB)"""
|
||||
value = cls.get_config(cls.MAX_AUDIO_SIZE, str(default))
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
# LLM模型配置获取方法
|
||||
@classmethod
|
||||
def get_llm_model_name(cls, default: str = "qwen-plus") -> str:
|
||||
"""获取LLM模型名称"""
|
||||
return cls.get_config(cls.LLM_MODEL_NAME, default)
|
||||
|
||||
@classmethod
|
||||
def get_llm_timeout(cls, default: int = 120) -> int:
|
||||
"""获取LLM超时时间(秒)"""
|
||||
value = cls.get_config(cls.LLM_TIMEOUT, str(default))
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_llm_temperature(cls, default: float = 0.7) -> float:
|
||||
"""获取LLM temperature参数"""
|
||||
value = cls.get_config(cls.LLM_TEMPERATURE, str(default))
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_llm_top_p(cls, default: float = 0.9) -> float:
|
||||
"""获取LLM top_p参数"""
|
||||
value = cls.get_config(cls.LLM_TOP_P, str(default))
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
"""
|
||||
声纹服务 - 处理用户声纹采集、存储和验证
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
import app.core.config as config_module
|
||||
|
||||
|
||||
class VoiceprintService:
|
||||
"""声纹服务类 - 同步处理声纹采集"""
|
||||
|
||||
def __init__(self):
|
||||
self.voiceprint_dir = config_module.VOICEPRINT_DIR
|
||||
|
||||
def get_user_voiceprint_status(self, user_id: int) -> Dict:
|
||||
"""
|
||||
获取用户声纹状态
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
Dict: 声纹状态信息
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT vp_id, user_id, file_path, file_size, duration_seconds, collected_at, updated_at
|
||||
FROM user_voiceprint
|
||||
WHERE user_id = %s
|
||||
"""
|
||||
cursor.execute(query, (user_id,))
|
||||
voiceprint = cursor.fetchone()
|
||||
|
||||
if voiceprint:
|
||||
return {
|
||||
"has_voiceprint": True,
|
||||
"vp_id": voiceprint['vp_id'],
|
||||
"file_path": voiceprint['file_path'],
|
||||
"duration_seconds": float(voiceprint['duration_seconds']) if voiceprint['duration_seconds'] else None,
|
||||
"collected_at": voiceprint['collected_at'].isoformat() if voiceprint['collected_at'] else None
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"has_voiceprint": False,
|
||||
"vp_id": None,
|
||||
"file_path": None,
|
||||
"duration_seconds": None,
|
||||
"collected_at": None
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"获取声纹状态错误: {e}")
|
||||
raise e
|
||||
|
||||
def save_voiceprint(self, user_id: int, audio_file_path: str, file_size: int) -> Dict:
|
||||
"""
|
||||
保存声纹文件并提取特征向量
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
audio_file_path: 音频文件路径
|
||||
file_size: 文件大小
|
||||
|
||||
Returns:
|
||||
Dict: 保存结果
|
||||
"""
|
||||
try:
|
||||
# 1. 获取音频时长
|
||||
duration = self._get_audio_duration(audio_file_path)
|
||||
|
||||
# 2. 提取声纹向量(调用FunASR)
|
||||
vector_data = self._extract_voiceprint_vector(audio_file_path)
|
||||
|
||||
# 3. 保存到数据库
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查用户是否已有声纹
|
||||
cursor.execute("SELECT vp_id FROM user_voiceprint WHERE user_id = %s", (user_id,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
# 计算相对路径
|
||||
relative_path = str(Path(audio_file_path).relative_to(config_module.BASE_DIR))
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
update_query = """
|
||||
UPDATE user_voiceprint
|
||||
SET file_path = %s, file_size = %s, duration_seconds = %s,
|
||||
vector_data = %s, updated_at = NOW()
|
||||
WHERE user_id = %s
|
||||
"""
|
||||
cursor.execute(update_query, (
|
||||
relative_path, file_size, duration,
|
||||
json.dumps(vector_data) if vector_data else None,
|
||||
user_id
|
||||
))
|
||||
vp_id = existing['vp_id']
|
||||
else:
|
||||
# 插入新记录
|
||||
insert_query = """
|
||||
INSERT INTO user_voiceprint
|
||||
(user_id, file_path, file_size, duration_seconds, vector_data, collected_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, NOW(), NOW())
|
||||
"""
|
||||
cursor.execute(insert_query, (
|
||||
user_id, relative_path, file_size, duration,
|
||||
json.dumps(vector_data) if vector_data else None
|
||||
))
|
||||
vp_id = cursor.lastrowid
|
||||
|
||||
connection.commit()
|
||||
|
||||
return {
|
||||
"vp_id": vp_id,
|
||||
"user_id": user_id,
|
||||
"file_path": relative_path,
|
||||
"file_size": file_size,
|
||||
"duration_seconds": duration,
|
||||
"has_vector": vector_data is not None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存声纹错误: {e}")
|
||||
raise e
|
||||
|
||||
def delete_voiceprint(self, user_id: int) -> bool:
|
||||
"""
|
||||
删除用户声纹
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取文件路径
|
||||
cursor.execute("SELECT file_path FROM user_voiceprint WHERE user_id = %s", (user_id,))
|
||||
voiceprint = cursor.fetchone()
|
||||
|
||||
if voiceprint:
|
||||
# 构建完整文件路径
|
||||
relative_path = voiceprint['file_path']
|
||||
if relative_path.startswith('/'):
|
||||
relative_path = relative_path.lstrip('/')
|
||||
file_path = config_module.BASE_DIR / relative_path
|
||||
|
||||
# 删除数据库记录
|
||||
cursor.execute("DELETE FROM user_voiceprint WHERE user_id = %s", (user_id,))
|
||||
connection.commit()
|
||||
|
||||
# 删除文件
|
||||
if file_path.exists():
|
||||
os.remove(file_path)
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"删除声纹错误: {e}")
|
||||
raise e
|
||||
|
||||
def _get_audio_duration(self, audio_file_path: str) -> float:
|
||||
"""
|
||||
获取音频文件时长
|
||||
|
||||
Args:
|
||||
audio_file_path: 音频文件路径
|
||||
|
||||
Returns:
|
||||
float: 时长(秒)
|
||||
"""
|
||||
try:
|
||||
with wave.open(audio_file_path, 'rb') as wav_file:
|
||||
frames = wav_file.getnframes()
|
||||
rate = wav_file.getframerate()
|
||||
duration = frames / float(rate)
|
||||
return round(duration, 2)
|
||||
except Exception as e:
|
||||
print(f"获取音频时长错误: {e}")
|
||||
return 10.0 # 默认返回10秒
|
||||
|
||||
def _extract_voiceprint_vector(self, audio_file_path: str) -> Optional[list]:
|
||||
"""
|
||||
提取声纹特征向量(调用FunASR)
|
||||
|
||||
Args:
|
||||
audio_file_path: 音频文件路径
|
||||
|
||||
Returns:
|
||||
Optional[list]: 声纹向量(192维),失败返回None
|
||||
"""
|
||||
# TODO: 集成FunASR的说话人识别模型
|
||||
# 使用 speech_campplus_sv_zh-cn_16k-common 模型
|
||||
# 返回192维的embedding向量
|
||||
|
||||
print(f"[TODO] 调用FunASR提取声纹向量: {audio_file_path}")
|
||||
|
||||
# 暂时返回None,等待FunASR集成
|
||||
# 集成后应该返回类似: [0.123, -0.456, 0.789, ...]
|
||||
return None
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
voiceprint_service = VoiceprintService()
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"""
|
||||
APK解析工具
|
||||
用于从APK文件中提取版本信息(使用 pyaxmlparser)
|
||||
"""
|
||||
|
||||
|
||||
def parse_apk_with_androguard(apk_path):
|
||||
"""
|
||||
解析APK文件,提取版本信息
|
||||
使用 pyaxmlparser 库(轻量级,~1MB)
|
||||
"""
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
apk = APK(apk_path)
|
||||
|
||||
# 提取所有需要的信息
|
||||
package_name = apk.package
|
||||
version_code = apk.version_code
|
||||
version_name = apk.version_name
|
||||
app_name = apk.application
|
||||
min_sdk_version = apk.get_min_sdk_version()
|
||||
target_sdk_version = apk.get_target_sdk_version()
|
||||
|
||||
print(f"APK解析成功: package={package_name}, version_code={version_code}, version_name={version_name}, app_name={app_name}")
|
||||
|
||||
return {
|
||||
'package_name': package_name,
|
||||
'version_code': int(version_code) if version_code else None,
|
||||
'version_name': version_name,
|
||||
'app_name': app_name,
|
||||
'min_sdk_version': min_sdk_version,
|
||||
'target_sdk_version': target_sdk_version
|
||||
}
|
||||
except ImportError:
|
||||
print("错误: pyaxmlparser 未安装")
|
||||
print("请运行: pip install pyaxmlparser")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"APK解析失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
音频文件解析工具
|
||||
|
||||
用于解析音频文件的元数据信息,如时长、采样率、编码格式等
|
||||
"""
|
||||
|
||||
from tinytag import TinyTag
|
||||
|
||||
|
||||
def get_audio_duration(file_path: str) -> int:
|
||||
"""
|
||||
获取音频文件时长(秒)
|
||||
|
||||
使用TinyTag读取音频文件时长
|
||||
|
||||
Args:
|
||||
file_path: 音频文件的完整路径
|
||||
|
||||
Returns:
|
||||
音频时长(秒),如果解析失败返回0
|
||||
|
||||
支持格式:
|
||||
- MP3 (.mp3)
|
||||
- M4A (.m4a)
|
||||
- MP4 (.mp4)
|
||||
- WAV (.wav)
|
||||
- OGG (.ogg)
|
||||
- FLAC (.flac)
|
||||
- 以及TinyTag支持的其他音频格式
|
||||
"""
|
||||
try:
|
||||
tag = TinyTag.get(file_path)
|
||||
if tag.duration and tag.duration > 0:
|
||||
return int(tag.duration)
|
||||
except Exception as e:
|
||||
print(f"获取音频时长失败 ({file_path}): {e}")
|
||||
|
||||
return 0
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Core Application Framework
|
||||
fastapi
|
||||
uvicorn
|
||||
|
||||
# Database & Cache
|
||||
mysql-connector-python
|
||||
redis
|
||||
|
||||
# Services & External APIs
|
||||
requests
|
||||
dashscope
|
||||
PyJWT
|
||||
qiniu
|
||||
|
||||
# Validation & Forms
|
||||
email-validator
|
||||
python-multipart
|
||||
|
||||
# System Monitoring
|
||||
psutil
|
||||
|
||||
# APK Parsing
|
||||
pyaxmlparser
|
||||
|
||||
# Audio Metadata
|
||||
tinytagpython-dotenv
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# 客户端管理 - 专用终端类型添加说明
|
||||
|
||||
## 概述
|
||||
|
||||
本次更新在客户端管理系统中添加了"专用终端"(terminal)大类型,支持 Android 专用终端和单片机(MCU)平台。
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. 修改表结构
|
||||
|
||||
执行 SQL 文件:`add_dedicated_terminal.sql`
|
||||
|
||||
```bash
|
||||
mysql -u [username] -p [database_name] < backend/sql/add_dedicated_terminal.sql
|
||||
```
|
||||
|
||||
**变更内容:**
|
||||
- 修改 `client_downloads` 表的 `platform_type` 枚举,添加 `terminal` 类型
|
||||
- 插入两条示例数据:
|
||||
- Android 专用终端(platform_type: `terminal`, platform_name: `android`)
|
||||
- 单片机固件(platform_type: `terminal`, platform_name: `mcu`)
|
||||
|
||||
### 2. 新的平台类型
|
||||
|
||||
| platform_type | platform_name | 说明 |
|
||||
|--------------|--------------|------|
|
||||
| terminal | android | Android 专用终端 |
|
||||
| terminal | mcu | 单片机(MCU)固件 |
|
||||
|
||||
## API 接口变更
|
||||
|
||||
### 1. 新增接口:通过平台类型和平台名称获取最新版本
|
||||
|
||||
**接口路径:** `GET /api/downloads/latest/by-platform`
|
||||
|
||||
**请求参数:**
|
||||
- `platform_type` (string, required): 平台类型 (mobile, desktop, terminal)
|
||||
- `platform_name` (string, required): 具体平台名称
|
||||
|
||||
**示例请求:**
|
||||
```bash
|
||||
# 获取 Android 专用终端最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android"
|
||||
|
||||
# 获取单片机固件最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu"
|
||||
```
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"code": "200",
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"id": 7,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "android",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1000,
|
||||
"download_url": "https://download.imeeting.com/terminals/android/iMeeting-Terminal-1.0.0.apk",
|
||||
"file_size": 25165824,
|
||||
"release_notes": "专用终端初始版本\n- 支持专用硬件集成\n- 优化的录音功能\n- 低功耗模式\n- 自动上传同步",
|
||||
"is_active": true,
|
||||
"is_latest": true,
|
||||
"min_system_version": "Android 5.0",
|
||||
"created_at": "2025-01-15T10:00:00",
|
||||
"updated_at": "2025-01-15T10:00:00",
|
||||
"created_by": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 更新接口:获取所有平台最新版本
|
||||
|
||||
**接口路径:** `GET /api/downloads/latest`
|
||||
|
||||
**变更:** 返回数据中新增 `terminal` 字段
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"code": "200",
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"mobile": [...],
|
||||
"desktop": [...],
|
||||
"terminal": [
|
||||
{
|
||||
"id": 7,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "android",
|
||||
"version": "1.0.0",
|
||||
...
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "mcu",
|
||||
"version": "1.0.0",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 已有接口说明
|
||||
|
||||
**原有接口:** `GET /api/downloads/{platform_name}/latest`
|
||||
|
||||
- 此接口标记为【已废弃】,建议使用新接口 `/downloads/latest/by-platform`
|
||||
- 原因:只通过 `platform_name` 查询可能产生歧义(如 mobile 的 android 和 terminal 的 android)
|
||||
- 保留此接口是为了向后兼容,但新开发应使用新接口
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:专用终端设备版本检查
|
||||
|
||||
专用终端设备(如会议室固定录音设备、单片机硬件)启动时检查更新:
|
||||
|
||||
```javascript
|
||||
// Android 专用终端
|
||||
const response = await fetch(
|
||||
'/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android'
|
||||
);
|
||||
const { data } = await response.json();
|
||||
|
||||
if (data.version_code > currentVersionCode) {
|
||||
// 发现新版本,提示更新
|
||||
showUpdateDialog(data);
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:后台管理界面展示
|
||||
|
||||
管理员查看所有终端版本:
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/api/downloads?platform_type=terminal');
|
||||
const { data } = await response.json();
|
||||
|
||||
// data.clients 包含所有 terminal 类型的客户端版本
|
||||
renderClientList(data.clients);
|
||||
```
|
||||
|
||||
### 场景 3:固件更新服务器
|
||||
|
||||
单片机设备定期轮询更新:
|
||||
|
||||
```c
|
||||
// MCU 固件代码示例
|
||||
char url[] = "http://api.imeeting.com/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu";
|
||||
http_get(url, response_buffer);
|
||||
|
||||
// 解析 JSON 获取 download_url 和 version_code
|
||||
if (new_version > FIRMWARE_VERSION) {
|
||||
download_and_update(download_url);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 数据库测试
|
||||
```sql
|
||||
-- 验证表结构修改
|
||||
DESCRIBE client_downloads;
|
||||
|
||||
-- 验证数据插入
|
||||
SELECT * FROM client_downloads WHERE platform_type = 'terminal';
|
||||
```
|
||||
|
||||
### 2. API 测试
|
||||
```bash
|
||||
# 测试新接口
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android"
|
||||
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu"
|
||||
|
||||
# 测试获取所有最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest"
|
||||
|
||||
# 测试列表接口
|
||||
curl "http://localhost:8000/api/downloads?platform_type=terminal"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **执行 SQL 前请备份数据库**
|
||||
2. **ENUM 类型修改**:ALTER TABLE 会修改表结构,请在低峰期执行
|
||||
3. **新接口优先**:建议所有新开发使用 `/downloads/latest/by-platform` 接口
|
||||
4. **版本管理**:上传新版本时记得设置 `is_latest=TRUE` 并将同平台旧版本设为 `FALSE`
|
||||
5. **platform_name 唯一性**:如果 mobile 和 terminal 都有 android,建议:
|
||||
- mobile 的保持 `android`
|
||||
- terminal 的改为 `android_terminal` 或其他区分名称
|
||||
- 或者始终使用新接口同时传递 platform_type 和 platform_name
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `backend/sql/add_dedicated_terminal.sql` - 数据库迁移 SQL
|
||||
- `backend/app/api/endpoints/client_downloads.py` - API 接口代码
|
||||
- `backend/sql/README_terminal_update.md` - 本说明文档
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
-- 添加专用终端类型支持
|
||||
-- 修改 platform_type 枚举,添加 'terminal' 类型
|
||||
|
||||
ALTER TABLE client_downloads
|
||||
MODIFY COLUMN platform_type ENUM('mobile', 'desktop', 'terminal') NOT NULL
|
||||
COMMENT '平台类型:mobile-移动端, desktop-桌面端, terminal-专用终端';
|
||||
|
||||
-- 插入专用终端示例数据
|
||||
|
||||
-- Android 专用终端
|
||||
INSERT INTO client_downloads (
|
||||
platform_type,
|
||||
platform_name,
|
||||
version,
|
||||
version_code,
|
||||
download_url,
|
||||
file_size,
|
||||
release_notes,
|
||||
is_active,
|
||||
is_latest,
|
||||
min_system_version,
|
||||
created_by
|
||||
) VALUES
|
||||
(
|
||||
'terminal',
|
||||
'android',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/terminals/android/iMeeting-1.0.0-Terminal.apk',
|
||||
25165824, -- 24MB
|
||||
'专用终端初始版本
|
||||
- 支持专用硬件集成
|
||||
- 优化的录音功能
|
||||
- 低功耗模式
|
||||
- 自动上传同步',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Android 5.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- 单片机(MCU)专用终端
|
||||
(
|
||||
'terminal',
|
||||
'mcu',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/terminals/mcu/iMeeting-1.0.0-MCU.bin',
|
||||
2097152, -- 2MB
|
||||
'单片机固件初始版本
|
||||
- 嵌入式录音系统
|
||||
- 低功耗设计
|
||||
- 支持WiFi/4G上传
|
||||
- 硬件级音频处理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'ESP32 / STM32',
|
||||
1
|
||||
);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TABLE IF NOT EXISTS `hot_words` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`text` VARCHAR(255) NOT NULL COMMENT '热词内容',
|
||||
`weight` INT NOT NULL DEFAULT 4 COMMENT '词汇权重 (1-10)',
|
||||
`lang` VARCHAR(20) NOT NULL DEFAULT 'zh' COMMENT '语言 (zh/en)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 (1:启用, 0:禁用)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_text_lang` (`text`, `lang`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统语音识别热词表';
|
||||
|
||||
-- 预留存储 Vocabulary ID 的配置项(如果不想用字典表存储配置,也可以在系统配置表中增加)
|
||||
INSERT INTO `dict_data` (dict_type, dict_code, parent_code, label_cn, status)
|
||||
VALUES ('system_config', 'asr_vocabulary_id', 'ROOT', '阿里云ASR热词表ID', 1)
|
||||
ON DUPLICATE KEY UPDATE label_cn='阿里云ASR热词表ID';
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
-- ===================================================================
|
||||
-- 菜单权限系统数据库迁移脚本
|
||||
-- 创建日期: 2025-12-10
|
||||
-- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的菜单权限管理
|
||||
-- ===================================================================
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for menus
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `menus`;
|
||||
CREATE TABLE `menus` (
|
||||
`menu_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
|
||||
`menu_code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单代码(唯一标识)',
|
||||
`menu_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单名称',
|
||||
`menu_icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单图标标识',
|
||||
`menu_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单URL/路由',
|
||||
`menu_type` enum('action','link','divider') COLLATE utf8mb4_unicode_ci DEFAULT 'action' COMMENT '菜单类型: action-操作/link-链接/divider-分隔符',
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父菜单ID(用于层级菜单)',
|
||||
`sort_order` int(11) DEFAULT 0 COMMENT '排序顺序',
|
||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用: 1-启用, 0-禁用',
|
||||
`description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单描述',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`menu_id`),
|
||||
UNIQUE KEY `uk_menu_code` (`menu_code`),
|
||||
KEY `idx_parent_id` (`parent_id`),
|
||||
KEY `idx_is_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for role_menu_permissions
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `role_menu_permissions`;
|
||||
CREATE TABLE `role_menu_permissions` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '权限ID',
|
||||
`role_id` int(11) NOT NULL COMMENT '角色ID',
|
||||
`menu_id` int(11) NOT NULL COMMENT '菜单ID',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`),
|
||||
KEY `idx_role_id` (`role_id`),
|
||||
KEY `idx_menu_id` (`menu_id`),
|
||||
CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
|
||||
|
||||
-- ----------------------------
|
||||
-- 初始化菜单数据(基于现有系统的下拉菜单)
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
|
||||
-- 用户菜单项
|
||||
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`)
|
||||
VALUES
|
||||
('change_password', '修改密码', 'KeyRound', NULL, 'action', 1, 1, '用户修改自己的密码'),
|
||||
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', 2, 1, '管理AI提示词模版'),
|
||||
('platform_admin', '平台管理', 'Shield', '/admin/management', 'link', 3, 1, '平台管理员后台'),
|
||||
('logout', '退出登录', 'LogOut', NULL, 'action', 99, 1, '退出当前账号');
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- 初始化角色权限数据
|
||||
-- 注意:角色表已存在,role_id=1为平台管理员,role_id=2为普通用户
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
|
||||
-- 平台管理员(role_id=1)拥有所有菜单权限
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 1, menu_id FROM `menus` WHERE is_active = 1;
|
||||
|
||||
-- 普通用户(role_id=2)拥有除"平台管理"外的所有菜单权限
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 2, menu_id FROM `menus` WHERE menu_code != 'platform_admin' AND is_active = 1;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- 查询验证
|
||||
-- ----------------------------
|
||||
-- 查看所有菜单
|
||||
-- SELECT * FROM menus ORDER BY sort_order;
|
||||
|
||||
-- 查看平台管理员的菜单权限
|
||||
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
|
||||
-- FROM role_menu_permissions rmp
|
||||
-- JOIN roles r ON rmp.role_id = r.role_id
|
||||
-- JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
-- WHERE r.role_id = 1
|
||||
-- ORDER BY m.sort_order;
|
||||
|
||||
-- 查看普通用户的菜单权限
|
||||
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
|
||||
-- FROM role_menu_permissions rmp
|
||||
-- JOIN roles r ON rmp.role_id = r.role_id
|
||||
-- JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
-- WHERE r.role_id = 2
|
||||
-- ORDER BY m.sort_order;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- 为 llm_tasks 表添加 prompt_id 列,用于支持自定义模版选择功能
|
||||
-- 执行日期:2025-12-08
|
||||
|
||||
ALTER TABLE `llm_tasks`
|
||||
ADD COLUMN `prompt_id` int(11) DEFAULT NULL COMMENT '提示词模版ID' AFTER `user_prompt`,
|
||||
ADD KEY `idx_prompt_id` (`prompt_id`);
|
||||
|
||||
-- 说明:
|
||||
-- 1. prompt_id 允许为 NULL,表示使用默认模版
|
||||
-- 2. 添加索引以优化查询性能
|
||||
-- 3. 不添加外键约束,因为 prompts 表中的记录可能被删除,我们希望保留历史任务记录
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 添加 task_type 字典数据
|
||||
-- 用于会议任务和知识库任务的分类
|
||||
|
||||
INSERT INTO dict_data (dict_type, dict_code, parent_code, label_cn, label_en, sort_order, status, extension_attr) VALUES
|
||||
('task_type', 'MEETING_TASK', 'ROOT', '会议任务', 'Meeting Task', 1, 1, NULL),
|
||||
('task_type', 'KNOWLEDGE_TASK', 'ROOT', '知识库任务', 'Knowledge Task', 2, 1, NULL)
|
||||
ON DUPLICATE KEY UPDATE label_cn=VALUES(label_cn), label_en=VALUES(label_en);
|
||||
|
||||
-- 查看结果
|
||||
SELECT * FROM dict_data WHERE dict_type='task_type';
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
-- 客户端下载管理表
|
||||
CREATE TABLE IF NOT EXISTS client_downloads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
platform_type ENUM('mobile', 'desktop') NOT NULL COMMENT '平台类型:mobile-移动端, desktop-桌面端',
|
||||
platform_name VARCHAR(50) NOT NULL COMMENT '具体平台:ios, android, windows, mac_intel, mac_m, linux',
|
||||
version VARCHAR(50) NOT NULL COMMENT '版本号,如: 1.0.0',
|
||||
version_code INT NOT NULL DEFAULT 1 COMMENT '版本代码,用于版本比较',
|
||||
download_url TEXT NOT NULL COMMENT '下载链接',
|
||||
file_size BIGINT COMMENT '文件大小(字节)',
|
||||
release_notes TEXT COMMENT '更新说明',
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
is_latest BOOLEAN DEFAULT FALSE COMMENT '是否为最新版本',
|
||||
min_system_version VARCHAR(50) COMMENT '最低系统版本要求',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
created_by INT COMMENT '创建人ID',
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_platform (platform_type, platform_name),
|
||||
INDEX idx_version (version_code),
|
||||
INDEX idx_active (is_active, is_latest)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户端下载管理表';
|
||||
|
||||
-- 插入初始数据(示例版本)
|
||||
INSERT INTO client_downloads (
|
||||
platform_type,
|
||||
platform_name,
|
||||
version,
|
||||
version_code,
|
||||
download_url,
|
||||
file_size,
|
||||
release_notes,
|
||||
is_active,
|
||||
is_latest,
|
||||
min_system_version,
|
||||
created_by
|
||||
) VALUES
|
||||
-- iOS 客户端
|
||||
(
|
||||
'mobile',
|
||||
'ios',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://apps.apple.com/app/imeeting/id123456789',
|
||||
52428800, -- 50MB
|
||||
'初始版本发布
|
||||
- 支持会议录音
|
||||
- 支持实时转录
|
||||
- 支持会议摘要查看',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'iOS 13.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- Android 客户端
|
||||
(
|
||||
'mobile',
|
||||
'android',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://play.google.com/store/apps/details?id=com.imeeting.app',
|
||||
45088768, -- 43MB
|
||||
'初始版本发布
|
||||
- 支持会议录音
|
||||
- 支持实时转录
|
||||
- 支持会议摘要查看',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Android 8.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- Windows 客户端
|
||||
(
|
||||
'desktop',
|
||||
'windows',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/windows/iMeeting-1.0.0-Setup.exe',
|
||||
104857600, -- 100MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Windows 10 (64-bit)',
|
||||
1
|
||||
),
|
||||
|
||||
-- Mac Intel 客户端
|
||||
(
|
||||
'desktop',
|
||||
'mac_intel',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/mac/iMeeting-1.0.0-Intel.dmg',
|
||||
94371840, -- 90MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'macOS 10.15 Catalina',
|
||||
1
|
||||
),
|
||||
|
||||
-- Mac M系列 客户端
|
||||
(
|
||||
'desktop',
|
||||
'mac_m',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/mac/iMeeting-1.0.0-AppleSilicon.dmg',
|
||||
83886080, -- 80MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理
|
||||
- 原生支持Apple Silicon',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'macOS 11.0 Big Sur',
|
||||
1
|
||||
),
|
||||
|
||||
-- Linux 客户端
|
||||
(
|
||||
'desktop',
|
||||
'linux',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/linux/iMeeting-1.0.0-x64.AppImage',
|
||||
98566144, -- 94MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理
|
||||
- 支持主流Linux发行版',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Ubuntu 20.04 / Debian 10 / Fedora 32 或更高版本',
|
||||
1
|
||||
);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
-- 客户端下载管理表
|
||||
-- 保留 platform_type 和 platform_name 字段以兼容旧终端
|
||||
-- 新增 platform_code 关联 dict_data 表的码表数据
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `client_downloads` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`platform_type` VARCHAR(50) NULL COMMENT '平台类型(兼容旧版:mobile, desktop, terminal)',
|
||||
`platform_name` VARCHAR(50) NULL COMMENT '平台名称(兼容旧版:ios, android, windows等)',
|
||||
`platform_code` VARCHAR(64) NOT NULL COMMENT '平台编码(关联 dict_data.dict_code)',
|
||||
`version` VARCHAR(50) NOT NULL COMMENT '版本号(如 1.0.0)',
|
||||
`version_code` INT NOT NULL COMMENT '版本号数值(用于版本比较)',
|
||||
`download_url` VARCHAR(512) NOT NULL COMMENT '下载链接',
|
||||
`file_size` BIGINT NULL COMMENT '文件大小(bytes)',
|
||||
`release_notes` TEXT NULL COMMENT '更新说明',
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
|
||||
`is_latest` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为最新版本',
|
||||
`min_system_version` VARCHAR(50) NULL COMMENT '最低系统版本要求',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` INT NULL COMMENT '创建者用户ID',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_platform_code` (`platform_code`),
|
||||
INDEX `idx_platform_type_name` (`platform_type`, `platform_name`),
|
||||
INDEX `idx_is_latest` (`is_latest`),
|
||||
INDEX `idx_is_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端下载管理表';
|
||||
|
||||
-- 插入测试数据示例(包含新旧字段映射)
|
||||
-- 旧终端使用 platform_type + platform_name
|
||||
-- 新终端使用 platform_code
|
||||
-- INSERT INTO client_downloads (platform_type, platform_name, platform_code, version, version_code, download_url, file_size, release_notes, is_active, is_latest, min_system_version, created_by)
|
||||
-- VALUES
|
||||
-- ('desktop', 'windows', 'WIN', '1.0.0', 100, 'https://download.example.com/imeeting-win-1.0.0.exe', 52428800, '首个正式版本', TRUE, TRUE, 'Windows 10', 1),
|
||||
-- ('desktop', 'mac', 'MAC', '1.0.0', 100, 'https://download.example.com/imeeting-mac-1.0.0.dmg', 48234496, '首个正式版本', TRUE, TRUE, 'macOS 11.0', 1),
|
||||
-- ('mobile', 'ios', 'IOS', '1.0.0', 100, 'https://apps.apple.com/app/imeeting', 45088768, '首个正式版本', TRUE, TRUE, 'iOS 13.0', 1),
|
||||
-- ('mobile', 'android', 'ANDROID', '1.0.0', 100, 'https://download.example.com/imeeting-android-1.0.0.apk', 38797312, '首个正式版本', TRUE, TRUE, 'Android 8.0', 1);
|
||||
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
-- iMeeting 数据库初始化脚本 (MySQL 5.7 兼容)
|
||||
-- 基于 project.md v3
|
||||
|
||||
-- 设置数据库和字符集
|
||||
-- 请在使用前手动创建数据库: CREATE DATABASE imeeting CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- USE imeeting;
|
||||
|
||||
-- 删除已存在的表 (用于重新执行脚本)
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
DROP TABLE IF EXISTS `meeting_summaries`, `transcript_segments`, `audio_files`, `attachments`, `attendees`, `meetings`, `users`, `tags`;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 1. 创建表结构
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE `users` (
|
||||
`user_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`username` VARCHAR(50) UNIQUE NOT NULL,
|
||||
`caption` VARCHAR(50) NOT NULL,
|
||||
`email` VARCHAR(100) UNIQUE NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 会议表
|
||||
CREATE TABLE `meetings` (
|
||||
`meeting_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT, -- 会议创建者
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`meeting_time` TIMESTAMP NULL,
|
||||
`summary` TEXT, -- 以Markdown格式存储
|
||||
`tags` VARCHAR(1024) DEFAULT NULL, -- 以逗号分隔的标签字符串
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 参会人表 (关联用户)
|
||||
CREATE TABLE `attendees` (
|
||||
`attendee_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`user_id` INT,
|
||||
UNIQUE KEY `uk_meeting_user` (`meeting_id`, `user_id`) -- 确保同一用户在同一会议中只出现一次
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 会议材料附件表
|
||||
CREATE TABLE `attachments` (
|
||||
`attachment_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`file_path` VARCHAR(512) NOT NULL, -- 存储路径或URL
|
||||
`file_type` VARCHAR(100),
|
||||
`uploaded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 音频文件与处理任务表
|
||||
CREATE TABLE `audio_files` (
|
||||
`audio_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`file_name` VARCHAR(255),
|
||||
`file_path` VARCHAR(512) NOT NULL,
|
||||
`file_size` BIGINT DEFAULT NULL,
|
||||
`upload_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`processing_status` VARCHAR(20) DEFAULT 'uploaded', -- 'uploaded', 'processing', 'completed', 'failed'
|
||||
`error_message` TEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 转录任务表
|
||||
CREATE TABLE `transcript_tasks` (
|
||||
`task_id` VARCHAR(100) PRIMARY KEY,
|
||||
`paraformer_task_id` VARCHAR(100) DEFAULT NULL,
|
||||
`meeting_id` INT NOT NULL,
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
`progress` INT DEFAULT 0, -- 0-100 进度百分比
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL,
|
||||
`error_message` TEXT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 转录内容表 (核心)
|
||||
CREATE TABLE `transcript_segments` (
|
||||
`segment_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`speaker_id` INT, -- 解析出来的人员ID
|
||||
`speaker_tag` VARCHAR(50) NOT NULL, -- e.g., "Speaker A", "李雷"
|
||||
`start_time_ms` INT NOT NULL, -- 音频开始时间(毫秒)
|
||||
`end_time_ms` INT NOT NULL, -- 音频结束时间(毫秒)
|
||||
`text_content` TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 标签表 (用于标签快速检索和颜色管理)
|
||||
CREATE TABLE `tags` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#409EFF',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `tag_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 2. 插入测试数据
|
||||
|
||||
-- 插入用户 (4名)
|
||||
INSERT INTO `users` (`username`, `caption`, `email`, `password_hash`) VALUES
|
||||
('user1', 'alice', 'alice@example.com', 'hashed_password_1'),
|
||||
('user2', 'bob', 'bob@example.com', 'hashed_password_2'),
|
||||
('user3', 'charlie', 'charlie@example.com', 'hashed_password_3'),
|
||||
('user4', 'david', 'david@example.com', 'hashed_password_4');
|
||||
|
||||
-- 插入会议 (6条)
|
||||
INSERT INTO `meetings` (`user_id`, `title`, `meeting_time`, `summary`, `tags`) VALUES
|
||||
(1, 'Q3产品战略规划会', '2025-07-28 10:00:00', '# Q3产品战略规划会
|
||||
|
||||
## 核心议题
|
||||
- **目标**: 确定Q3主要产品迭代方向。
|
||||
- **讨论**: AI功能集成方案。
|
||||
|
||||
## 结论
|
||||
- 推进AI摘要功能开发。', '产品,重要'),
|
||||
(2, '“智慧大脑”项目技术评审', '2025-07-29 14:30:00', '技术方案已通过,部分细节待优化。', '技术'),
|
||||
(1, '营销团队周会', '2025-07-30 09:00:00', '回顾上周数据,制定本周计划。', '营销'),
|
||||
(3, '关于新版UI的设计评审', '2025-07-30 11:00:00', '## UI评审
|
||||
- **优点**: 简洁、现代。
|
||||
- **待办**: 调整登录页按钮颜色。', '设计'),
|
||||
(4, '年度财务报告初审', '2025-07-31 15:00:00', NULL, NULL),
|
||||
(2, '服务器架构升级讨论', '2025-08-01 16:00:00', '初步同意采用微服务架构。', '技术,重要');
|
||||
|
||||
-- 插入参会人
|
||||
-- 会议1: Alice, Bob, Charlie
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (1, 1), (1, 2), (1, 3);
|
||||
-- 会议2: Bob, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (2, 2), (2, 4);
|
||||
-- 会议3: Alice, Charlie
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (3, 1), (3, 3);
|
||||
-- 会议4: Charlie, Alice, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (4, 3), (4, 1), (4, 4);
|
||||
-- 会议5: David, Bob
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (5, 4), (5, 2);
|
||||
-- 会议6: Bob, Charlie, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (6, 2), (6, 3), (6, 4);
|
||||
|
||||
-- 插入会议材料
|
||||
INSERT INTO `attachments` (`meeting_id`, `file_name`, `file_path`, `file_type`) VALUES
|
||||
(1, 'Q3产品规划.pptx', '/uploads/meeting_1/q3_plan.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
|
||||
(2, '技术方案V2.pdf', '/uploads/meeting_2/tech_spec_v2.pdf', 'application/pdf');
|
||||
|
||||
-- 插入音频文件记录
|
||||
INSERT INTO `audio_files` (`meeting_id`, `file_name`, `file_path`, `file_size`, `processing_status`) VALUES
|
||||
(1, 'meeting_1_audio.mp3', '/uploads/audio/1/meeting_1_audio.mp3', 15728640, 'completed'),
|
||||
(2, 'meeting_2_audio.wav', '/uploads/audio/2/meeting_2_audio.wav', 23456780, 'processing'),
|
||||
(3, 'meeting_3_audio.m4a', '/uploads/audio/3/meeting_3_audio.m4a', 18923456, 'uploaded'),
|
||||
(4, 'meeting_4_audio.mp3', '/uploads/audio/4/meeting_4_audio.mp3', 12345678, 'failed');
|
||||
|
||||
-- 插入转录任务记录
|
||||
INSERT INTO `transcript_tasks` (`task_id`, `meeting_id`, `status`, `progress`, `created_at`) VALUES
|
||||
('task-uuid-1', 1, 'completed', 100, '2025-07-28 10:05:00'),
|
||||
('task-uuid-2', 2, 'processing', 45, '2025-07-29 14:35:00'),
|
||||
('task-uuid-4', 4, 'failed', 0, '2025-07-30 11:05:00');
|
||||
|
||||
-- 插入转录内容 (为会议1)
|
||||
INSERT INTO `transcript_segments` (`meeting_id`, `speaker_id`, `speaker_tag`, `start_time_ms`, `end_time_ms`, `text_content`) VALUES
|
||||
(1, 0, '发言人 0', 5200, 9800, '好的,我们开始今天Q3的战略规划会。'),
|
||||
(1, 1, '发言人 1', 10100, 15500, '我先同步一下上个季度的数据,我们的用户增长了20%,主要来自于新推出的移动端。'),
|
||||
(1, 0, '发言人 0', 16000, 21300, '非常好。这个季度,我希望我们能重点讨论一下AI功能的集成,特别是会议摘要这部分。'),
|
||||
(1, 2, '发言人 2', 21800, 28000, '我同意,自动摘要可以极大地提升用户体验,我这边已经做了一些初步的技术调研。');
|
||||
|
||||
-- 插入标签
|
||||
INSERT INTO `tags` (`name`, `color`) VALUES
|
||||
('产品', '#409EFF'),
|
||||
('技术', '#67C23A'),
|
||||
('营销', '#E6A23C'),
|
||||
('设计', '#F56C6C'),
|
||||
('重要', '#909399');
|
||||
|
||||
-- 3. 添加外键约束
|
||||
ALTER TABLE `meetings` ADD CONSTRAINT `fk_meetings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attendees` ADD CONSTRAINT `fk_attendees_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attendees` ADD CONSTRAINT `fk_attendees_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attachments` ADD CONSTRAINT `fk_attachments_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `audio_files` ADD CONSTRAINT `fk_audio_files_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `transcript_tasks` ADD CONSTRAINT `fk_transcript_tasks_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `transcript_segments` ADD CONSTRAINT `fk_transcript_segments_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
|
||||
-- 4. 添加索引优化查询性能
|
||||
-- audio_files 表索引
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_task_id` (`task_id`);
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_processing_status` (`processing_status`);
|
||||
|
||||
-- transcript_tasks 表索引
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_status` (`status`);
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_created_at` (`created_at`);
|
||||
|
||||
-- transcript_segments 表索引
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_speaker_id` (`speaker_id`);
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_start_time` (`start_time_ms`);
|
||||
|
||||
-- meetings 表索引
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_user_id` (`user_id`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_meeting_time` (`meeting_time`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_created_at` (`created_at`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_tags` (`tags`(255));
|
||||
|
||||
-- attendees 表索引
|
||||
ALTER TABLE `attendees` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `attendees` ADD INDEX `idx_user_id` (`user_id`);
|
||||
|
||||
-- attachments 表索引
|
||||
ALTER TABLE `attachments` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
|
||||
-- tags 表索引
|
||||
ALTER TABLE `tags` ADD INDEX `idx_name` (`name`);
|
||||
|
||||
-- 脚本结束
|
||||
SELECT '数据库初始化脚本 (MySQL) 执行完毕。';
|
||||
|
||||
-- Knowledge Base Tables
|
||||
CREATE TABLE IF NOT EXISTS `prompts` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL UNIQUE COMMENT '提示词名称,保持唯一以方便管理',
|
||||
`tags` VARCHAR(255) COMMENT '标签,用于分类和搜索,多个标签用逗号分隔',
|
||||
`content` TEXT NOT NULL COMMENT '完整的提示词内容',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
|
||||
) COMMENT='用于存储AI总结的提示词模板';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_bases` (
|
||||
`kb_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`title` VARCHAR(255) NOT NULL COMMENT '标题',
|
||||
`content` TEXT NULL COMMENT '生成的知识库内容 (Markdown格式)',
|
||||
`creator_id` INT NOT NULL COMMENT '创建者用户ID',
|
||||
`is_shared` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为共享知识库',
|
||||
`source_meeting_ids` VARCHAR(255) NULL COMMENT '内容来源的会议ID列表 (逗号分隔)',
|
||||
`tags` VARCHAR(255) NULL COMMENT '逗号分隔的标签',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE
|
||||
) COMMENT='知识库条目表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_base_tasks` (
|
||||
`task_id` VARCHAR(100) PRIMARY KEY COMMENT '业务任务唯一ID (UUID)',
|
||||
`user_id` INT NOT NULL COMMENT '发起任务的用户ID',
|
||||
`kb_id` INT NOT NULL COMMENT '关联的知识库条目ID',
|
||||
`user_prompt` TEXT NULL COMMENT '用户输入的提示词',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
|
||||
`progress` INT DEFAULT 0 COMMENT '任务进度百分比 (0-100)',
|
||||
`error_message` TEXT NULL COMMENT '任务失败时的错误信息',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`kb_id`) REFERENCES `knowledge_bases`(`kb_id`) ON DELETE CASCADE
|
||||
) COMMENT='知识库生成任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `prompt_config` (
|
||||
`config_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_name` VARCHAR(100) UNIQUE NOT NULL COMMENT '任务名称',
|
||||
`prompt_id` INT NOT NULL COMMENT '关联的提示词模版ID',
|
||||
FOREIGN KEY (`prompt_id`) REFERENCES `prompts`(`id`)
|
||||
) COMMENT='提示词配置表';
|
||||
|
||||
-- Initial data for prompt_config
|
||||
INSERT INTO `prompt_config` (`task_name`, `prompt_id`) VALUES ('LLM_TASK', 1);
|
||||
INSERT INTO `prompt_config` (`task_name`, `prompt_id`) VALUES ('KNOWLEDGE_TASK', 2);
|
||||
|
||||
-- You might need to insert prompts with id=1 and id=2 into the `prompts` table for this to work.
|
||||
-- Example:
|
||||
-- INSERT INTO `prompts` (`id`, `name`, `content`) VALUES (1, 'Default Meeting Summary', 'Please summarize the following meeting transcript...');
|
||||
-- INSERT INTO `prompts` (`id`, `name`, `content`) VALUES (2, 'Default Knowledge Base Generation', 'Please generate a knowledge base article from the following text...');
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,67 @@
|
|||
-- 提示词表改造迁移脚本
|
||||
-- 将 prompt_config 表的功能整合到 prompts 表
|
||||
|
||||
-- 步骤1: 添加新字段
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN task_type ENUM('MEETING_TASK', 'KNOWLEDGE_TASK')
|
||||
COMMENT '任务类型:MEETING_TASK-会议任务, KNOWLEDGE_TASK-知识库任务' AFTER name;
|
||||
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE
|
||||
COMMENT '是否为该任务类型的默认模板' AFTER content;
|
||||
|
||||
-- 步骤2: 修改 is_active 字段(如果存在且类型不是 BOOLEAN)
|
||||
-- 先检查字段是否存在,如果不存在则添加
|
||||
ALTER TABLE prompts
|
||||
MODIFY COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
COMMENT '是否启用(只有启用的提示词才能被使用)';
|
||||
|
||||
-- 步骤3: 删除 tags 字段
|
||||
ALTER TABLE prompts DROP COLUMN IF EXISTS tags;
|
||||
|
||||
-- 步骤4: 从 prompt_config 迁移数据(如果 prompt_config 表存在)
|
||||
-- 更新 task_type 和 is_default
|
||||
UPDATE prompts p
|
||||
LEFT JOIN prompt_config pc ON p.id = pc.prompt_id
|
||||
SET
|
||||
p.task_type = CASE
|
||||
WHEN pc.task_name IS NOT NULL THEN pc.task_name
|
||||
ELSE 'MEETING_TASK' -- 默认值
|
||||
END,
|
||||
p.is_default = CASE
|
||||
WHEN pc.is_default = 1 THEN TRUE
|
||||
ELSE FALSE
|
||||
END
|
||||
WHERE pc.prompt_id IS NOT NULL OR p.task_type IS NULL;
|
||||
|
||||
-- 步骤5: 为所有没有设置 task_type 的提示词设置默认值
|
||||
UPDATE prompts
|
||||
SET task_type = 'MEETING_TASK'
|
||||
WHERE task_type IS NULL;
|
||||
|
||||
-- 步骤6: 将 task_type 设置为 NOT NULL
|
||||
ALTER TABLE prompts
|
||||
MODIFY COLUMN task_type ENUM('MEETING_TASK', 'KNOWLEDGE_TASK') NOT NULL
|
||||
COMMENT '任务类型:MEETING_TASK-会议任务, KNOWLEDGE_TASK-知识库任务';
|
||||
|
||||
-- 步骤7: 确保每个 task_type 只有一个默认提示词
|
||||
-- 如果有多个默认,只保留 id 最小的那个
|
||||
UPDATE prompts p1
|
||||
LEFT JOIN (
|
||||
SELECT task_type, MIN(id) as min_id
|
||||
FROM prompts
|
||||
WHERE is_default = TRUE
|
||||
GROUP BY task_type
|
||||
) p2 ON p1.task_type = p2.task_type
|
||||
SET p1.is_default = FALSE
|
||||
WHERE p1.is_default = TRUE AND p1.id != p2.min_id;
|
||||
|
||||
-- 步骤8: (可选) 备注 prompt_config 表已废弃
|
||||
-- 如果需要删除 prompt_config 表,取消下面的注释
|
||||
-- DROP TABLE IF EXISTS prompt_config;
|
||||
|
||||
-- 迁移完成
|
||||
SELECT '提示词表迁移完成!' as message;
|
||||
SELECT task_type, COUNT(*) as total, SUM(is_default) as default_count
|
||||
FROM prompts
|
||||
GROUP BY task_type;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- ============================================
|
||||
-- 为 audio_files 表添加 duration 字段
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 添加音频时长字段(秒),用于统计用户会议总时长
|
||||
-- ============================================
|
||||
|
||||
-- 添加 duration 字段(单位:秒)
|
||||
ALTER TABLE audio_files
|
||||
ADD COLUMN duration INT(11) DEFAULT 0 COMMENT '音频时长(秒)'
|
||||
AFTER file_size;
|
||||
|
||||
-- 添加索引以提高查询性能
|
||||
ALTER TABLE audio_files
|
||||
ADD INDEX idx_duration (duration);
|
||||
|
||||
-- ============================================
|
||||
-- 验证修改
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE audio_files;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- ============================================
|
||||
-- 添加 prompt_id 字段到主表
|
||||
-- 创建时间: 2025-01-11
|
||||
-- 说明: 在 meetings 和 knowledge_bases 表中添加 prompt_id 字段
|
||||
-- 用于记录会议/知识库使用的提示词模版
|
||||
-- ============================================
|
||||
|
||||
-- 1. 为 meetings 表添加 prompt_id 字段
|
||||
ALTER TABLE meetings
|
||||
ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版'
|
||||
AFTER summary;
|
||||
|
||||
-- 为 meetings 表添加索引
|
||||
ALTER TABLE meetings
|
||||
ADD INDEX idx_prompt_id (prompt_id);
|
||||
|
||||
-- 2. 为 knowledge_bases 表添加 prompt_id 字段
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版'
|
||||
AFTER tags;
|
||||
|
||||
-- 为 knowledge_bases 表添加索引
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD INDEX idx_prompt_id (prompt_id);
|
||||
|
||||
-- ============================================
|
||||
-- 验证修改
|
||||
-- ============================================
|
||||
-- 查看 meetings 表结构
|
||||
-- DESCRIBE meetings;
|
||||
|
||||
-- 查看 knowledge_bases 表结构
|
||||
-- DESCRIBE knowledge_bases;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- ============================================
|
||||
-- 创建用户日志表 (user_logs)
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 用于记录用户活动日志,包括登录、登出等操作
|
||||
-- 支持查询用户最后登录时间等统计信息
|
||||
-- ============================================
|
||||
|
||||
-- 创建 user_logs 表
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
log_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
|
||||
user_id INT(11) NOT NULL COMMENT '用户ID',
|
||||
action_type VARCHAR(50) NOT NULL COMMENT '操作类型: login, logout, etc.',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT '用户IP地址',
|
||||
user_agent TEXT DEFAULT NULL COMMENT '用户代理字符串(浏览器/设备信息)',
|
||||
metadata JSON DEFAULT NULL COMMENT '额外的元数据(JSON格式)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '日志创建时间',
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_action_type (action_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_user_action (user_id, action_type),
|
||||
|
||||
-- 外键约束
|
||||
CONSTRAINT fk_user_logs_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活动日志表';
|
||||
|
||||
-- ============================================
|
||||
-- 验证创建
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE user_logs;
|
||||
|
||||
-- 查看索引
|
||||
-- SHOW INDEX FROM user_logs;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
-- Migration: Add avatar_url and update menu for Account Settings
|
||||
-- Created at: 2026-01-15
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Add avatar_url to users table if it doesn't exist
|
||||
-- Note: MySQL 5.7 doesn't support IF NOT EXISTS for columns easily in one line without procedure,
|
||||
-- but for this environment we assume it doesn't exist or ignore error if strictly handled.
|
||||
-- However, creating a safe idempotent script is better.
|
||||
-- Since I can't run complex procedures easily here, I'll just run the ALTER.
|
||||
-- If it fails, it fails (user can ignore if already applied).
|
||||
ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) DEFAULT NULL AFTER `email`;
|
||||
|
||||
-- 2. Remove 'change_password' menu
|
||||
DELETE FROM `role_menu_permissions` WHERE `menu_id` IN (SELECT `menu_id` FROM `menus` WHERE `menu_code` = 'change_password');
|
||||
DELETE FROM `menus` WHERE `menu_code` = 'change_password';
|
||||
|
||||
-- 3. Add 'account_settings' menu
|
||||
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`)
|
||||
VALUES ('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', 1, 1, '管理个人账户信息');
|
||||
|
||||
-- 4. Grant permissions
|
||||
-- Grant to Admin (role_id=1) and User (role_id=2)
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 1, menu_id FROM `menus` WHERE `menu_code` = 'account_settings';
|
||||
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 2, menu_id FROM `menus` WHERE `menu_code` = 'account_settings';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
-- Migration to unify LLM config into a single dict entry 'llm_model'
|
||||
-- Using dict_type='system_config' and dict_code='llm_model'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Insert default LLM configuration
|
||||
INSERT INTO `dict_data` (
|
||||
`dict_type`, `dict_code`, `parent_code`, `label_cn`, `label_en`,
|
||||
`sort_order`, `extension_attr`, `is_default`, `status`
|
||||
) VALUES (
|
||||
'system_config', 'llm_model', 'ROOT', '大模型配置', 'LLM Model Config',
|
||||
0, '{"model_name": "qwen-plus", "timeout": 120, "temperature": 0.7, "top_p": 0.9}', 0, 1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`label_cn` = VALUES(`label_cn`);
|
||||
-- Note: We avoid overwriting extension_attr on duplicate key to preserve existing settings if any,
|
||||
-- UNLESS we want to force reset. The user said "refer to...", implying structure exists or should be this.
|
||||
-- If I want to ensure the structure exists with keys, I might need to merge.
|
||||
-- For simplicity, if it exists, I assume it's correct or managed by admin UI.
|
||||
-- But since this is a new "unification", likely it doesn't exist or we want to establish defaults.
|
||||
-- Let's update extension_attr if it's NULL, or just leave it.
|
||||
-- Actually, if I am changing the SCHEMA of config (from individual to unified),
|
||||
-- I should probably populate it.
|
||||
-- Since I cannot easily read old values here, I will just ensure the entry exists.
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- 更新voiceprint配置
|
||||
-- 将dict_type='system_config'且dict_code='voiceprint_template'的记录改为 dict_type='voiceprint' 且 dict_code='voiceprint'
|
||||
-- 或者如果已经存在voiceprint类型的配置,则更新它
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 尝试删除旧的voiceprint配置(如果存在)
|
||||
DELETE FROM `dict_data` WHERE `dict_type` = 'system_config' AND `dict_code` = 'voiceprint_template';
|
||||
|
||||
-- 2. 插入或更新新的voiceprint配置
|
||||
INSERT INTO `dict_data` (
|
||||
`dict_type`, `dict_code`, `parent_code`, `label_cn`, `label_en`,
|
||||
`sort_order`, `extension_attr`, `is_default`, `status`
|
||||
) VALUES (
|
||||
'voiceprint', 'voiceprint', 'ROOT', '声纹配置', 'Voiceprint Config',
|
||||
0, '{"channels": 1, "sample_rate": 16000, "template_text": "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。", "duration_seconds": 12}', 0, 1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`extension_attr` = VALUES(`extension_attr`),
|
||||
`label_cn` = VALUES(`label_cn`);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
-- 为现有数据库添加转录任务支持的SQL脚本
|
||||
|
||||
-- 1. 更新audio_files表结构,添加缺失字段
|
||||
ALTER TABLE audio_files
|
||||
ADD COLUMN file_name VARCHAR(255) AFTER meeting_id,
|
||||
ADD COLUMN file_size BIGINT DEFAULT NULL AFTER file_path,
|
||||
ADD COLUMN task_id VARCHAR(255) DEFAULT NULL AFTER upload_time;
|
||||
|
||||
-- 2. 创建转录任务表
|
||||
CREATE TABLE transcript_tasks (
|
||||
task_id VARCHAR(255) PRIMARY KEY,
|
||||
meeting_id INT NOT NULL,
|
||||
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
progress INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
error_message TEXT NULL,
|
||||
|
||||
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3. 添加索引以优化查询性能
|
||||
-- audio_files 表索引
|
||||
ALTER TABLE audio_files ADD INDEX idx_task_id (task_id);
|
||||
|
||||
-- transcript_tasks 表索引
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_meeting_id (meeting_id);
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_status (status);
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_created_at (created_at);
|
||||
|
||||
-- 4. 更新现有测试数据(如果需要)
|
||||
-- 这些语句是可选的,用于更新现有的测试数据
|
||||
UPDATE audio_files SET file_name = 'test_audio.mp3' WHERE file_name IS NULL;
|
||||
UPDATE audio_files SET file_size = 10485760 WHERE file_size IS NULL; -- 10MB
|
||||
|
||||
SELECT '转录任务表创建完成!' as message;
|
||||
|
||||
|
||||
CREATE TABLE llm_tasks (
|
||||
task_id VARCHAR(100) PRIMARY KEY,
|
||||
llm_task_id VARCHAR(100) DEFAULT NULL,
|
||||
meeting_id INT NOT NULL,
|
||||
user_prompt TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
progress INT DEFAULT 0,
|
||||
result TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
INDEX idx_meeting_id (meeting_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
API安全性测试脚本
|
||||
测试添加JWT验证后,API端点是否正确拒绝未授权访问
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate
|
||||
python test/test_api_security.py
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def test_unauthorized_access():
|
||||
"""测试未授权访问各个API端点"""
|
||||
print("=== API安全性测试 ===")
|
||||
print("测试未授权访问是否被正确拒绝\n")
|
||||
|
||||
# 需要验证的API端点
|
||||
protected_endpoints = [
|
||||
# Users endpoints
|
||||
("GET", "/api/users", "获取所有用户"),
|
||||
("GET", "/api/users/1", "获取用户详情"),
|
||||
|
||||
# Meetings endpoints
|
||||
("GET", "/api/meetings", "获取会议列表"),
|
||||
("GET", "/api/meetings/1", "获取会议详情"),
|
||||
("GET", "/api/meetings/1/transcript", "获取会议转录"),
|
||||
("GET", "/api/meetings/1/edit", "获取会议编辑信息"),
|
||||
("GET", "/api/meetings/1/audio", "获取会议音频"),
|
||||
("POST", "/api/meetings/1/regenerate-summary", "重新生成摘要"),
|
||||
("GET", "/api/meetings/1/summaries", "获取会议摘要"),
|
||||
("GET", "/api/meetings/1/transcription/status", "获取转录状态"),
|
||||
|
||||
# Auth endpoints (需要token的)
|
||||
("GET", "/api/auth/me", "获取用户信息"),
|
||||
("POST", "/api/auth/logout", "登出"),
|
||||
("POST", "/api/auth/logout-all", "登出所有设备"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
total_count = len(protected_endpoints)
|
||||
|
||||
for method, endpoint, description in protected_endpoints:
|
||||
try:
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
|
||||
if method == "GET":
|
||||
response = requests.get(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "POST":
|
||||
response = requests.post(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "PUT":
|
||||
response = requests.put(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "DELETE":
|
||||
response = requests.delete(url, proxies=PROXIES, timeout=5)
|
||||
|
||||
if response.status_code == 401:
|
||||
print(f"✅ {method} {endpoint} - {description}")
|
||||
print(f" 正确返回401 Unauthorized")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 错误:返回 {response.status_code},应该返回401")
|
||||
print(f" 响应: {response.text[:100]}...")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 请求异常: {e}")
|
||||
|
||||
print()
|
||||
|
||||
print(f"=== 测试结果 ===")
|
||||
print(f"通过: {success_count}/{total_count}")
|
||||
print(f"成功率: {success_count/total_count*100:.1f}%")
|
||||
|
||||
if success_count == total_count:
|
||||
print("🎉 所有API端点都正确实施了JWT验证!")
|
||||
else:
|
||||
print("⚠️ 有些API端点未正确实施JWT验证,需要修复")
|
||||
|
||||
return success_count == total_count
|
||||
|
||||
def test_valid_token_access():
|
||||
"""测试有效token的访问"""
|
||||
print("\n=== 测试有效Token访问 ===")
|
||||
|
||||
# 1. 先登录获取token
|
||||
login_data = {"username": "mula", "password": "781126"}
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print("❌ 无法登录获取测试token")
|
||||
print(f"登录响应: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
user_data = response.json()
|
||||
token = user_data["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print(f"✅ 登录成功,获得token")
|
||||
|
||||
# 2. 测试几个主要API端点
|
||||
test_endpoints = [
|
||||
("GET", "/api/auth/me", "获取当前用户信息"),
|
||||
("GET", "/api/users", "获取用户列表"),
|
||||
("GET", "/api/meetings", "获取会议列表"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
for method, endpoint, description in test_endpoints:
|
||||
try:
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
response = requests.get(url, headers=headers, proxies=PROXIES, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"✅ {method} {endpoint} - {description}")
|
||||
print(f" 正确返回200 OK")
|
||||
success_count += 1
|
||||
elif response.status_code == 500:
|
||||
print(f"⚠️ {method} {endpoint} - {description}")
|
||||
print(f" 返回500 (可能是数据库连接问题,但JWT验证通过了)")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 意外响应: {response.status_code}")
|
||||
print(f" 响应内容: {response.text[:100]}...")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 请求异常: {e}")
|
||||
|
||||
print(f"\n有效token测试: {success_count}/{len(test_endpoints)} 通过")
|
||||
return success_count == len(test_endpoints)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("API JWT安全性测试工具")
|
||||
print("=" * 50)
|
||||
|
||||
# 测试未授权访问
|
||||
unauthorized_ok = test_unauthorized_access()
|
||||
|
||||
# 测试授权访问
|
||||
authorized_ok = test_valid_token_access()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if unauthorized_ok and authorized_ok:
|
||||
print("🎉 JWT验证实施成功!")
|
||||
print("✅ 未授权访问被正确拒绝")
|
||||
print("✅ 有效token可以正常访问")
|
||||
else:
|
||||
print("⚠️ JWT验证实施不完整")
|
||||
if not unauthorized_ok:
|
||||
print("❌ 部分API未正确拒绝未授权访问")
|
||||
if not authorized_ok:
|
||||
print("❌ 有效token访问存在问题")
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建一个新的测试任务
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
|
||||
# 创建服务实例
|
||||
service = AsyncMeetingService()
|
||||
|
||||
# 创建测试任务
|
||||
meeting_id = 38
|
||||
user_prompt = "请重点关注决策事项和待办任务"
|
||||
|
||||
print("创建新任务...")
|
||||
task_id = service.start_summary_generation(meeting_id, user_prompt)
|
||||
print(f"✅ 任务创建成功: {task_id}")
|
||||
|
||||
# 获取任务状态
|
||||
status = service.get_task_status(task_id)
|
||||
print(f"任务状态: {status}")
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>JWT Token 测试工具</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
textarea { width: 100%; height: 100px; margin: 10px 0; }
|
||||
.result { background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
.error { background: #ffebee; color: #c62828; }
|
||||
.success { background: #e8f5e8; color: #2e7d2e; }
|
||||
button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>JWT Token 验证工具</h1>
|
||||
|
||||
<h3>步骤1: 从浏览器获取Token</h3>
|
||||
<p>1. 登录你的应用</p>
|
||||
<p>2. 打开开发者工具 → Application → Local Storage → 找到 'iMeetingUser'</p>
|
||||
<p>3. 复制其中的 token 值到下面的文本框</p>
|
||||
|
||||
<textarea id="tokenInput" placeholder="在此粘贴JWT token..."></textarea>
|
||||
<button onclick="decodeToken()">解码 JWT Token</button>
|
||||
|
||||
<div id="result"></div>
|
||||
|
||||
<h3>预期的JWT payload应该包含:</h3>
|
||||
<ul>
|
||||
<li><code>user_id</code>: 用户ID</li>
|
||||
<li><code>username</code>: 用户名</li>
|
||||
<li><code>caption</code>: 用户显示名</li>
|
||||
<li><code>exp</code>: 过期时间戳</li>
|
||||
<li><code>type</code>: "access"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function decodeToken() {
|
||||
const token = document.getElementById('tokenInput').value.trim();
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
if (!token) {
|
||||
resultDiv.innerHTML = '<div class="result error">请输入JWT token</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// JWT 由三部分组成,用 . 分隔
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('无效的JWT格式');
|
||||
}
|
||||
|
||||
// 解码 header
|
||||
const header = JSON.parse(atob(parts[0]));
|
||||
|
||||
// 解码 payload
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
// 检查是否是我们的JWT
|
||||
const isValidJWT = payload.type === 'access' &&
|
||||
payload.user_id &&
|
||||
payload.username &&
|
||||
payload.exp;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = payload.exp < now;
|
||||
|
||||
let resultHTML = '<div class="result ' + (isValidJWT ? 'success' : 'error') + '">';
|
||||
resultHTML += '<h4>JWT 解码结果:</h4>';
|
||||
resultHTML += '<p><strong>Header:</strong></p>';
|
||||
resultHTML += '<pre>' + JSON.stringify(header, null, 2) + '</pre>';
|
||||
resultHTML += '<p><strong>Payload:</strong></p>';
|
||||
resultHTML += '<pre>' + JSON.stringify(payload, null, 2) + '</pre>';
|
||||
|
||||
if (isExpired) {
|
||||
resultHTML += '<p style="color: red;"><strong>⚠️ Token已过期!</strong></p>';
|
||||
} else {
|
||||
const expireDate = new Date(payload.exp * 1000);
|
||||
resultHTML += '<p style="color: green;"><strong>✅ Token有效,过期时间: ' + expireDate.toLocaleString() + '</strong></p>';
|
||||
}
|
||||
|
||||
if (isValidJWT) {
|
||||
resultHTML += '<p style="color: green;"><strong>✅ 这是有效的iMeeting JWT token!</strong></p>';
|
||||
} else {
|
||||
resultHTML += '<p style="color: red;"><strong>❌ 这不是有效的iMeeting JWT token</strong></p>';
|
||||
}
|
||||
|
||||
resultHTML += '</div>';
|
||||
resultDiv.innerHTML = resultHTML;
|
||||
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = '<div class="result error">解码失败: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""
|
||||
测试知识库提示词模版选择功能
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'app')
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.async_knowledge_base_service import AsyncKnowledgeBaseService
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_get_active_knowledge_prompts():
|
||||
"""测试获取启用的知识库提示词列表"""
|
||||
print("\n=== 测试1: 获取启用的知识库提示词列表 ===")
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取KNOWLEDGE_TASK类型的启用模版
|
||||
query = """
|
||||
SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
"""
|
||||
cursor.execute(query, ('KNOWLEDGE_TASK',))
|
||||
prompts = cursor.fetchall()
|
||||
|
||||
print(f"✓ 找到 {len(prompts)} 个启用的知识库任务模版:")
|
||||
for p in prompts:
|
||||
default_flag = " [默认]" if p['is_default'] else ""
|
||||
print(f" - ID: {p['id']}, 名称: {p['name']}{default_flag}")
|
||||
|
||||
return prompts
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def test_get_task_prompt_with_id(prompts):
|
||||
"""测试通过prompt_id获取知识库提示词内容"""
|
||||
print("\n=== 测试2: 通过prompt_id获取知识库提示词内容 ===")
|
||||
|
||||
if not prompts:
|
||||
print("⚠ 没有可用的提示词模版,跳过测试")
|
||||
return
|
||||
|
||||
llm_service = LLMService()
|
||||
|
||||
# 测试获取第一个提示词
|
||||
test_prompt = prompts[0]
|
||||
try:
|
||||
content = llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id=test_prompt['id'])
|
||||
print(f"✓ 成功获取提示词 ID={test_prompt['id']}, 名称={test_prompt['name']}")
|
||||
print(f" 内容长度: {len(content)} 字符")
|
||||
print(f" 内容预览: {content[:100]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 测试获取默认提示词(不指定prompt_id)
|
||||
try:
|
||||
default_content = llm_service.get_task_prompt('KNOWLEDGE_TASK')
|
||||
print(f"✓ 成功获取默认提示词")
|
||||
print(f" 内容长度: {len(default_content)} 字符")
|
||||
except Exception as e:
|
||||
print(f"✗ 获取默认提示词失败: {e}")
|
||||
|
||||
def test_async_kb_service_signature():
|
||||
"""测试async_knowledge_base_service的方法签名"""
|
||||
print("\n=== 测试3: 验证方法签名支持prompt_id参数 ===")
|
||||
|
||||
import inspect
|
||||
async_service = AsyncKnowledgeBaseService()
|
||||
|
||||
# 检查start_generation方法签名
|
||||
sig = inspect.signature(async_service.start_generation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ start_generation 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
else:
|
||||
print(f"✗ start_generation 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
|
||||
# 检查_build_prompt方法签名
|
||||
sig2 = inspect.signature(async_service._build_prompt)
|
||||
params2 = list(sig2.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params2:
|
||||
print(f"✓ _build_prompt 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
else:
|
||||
print(f"✗ _build_prompt 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
|
||||
def test_database_schema():
|
||||
"""测试数据库schema是否包含prompt_id列"""
|
||||
print("\n=== 测试4: 验证数据库schema ===")
|
||||
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查knowledge_base_tasks表是否有prompt_id列
|
||||
cursor.execute("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'knowledge_base_tasks'
|
||||
AND COLUMN_NAME = 'prompt_id'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
print(f"✓ knowledge_base_tasks 表包含 prompt_id 列")
|
||||
print(f" 类型: {result['DATA_TYPE']}")
|
||||
print(f" 可空: {result['IS_NULLABLE']}")
|
||||
print(f" 默认值: {result['COLUMN_DEFAULT']}")
|
||||
else:
|
||||
print(f"✗ knowledge_base_tasks 表缺少 prompt_id 列")
|
||||
except Exception as e:
|
||||
print(f"✗ 数据库检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def test_api_model():
|
||||
"""测试API模型定义"""
|
||||
print("\n=== 测试5: 验证API模型定义 ===")
|
||||
|
||||
try:
|
||||
from app.models.models import CreateKnowledgeBaseRequest
|
||||
import inspect
|
||||
|
||||
# 检查CreateKnowledgeBaseRequest模型
|
||||
fields = CreateKnowledgeBaseRequest.model_fields
|
||||
|
||||
if 'prompt_id' in fields:
|
||||
print(f"✓ CreateKnowledgeBaseRequest 包含 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
else:
|
||||
print(f"✗ CreateKnowledgeBaseRequest 缺少 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ API模型检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("开始测试知识库提示词模版选择功能")
|
||||
print("=" * 60)
|
||||
|
||||
# 运行所有测试
|
||||
prompts = test_get_active_knowledge_prompts()
|
||||
test_get_task_prompt_with_id(prompts)
|
||||
test_async_kb_service_signature()
|
||||
test_database_schema()
|
||||
test_api_model()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
登录调试脚本 - 诊断JWT认证问题
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_login_debug.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
# 禁用代理以避免本地请求被代理
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def test_backend_connection():
|
||||
"""测试后端连接"""
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/", proxies=PROXIES)
|
||||
print(f"✅ 后端服务连接成功: {response.status_code}")
|
||||
return True
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务")
|
||||
return False
|
||||
|
||||
def test_login_with_debug(username, password):
|
||||
"""详细的登录测试"""
|
||||
print(f"\n=== 测试登录: {username} ===")
|
||||
|
||||
login_data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"请求URL: {BASE_URL}/api/auth/login")
|
||||
print(f"请求数据: {json.dumps(login_data, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应头: {dict(response.headers)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
user_data = response.json()
|
||||
print("✅ 登录成功!")
|
||||
print(f"用户信息: {json.dumps(user_data, ensure_ascii=False, indent=2)}")
|
||||
return user_data.get("token")
|
||||
else:
|
||||
print("❌ 登录失败")
|
||||
print(f"错误内容: {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 请求异常: {e}")
|
||||
return None
|
||||
|
||||
def test_authenticated_request(token):
|
||||
"""测试认证请求"""
|
||||
if not token:
|
||||
print("❌ 没有有效token,跳过认证测试")
|
||||
return
|
||||
|
||||
print(f"\n=== 测试认证请求 ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
# 测试 /api/auth/me
|
||||
print("测试 /api/auth/me")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✅ 认证请求成功")
|
||||
print(f"用户信息: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
else:
|
||||
print("❌ 认证请求失败")
|
||||
print(f"错误: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 认证请求异常: {e}")
|
||||
|
||||
def check_database_users():
|
||||
"""检查数据库用户"""
|
||||
try:
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
print(f"\n=== 检查数据库用户 ===")
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT user_id, username, caption, email FROM users LIMIT 10")
|
||||
users = cursor.fetchall()
|
||||
|
||||
print(f"数据库中的用户 (前10个):")
|
||||
for user in users:
|
||||
print(f" - ID: {user['user_id']}, 用户名: {user['username']}, 名称: {user['caption']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 无法访问数据库: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT登录调试工具")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. 测试后端连接
|
||||
if not test_backend_connection():
|
||||
exit(1)
|
||||
|
||||
# 2. 检查数据库用户
|
||||
check_database_users()
|
||||
|
||||
# 3. 测试登录
|
||||
username = input("\n请输入用户名 (默认: mula): ").strip() or "mula"
|
||||
password = input("请输入密码 (默认: 781126): ").strip() or "781126"
|
||||
|
||||
token = test_login_with_debug(username, password)
|
||||
|
||||
# 4. 测试认证请求
|
||||
test_authenticated_request(token)
|
||||
|
||||
print("\n=== 调试完成 ===")
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试菜单权限数据是否存在
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_menu_permissions():
|
||||
print("=== 测试菜单权限数据 ===\n")
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. 检查menus表
|
||||
print("1. 检查menus表:")
|
||||
cursor.execute("SELECT COUNT(*) as count FROM menus")
|
||||
menu_count = cursor.fetchone()['count']
|
||||
print(f" - 菜单总数: {menu_count}")
|
||||
|
||||
if menu_count > 0:
|
||||
cursor.execute("SELECT menu_id, menu_code, menu_name, is_active FROM menus ORDER BY sort_order")
|
||||
menus = cursor.fetchall()
|
||||
for menu in menus:
|
||||
print(f" - [{menu['menu_id']}] {menu['menu_name']} ({menu['menu_code']}) - 启用: {menu['is_active']}")
|
||||
else:
|
||||
print(" ⚠️ menus表为空!")
|
||||
|
||||
print()
|
||||
|
||||
# 2. 检查roles表
|
||||
print("2. 检查roles表:")
|
||||
cursor.execute("SELECT * FROM roles ORDER BY role_id")
|
||||
roles = cursor.fetchall()
|
||||
for role in roles:
|
||||
print(f" - [{role['role_id']}] {role['role_name']}")
|
||||
|
||||
print()
|
||||
|
||||
# 3. 检查role_menu_permissions表
|
||||
print("3. 检查role_menu_permissions表:")
|
||||
cursor.execute("SELECT COUNT(*) as count FROM role_menu_permissions")
|
||||
perm_count = cursor.fetchone()['count']
|
||||
print(f" - 权限总数: {perm_count}")
|
||||
|
||||
if perm_count > 0:
|
||||
cursor.execute("""
|
||||
SELECT r.role_name, m.menu_name, rmp.role_id, rmp.menu_id
|
||||
FROM role_menu_permissions rmp
|
||||
JOIN roles r ON rmp.role_id = r.role_id
|
||||
JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
ORDER BY rmp.role_id, m.sort_order
|
||||
""")
|
||||
permissions = cursor.fetchall()
|
||||
|
||||
current_role = None
|
||||
for perm in permissions:
|
||||
if current_role != perm['role_name']:
|
||||
current_role = perm['role_name']
|
||||
print(f"\n {current_role}的权限:")
|
||||
print(f" - {perm['menu_name']}")
|
||||
else:
|
||||
print(" ⚠️ role_menu_permissions表为空!")
|
||||
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 4. 检查是否需要执行SQL脚本
|
||||
if menu_count == 0 or perm_count == 0:
|
||||
print("\n❌ 数据库中缺少菜单或权限数据!")
|
||||
print("请执行以下命令初始化数据:")
|
||||
print("\nmysql -h 10.100.51.161 -u root -psagacity imeeting_dev < backend/sql/add_menu_permissions_system.sql")
|
||||
print("\n或者在MySQL客户端中执行该SQL文件。")
|
||||
else:
|
||||
print("\n✅ 菜单权限数据正常!")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_menu_permissions()
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"""
|
||||
测试提示词模版选择功能
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'app')
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_get_active_prompts():
|
||||
"""测试获取启用的提示词列表"""
|
||||
print("\n=== 测试1: 获取启用的提示词列表 ===")
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取MEETING_TASK类型的启用模版
|
||||
query = """
|
||||
SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
"""
|
||||
cursor.execute(query, ('MEETING_TASK',))
|
||||
prompts = cursor.fetchall()
|
||||
|
||||
print(f"✓ 找到 {len(prompts)} 个启用的会议任务模版:")
|
||||
for p in prompts:
|
||||
default_flag = " [默认]" if p['is_default'] else ""
|
||||
print(f" - ID: {p['id']}, 名称: {p['name']}{default_flag}")
|
||||
|
||||
return prompts
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def test_get_task_prompt_with_id(prompts):
|
||||
"""测试通过prompt_id获取提示词内容"""
|
||||
print("\n=== 测试2: 通过prompt_id获取提示词内容 ===")
|
||||
|
||||
if not prompts:
|
||||
print("⚠ 没有可用的提示词模版,跳过测试")
|
||||
return
|
||||
|
||||
llm_service = LLMService()
|
||||
|
||||
# 测试获取第一个提示词
|
||||
test_prompt = prompts[0]
|
||||
try:
|
||||
content = llm_service.get_task_prompt('MEETING_TASK', prompt_id=test_prompt['id'])
|
||||
print(f"✓ 成功获取提示词 ID={test_prompt['id']}, 名称={test_prompt['name']}")
|
||||
print(f" 内容长度: {len(content)} 字符")
|
||||
print(f" 内容预览: {content[:100]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 测试获取默认提示词(不指定prompt_id)
|
||||
try:
|
||||
default_content = llm_service.get_task_prompt('MEETING_TASK')
|
||||
print(f"✓ 成功获取默认提示词")
|
||||
print(f" 内容长度: {len(default_content)} 字符")
|
||||
except Exception as e:
|
||||
print(f"✗ 获取默认提示词失败: {e}")
|
||||
|
||||
def test_async_meeting_service_signature():
|
||||
"""测试async_meeting_service的方法签名"""
|
||||
print("\n=== 测试3: 验证方法签名支持prompt_id参数 ===")
|
||||
|
||||
import inspect
|
||||
async_service = AsyncMeetingService()
|
||||
|
||||
# 检查start_summary_generation方法签名
|
||||
sig = inspect.signature(async_service.start_summary_generation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ start_summary_generation 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
else:
|
||||
print(f"✗ start_summary_generation 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
|
||||
# 检查monitor_and_auto_summarize方法签名
|
||||
sig2 = inspect.signature(async_service.monitor_and_auto_summarize)
|
||||
params2 = list(sig2.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params2:
|
||||
print(f"✓ monitor_and_auto_summarize 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
else:
|
||||
print(f"✗ monitor_and_auto_summarize 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
|
||||
def test_database_schema():
|
||||
"""测试数据库schema是否包含prompt_id列"""
|
||||
print("\n=== 测试4: 验证数据库schema ===")
|
||||
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查llm_tasks表是否有prompt_id列
|
||||
cursor.execute("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'llm_tasks'
|
||||
AND COLUMN_NAME = 'prompt_id'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
print(f"✓ llm_tasks 表包含 prompt_id 列")
|
||||
print(f" 类型: {result['DATA_TYPE']}")
|
||||
print(f" 可空: {result['IS_NULLABLE']}")
|
||||
print(f" 默认值: {result['COLUMN_DEFAULT']}")
|
||||
else:
|
||||
print(f"✗ llm_tasks 表缺少 prompt_id 列")
|
||||
except Exception as e:
|
||||
print(f"✗ 数据库检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def test_api_endpoints():
|
||||
"""测试API端点定义"""
|
||||
print("\n=== 测试5: 验证API端点定义 ===")
|
||||
|
||||
try:
|
||||
from app.api.endpoints.meetings import GenerateSummaryRequest
|
||||
import inspect
|
||||
|
||||
# 检查GenerateSummaryRequest模型
|
||||
fields = GenerateSummaryRequest.__fields__
|
||||
|
||||
if 'prompt_id' in fields:
|
||||
print(f"✓ GenerateSummaryRequest 包含 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
else:
|
||||
print(f"✗ GenerateSummaryRequest 缺少 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
|
||||
# 检查audio_service.handle_audio_upload签名
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
sig = inspect.signature(handle_audio_upload)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ handle_audio_upload 方法支持 prompt_id 参数")
|
||||
else:
|
||||
print(f"✗ handle_audio_upload 方法缺少 prompt_id 参数")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ API端点检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("开始测试提示词模版选择功能")
|
||||
print("=" * 60)
|
||||
|
||||
# 运行所有测试
|
||||
prompts = test_get_active_prompts()
|
||||
test_get_task_prompt_with_id(prompts)
|
||||
test_async_meeting_service_signature()
|
||||
test_database_schema()
|
||||
test_api_endpoints()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
from qiniu import Auth, put_file_v2, BucketManager
|
||||
|
||||
# Add app path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
|
||||
|
||||
from app.core.config import QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
|
||||
|
||||
def test_qiniu_connection():
|
||||
print("=== 七牛云连接测试 ===")
|
||||
print(f"Access Key: {QINIU_ACCESS_KEY[:10]}...")
|
||||
print(f"Secret Key: {QINIU_SECRET_KEY[:10]}...")
|
||||
print(f"Bucket: {QINIU_BUCKET}")
|
||||
print(f"Domain: {QINIU_DOMAIN}")
|
||||
|
||||
# 创建认证对象
|
||||
q = Auth(QINIU_ACCESS_KEY, QINIU_SECRET_KEY)
|
||||
|
||||
# 测试1: 生成上传token
|
||||
try:
|
||||
key = "test/connection-test.txt"
|
||||
token = q.upload_token(QINIU_BUCKET, key, 3600)
|
||||
print(f"✓ Token生成成功: {token[:50]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ Token生成失败: {e}")
|
||||
return False
|
||||
|
||||
# 测试2: 列举存储空间 (测试认证是否正确)
|
||||
try:
|
||||
bucket_manager = BucketManager(q)
|
||||
ret, eof, info = bucket_manager.list(QINIU_BUCKET, limit=100)
|
||||
print(f"✓ Bucket访问成功, status_code: {info.status_code}")
|
||||
if ret:
|
||||
print(f" 存储空间中有文件: {len(ret.get('items', []))} 个")
|
||||
except Exception as e:
|
||||
print(f"✗ Bucket访问失败: {e}")
|
||||
return False
|
||||
|
||||
# 测试3: 上传一个小文件
|
||||
test_file = "/Users/jiliu/工作/projects/imeeting/backend/uploads/result.json"
|
||||
if os.path.exists(test_file):
|
||||
try:
|
||||
key = "test/result1.json"
|
||||
token = q.upload_token(QINIU_BUCKET, key, 3600)
|
||||
ret, info = put_file_v2(token, key, test_file, version='v2')
|
||||
|
||||
print(f"上传结果:")
|
||||
print(f" ret: {ret}")
|
||||
print(f" status_code: {info.status_code}")
|
||||
print(f" text_body: {info.text_body}")
|
||||
print(f" url: {info.url}")
|
||||
print(f" req_id: {info.req_id}")
|
||||
print(f" x_log: {info.x_log}")
|
||||
|
||||
if info.status_code == 200:
|
||||
print("✓ 文件上传成功")
|
||||
url = f"http://{QINIU_DOMAIN}/{key}"
|
||||
print(f" 访问URL: {url}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ 文件上传失败: {info.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 文件上传异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
else:
|
||||
print(f"✗ 测试文件不存在: {test_file}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_qiniu_connection()
|
||||
if success:
|
||||
print("\n🎉 七牛云连接测试成功!")
|
||||
else:
|
||||
print("\n❌ 七牛云连接测试失败!")
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Redis JWT Token 验证脚本
|
||||
用于检查JWT token是否正确存储在Redis中
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_redis_jwt.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import redis
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from app.core.config import REDIS_CONFIG
|
||||
print("✅ 成功导入项目配置")
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入项目配置失败: {e}")
|
||||
print("请确保在 backend 目录下运行: python test/test_redis_jwt.py")
|
||||
sys.exit(1)
|
||||
|
||||
def check_jwt_in_redis():
|
||||
"""检查Redis中的JWT token"""
|
||||
try:
|
||||
# 使用项目配置连接Redis
|
||||
r = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
# 测试连接
|
||||
r.ping()
|
||||
print("✅ Redis连接成功")
|
||||
print(f"连接配置: {REDIS_CONFIG}")
|
||||
|
||||
# 获取所有token相关的keys
|
||||
token_keys = r.keys("token:*")
|
||||
|
||||
if not token_keys:
|
||||
print("❌ Redis中没有找到JWT token")
|
||||
print("提示: 请先通过前端登录以生成token")
|
||||
return False
|
||||
|
||||
print(f"✅ 找到 {len(token_keys)} 个token记录:")
|
||||
|
||||
for key in token_keys:
|
||||
# 解析key格式: token:user_id:jwt_token
|
||||
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
||||
parts = key_str.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
user_id = parts[1]
|
||||
token_preview = parts[2][:20] + "..."
|
||||
ttl = r.ttl(key)
|
||||
value = r.get(key)
|
||||
value_str = value.decode('utf-8') if isinstance(value, bytes) else value
|
||||
|
||||
print(f" - 用户ID: {user_id}")
|
||||
print(f" Token预览: {token_preview}")
|
||||
if ttl > 0:
|
||||
print(f" 剩余时间: {ttl}秒 ({ttl/3600:.1f}小时)")
|
||||
else:
|
||||
print(f" TTL: {ttl} (永不过期)" if ttl == -1 else f" TTL: {ttl} (已过期)")
|
||||
print(f" 状态: {value_str}")
|
||||
print()
|
||||
|
||||
return True
|
||||
|
||||
except redis.ConnectionError:
|
||||
print("❌ 无法连接到Redis服务器")
|
||||
print("请确保Redis服务正在运行:")
|
||||
print(" brew services start redis # macOS")
|
||||
print(" 或 redis-server # 直接启动")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 检查失败: {e}")
|
||||
return False
|
||||
|
||||
def test_token_operations():
|
||||
"""测试token操作"""
|
||||
try:
|
||||
r = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
print("\n=== Token操作测试 ===")
|
||||
|
||||
# 模拟创建token
|
||||
test_key = "token:999:test_token_12345"
|
||||
r.setex(test_key, 60, "active")
|
||||
print(f"✅ 创建测试token: {test_key}")
|
||||
|
||||
# 检查token存在
|
||||
if r.exists(test_key):
|
||||
print("✅ Token存在性验证通过")
|
||||
|
||||
# 检查TTL
|
||||
ttl = r.ttl(test_key)
|
||||
print(f"✅ Token TTL: {ttl}秒")
|
||||
|
||||
# 删除测试token
|
||||
r.delete(test_key)
|
||||
print("✅ 清理测试token")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Token操作测试失败: {e}")
|
||||
return False
|
||||
|
||||
def test_jwt_service():
|
||||
"""测试JWT服务"""
|
||||
try:
|
||||
from app.services.jwt_service import jwt_service
|
||||
|
||||
print("\n=== JWT服务测试 ===")
|
||||
|
||||
# 测试创建token
|
||||
test_data = {
|
||||
"user_id": 999,
|
||||
"username": "test_user",
|
||||
"caption": "测试用户"
|
||||
}
|
||||
|
||||
token = jwt_service.create_access_token(test_data)
|
||||
print(f"✅ 创建JWT token: {token[:30]}...")
|
||||
|
||||
# 测试验证token
|
||||
payload = jwt_service.verify_token(token)
|
||||
if payload:
|
||||
print(f"✅ Token验证成功: 用户ID={payload['user_id']}, 用户名={payload['username']}")
|
||||
else:
|
||||
print("❌ Token验证失败")
|
||||
return False
|
||||
|
||||
# 测试撤销token
|
||||
revoked = jwt_service.revoke_token(token, test_data["user_id"])
|
||||
print(f"✅ 撤销token: {'成功' if revoked else '失败'}")
|
||||
|
||||
# 验证撤销后token失效
|
||||
payload_after_revoke = jwt_service.verify_token(token)
|
||||
if not payload_after_revoke:
|
||||
print("✅ Token撤销后验证失败,符合预期")
|
||||
else:
|
||||
print("❌ Token撤销后仍然有效,不符合预期")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ JWT服务测试失败: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT + Redis 认证系统测试")
|
||||
print("=" * 50)
|
||||
print(f"工作目录: {os.getcwd()}")
|
||||
print(f"测试脚本路径: {__file__}")
|
||||
|
||||
# 检查Redis中的JWT tokens
|
||||
redis_ok = check_jwt_in_redis()
|
||||
|
||||
# 测试token操作
|
||||
operations_ok = test_token_operations()
|
||||
|
||||
# 测试JWT服务
|
||||
jwt_service_ok = test_jwt_service()
|
||||
|
||||
print("=" * 50)
|
||||
if redis_ok and operations_ok and jwt_service_ok:
|
||||
print("✅ JWT + Redis 认证系统工作正常!")
|
||||
else:
|
||||
print("❌ JWT + Redis 认证系统存在问题")
|
||||
print("\n故障排除建议:")
|
||||
print("1. 确保在 backend 目录下运行测试")
|
||||
print("2. 确保Redis服务正在运行")
|
||||
print("3. 确保已安装所有依赖: pip install -r requirements.txt")
|
||||
print("4. 尝试先通过前端登录生成token")
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试Redis连接和LLM任务队列
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import redis
|
||||
from app.core.config import REDIS_CONFIG
|
||||
|
||||
# 连接Redis
|
||||
redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
try:
|
||||
# 测试连接
|
||||
redis_client.ping()
|
||||
print("✅ Redis连接成功")
|
||||
|
||||
# 检查任务队列
|
||||
queue_length = redis_client.llen("llm_task_queue")
|
||||
print(f"📋 当前任务队列长度: {queue_length}")
|
||||
|
||||
# 检查所有LLM任务
|
||||
keys = redis_client.keys("llm_task:*")
|
||||
print(f"📊 当前存在的LLM任务: {len(keys)} 个")
|
||||
|
||||
for key in keys:
|
||||
task_data = redis_client.hgetall(key)
|
||||
# key可能是bytes或str
|
||||
if isinstance(key, bytes):
|
||||
task_id = key.decode('utf-8').replace('llm_task:', '')
|
||||
else:
|
||||
task_id = key.replace('llm_task:', '')
|
||||
|
||||
# 获取状态和进度
|
||||
status = task_data.get(b'status', task_data.get('status', 'unknown'))
|
||||
if isinstance(status, bytes):
|
||||
status = status.decode('utf-8')
|
||||
|
||||
progress = task_data.get(b'progress', task_data.get('progress', '0'))
|
||||
if isinstance(progress, bytes):
|
||||
progress = progress.decode('utf-8')
|
||||
|
||||
print(f" - 任务 {task_id[:8]}... 状态: {status}, 进度: {progress}%")
|
||||
|
||||
# 如果任务是pending,重新推送到队列
|
||||
if status == 'pending':
|
||||
print(f" 🔄 发现pending任务,重新推送到队列...")
|
||||
redis_client.lpush("llm_task_queue", task_id)
|
||||
print(f" ✅ 任务 {task_id[:8]}... 已重新推送到队列")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f"❌ Redis连接失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试流式LLM服务
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
|
||||
def test_stream_generation():
|
||||
"""测试流式生成功能"""
|
||||
print("=== 测试流式LLM生成 ===")
|
||||
|
||||
llm_service = LLMService()
|
||||
test_meeting_id = 38 # 使用一个存在的会议ID
|
||||
test_user_prompt = "请重点关注决策事项和待办任务"
|
||||
|
||||
print(f"开始为会议 {test_meeting_id} 生成流式总结...")
|
||||
print("输出内容:")
|
||||
print("-" * 50)
|
||||
|
||||
full_content = ""
|
||||
chunk_count = 0
|
||||
|
||||
try:
|
||||
for chunk in llm_service.generate_meeting_summary_stream(test_meeting_id, test_user_prompt):
|
||||
if chunk.startswith("error:"):
|
||||
print(f"\n生成过程中出现错误: {chunk}")
|
||||
break
|
||||
else:
|
||||
print(chunk, end='', flush=True)
|
||||
full_content += chunk
|
||||
chunk_count += 1
|
||||
|
||||
print(f"\n\n-" * 50)
|
||||
print(f"流式生成完成!")
|
||||
print(f"总共接收到 {chunk_count} 个数据块")
|
||||
print(f"完整内容长度: {len(full_content)} 字符")
|
||||
|
||||
# 测试传统方式(对比)
|
||||
print("\n=== 对比测试传统生成方式 ===")
|
||||
result = llm_service.generate_meeting_summary(test_meeting_id, test_user_prompt)
|
||||
if result.get("error"):
|
||||
print(f"传统方式生成失败: {result['error']}")
|
||||
else:
|
||||
print("传统方式生成成功!")
|
||||
print(f"内容长度: {len(result['content'])} 字符")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n测试过程中出现异常: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_stream_generation()
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
JWT Token 过期测试脚本
|
||||
用于测试JWT token的过期、撤销机制,可以模拟指定用户的token失效
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_token_expiration.py
|
||||
|
||||
功能:
|
||||
1. 登录指定用户并获取token
|
||||
2. 验证token有效性
|
||||
3. 撤销指定用户的所有token(模拟失效)
|
||||
4. 验证撤销后token失效
|
||||
|
||||
期望结果:在网页上登录的用户执行失效命令后,网页会自动登出
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
# 禁用代理以避免本地请求被代理
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def invalidate_user_tokens():
|
||||
"""模拟指定用户的token失效"""
|
||||
print("模拟用户Token失效工具")
|
||||
print("=" * 40)
|
||||
|
||||
# 获取要失效的用户名
|
||||
target_username = input("请输入要失效token的用户名 (默认: mula): ").strip()
|
||||
if not target_username:
|
||||
target_username = "mula"
|
||||
|
||||
# 获取管理员凭据来执行失效操作
|
||||
admin_username = input("请输入管理员用户名 (默认: mula): ").strip()
|
||||
admin_password = input("请输入管理员密码 (默认: 781126): ").strip()
|
||||
|
||||
if not admin_username:
|
||||
admin_username = "mula"
|
||||
if not admin_password:
|
||||
admin_password = "781126"
|
||||
|
||||
try:
|
||||
# 1. 管理员登录获取token
|
||||
print(f"\n步骤1: 管理员登录 ({admin_username})")
|
||||
admin_login_data = {
|
||||
"username": admin_username,
|
||||
"password": admin_password
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=admin_login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 管理员登录失败")
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return
|
||||
|
||||
admin_data = response.json()
|
||||
admin_token = admin_data["token"]
|
||||
admin_headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
print(f"✅ 管理员登录成功: {admin_data['username']} ({admin_data['caption']})")
|
||||
|
||||
# 2. 如果目标用户不是管理员,先登录目标用户验证token存在
|
||||
if target_username != admin_username:
|
||||
print(f"\n步骤2: 验证目标用户 ({target_username}) 是否存在")
|
||||
target_password = input(f"请输入 {target_username} 的密码 (用于验证): ").strip()
|
||||
if not target_password:
|
||||
print("❌ 需要提供目标用户的密码来验证")
|
||||
return
|
||||
|
||||
target_login_data = {
|
||||
"username": target_username,
|
||||
"password": target_password
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=target_login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 目标用户登录失败,无法验证用户存在")
|
||||
return
|
||||
|
||||
target_data = response.json()
|
||||
print(f"✅ 目标用户验证成功: {target_data['username']} ({target_data['caption']})")
|
||||
target_user_id = target_data['user_id']
|
||||
else:
|
||||
target_user_id = admin_data['user_id']
|
||||
|
||||
# 3. 撤销目标用户的所有token
|
||||
print(f"\n步骤3: 撤销用户 {target_username} (ID: {target_user_id}) 的所有token")
|
||||
|
||||
# 使用管理员权限调用新的admin API
|
||||
response = requests.post(f"{BASE_URL}/api/auth/admin/revoke-user-tokens/{target_user_id}",
|
||||
headers=admin_headers, proxies=PROXIES)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✅ Token撤销成功: {result.get('message', '已撤销所有token')}")
|
||||
|
||||
# 4. 验证token是否真的失效了
|
||||
print(f"\n步骤4: 验证token失效")
|
||||
if target_username != admin_username:
|
||||
# 尝试使用目标用户的token访问protected API
|
||||
target_token = target_data["token"]
|
||||
target_headers = {"Authorization": f"Bearer {target_token}"}
|
||||
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=target_headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print(f"✅ 验证成功:用户 {target_username} 的token已失效")
|
||||
else:
|
||||
print(f"❌ 验证失败:用户 {target_username} 的token仍然有效")
|
||||
else:
|
||||
# 如果目标用户就是管理员,验证当前管理员token是否失效
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=admin_headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print(f"✅ 验证成功:用户 {target_username} 的token已失效")
|
||||
else:
|
||||
print(f"❌ 验证失败:用户 {target_username} 的token仍然有效")
|
||||
|
||||
print(f"\n🌟 操作完成!")
|
||||
print(f"如果用户 {target_username} 在网页上已登录,现在应该会自动登出。")
|
||||
print(f"你可以在网页上验证是否自动跳转到登录页面。")
|
||||
|
||||
else:
|
||||
print(f"❌ Token撤销失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务器,请确保服务器正在运行")
|
||||
except Exception as e:
|
||||
print(f"❌ 操作失败: {e}")
|
||||
|
||||
def test_token_expiration():
|
||||
"""测试token过期机制"""
|
||||
print("JWT Token 过期测试")
|
||||
print("=" * 40)
|
||||
|
||||
# 1. 登录获取token
|
||||
username = input("请输入用户名 (默认: test): ").strip()
|
||||
password = input("请输入密码 (默认: test): ").strip()
|
||||
|
||||
# 使用默认值如果输入为空
|
||||
if not username:
|
||||
username = "test"
|
||||
if not password:
|
||||
password = "test"
|
||||
|
||||
login_data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
try:
|
||||
# 登录
|
||||
print(f"正在尝试登录用户: {login_data['username']}")
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 登录失败")
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
print(f"请求URL: {BASE_URL}/api/auth/login")
|
||||
print("请检查:")
|
||||
print("1. 后端服务是否正在运行")
|
||||
print("2. 用户名和密码是否正确")
|
||||
print("3. 数据库连接是否正常")
|
||||
return
|
||||
|
||||
user_data = response.json()
|
||||
token = user_data["token"]
|
||||
|
||||
print(f"✅ 登录成功,获得token: {token[:20]}...")
|
||||
|
||||
# 2. 测试token有效性
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print("\n测试1: 验证token有效性")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
print(f"✅ Token有效,用户: {user_info.get('username')}")
|
||||
else:
|
||||
print(f"❌ Token无效: {response.status_code}")
|
||||
return
|
||||
|
||||
# 3. 测试受保护的API
|
||||
print("\n测试2: 访问受保护的API")
|
||||
response = requests.get(f"{BASE_URL}/api/meetings", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
print("✅ 成功访问会议列表API")
|
||||
else:
|
||||
print(f"❌ 访问受保护API失败: {response.status_code}")
|
||||
|
||||
# 4. 登出token
|
||||
print("\n测试3: 登出token")
|
||||
response = requests.post(f"{BASE_URL}/api/auth/logout", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
print("✅ 登出成功")
|
||||
|
||||
# 5. 验证登出后token失效
|
||||
print("\n测试4: 验证登出后token失效")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print("✅ Token已失效,登出成功")
|
||||
else:
|
||||
print(f"❌ Token仍然有效,登出失败: {response.status_code}")
|
||||
else:
|
||||
print(f"❌ 登出失败: {response.status_code}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务器,请确保服务器正在运行")
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
|
||||
def check_token_format():
|
||||
"""检查token格式是否为JWT"""
|
||||
token = input("\n请粘贴JWT token (或按Enter跳过): ").strip()
|
||||
|
||||
if not token:
|
||||
return
|
||||
|
||||
print(f"\nJWT格式检查:")
|
||||
|
||||
# JWT应该有三个部分,用.分隔
|
||||
parts = token.split('.')
|
||||
if len(parts) != 3:
|
||||
print("❌ 不是有效的JWT格式 (应该有3个部分用.分隔)")
|
||||
return
|
||||
|
||||
try:
|
||||
import base64
|
||||
import json
|
||||
|
||||
# 解码header
|
||||
header_padding = parts[0] + '=' * (4 - len(parts[0]) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(header_padding))
|
||||
|
||||
# 解码payload
|
||||
payload_padding = parts[1] + '=' * (4 - len(parts[1]) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_padding))
|
||||
|
||||
print("✅ JWT格式有效")
|
||||
print(f"算法: {header.get('alg')}")
|
||||
print(f"类型: {header.get('typ')}")
|
||||
print(f"用户ID: {payload.get('user_id')}")
|
||||
print(f"用户名: {payload.get('username')}")
|
||||
|
||||
if 'exp' in payload:
|
||||
exp_time = datetime.fromtimestamp(payload['exp'])
|
||||
print(f"过期时间: {exp_time}")
|
||||
|
||||
if datetime.now() > exp_time:
|
||||
print("❌ Token已过期")
|
||||
else:
|
||||
print("✅ Token未过期")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ JWT解码失败: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT Token 测试工具")
|
||||
print("=" * 50)
|
||||
print(f"工作目录: {os.getcwd()}")
|
||||
print(f"测试脚本路径: {__file__}")
|
||||
print()
|
||||
|
||||
print("请选择功能:")
|
||||
print("1. 模拟指定用户Token失效 (推荐)")
|
||||
print("2. 完整Token过期测试")
|
||||
print("3. JWT格式检查")
|
||||
|
||||
choice = input("\n请输入选项 (1-3, 默认: 1): ").strip()
|
||||
|
||||
if choice == "2":
|
||||
test_token_expiration()
|
||||
check_token_format()
|
||||
elif choice == "3":
|
||||
check_token_format()
|
||||
else:
|
||||
# 默认选择1
|
||||
invalidate_user_tokens()
|
||||
|
||||
print("\n=== 测试完成 ===")
|
||||
print("如果测试失败,请检查:")
|
||||
print("1. 确保后端服务正在运行: python main.py")
|
||||
print("2. 确保在 backend 目录下运行测试")
|
||||
print("3. 确保Redis服务正在运行")
|
||||
print("4. 如果选择了选项1,请在网页上验证用户是否自动登出")
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
"""
|
||||
测试 upload_audio 接口的 auto_summarize 参数
|
||||
"""
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
# 请替换为你的有效token
|
||||
AUTH_TOKEN = "your_auth_token_here"
|
||||
# 请替换为你的测试会议ID
|
||||
TEST_MEETING_ID = 1
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {AUTH_TOKEN}"
|
||||
}
|
||||
|
||||
|
||||
def test_upload_audio(auto_summarize=True):
|
||||
"""测试音频上传接口"""
|
||||
print("=" * 60)
|
||||
print(f"测试: upload_audio 接口 (auto_summarize={auto_summarize})")
|
||||
print("=" * 60)
|
||||
|
||||
# 准备测试文件
|
||||
audio_file_path = "test_audio.mp3" # 请替换为实际的音频文件路径
|
||||
|
||||
try:
|
||||
with open(audio_file_path, 'rb') as audio_file:
|
||||
files = {
|
||||
'audio_file': ('test_audio.mp3', audio_file, 'audio/mpeg')
|
||||
}
|
||||
data = {
|
||||
'force_replace': 'false',
|
||||
'auto_summarize': 'true' if auto_summarize else 'false'
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
url = f"{BASE_URL}/meetings/upload-audio"
|
||||
print(f"\n发送请求到: {url}")
|
||||
print(f"参数: auto_summarize={data['auto_summarize']}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
# 如果上传成功,获取任务ID
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
task_id = response_data['data'].get('task_id')
|
||||
auto_sum = response_data['data'].get('auto_summarize')
|
||||
print(f"\n✓ 上传成功! 转录任务ID: {task_id}")
|
||||
print(f" 自动总结: {'开启' if auto_sum else '关闭'}")
|
||||
if auto_sum:
|
||||
print(f" 提示: 音频已上传,后台正在自动进行转录和总结")
|
||||
else:
|
||||
print(f" 提示: 音频已上传,正在进行转录(不会自动总结)")
|
||||
print(f"\n 可以通过以下接口查询状态:")
|
||||
print(f" - 转录状态: GET /meetings/{TEST_MEETING_ID}/transcription/status")
|
||||
print(f" - 总结任务: GET /meetings/{TEST_MEETING_ID}/llm-tasks")
|
||||
print(f" - 会议详情: GET /meetings/{TEST_MEETING_ID}")
|
||||
return True
|
||||
elif response_data.get('code') == '300':
|
||||
print("\n⚠ 需要确认替换现有文件")
|
||||
return False
|
||||
else:
|
||||
print(f"\n✗ 上传失败")
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"\n✗ 错误: 找不到测试音频文件 {audio_file_path}")
|
||||
print("请创建一个测试音频文件或修改 audio_file_path 变量")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_get_transcription_status():
|
||||
"""测试获取转录状态接口"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试: 获取转录状态")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/transcription/status"
|
||||
print(f"\n发送请求到: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
data = response_data['data']
|
||||
print(f"\n✓ 获取转录状态成功!")
|
||||
print(f" - 任务ID: {data.get('task_id')}")
|
||||
print(f" - 状态: {data.get('status')}")
|
||||
print(f" - 进度: {data.get('progress')}%")
|
||||
return data.get('status'), data.get('progress')
|
||||
else:
|
||||
print(f"\n✗ 获取状态失败")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def test_get_llm_tasks():
|
||||
"""测试获取LLM任务列表"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试: 获取LLM任务列表")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/llm-tasks"
|
||||
print(f"\n发送请求到: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
tasks = response_data['data'].get('tasks', [])
|
||||
print(f"\n✓ 获取LLM任务成功! 共 {len(tasks)} 个任务")
|
||||
if tasks:
|
||||
latest_task = tasks[0]
|
||||
print(f" 最新任务:")
|
||||
print(f" - 任务ID: {latest_task.get('task_id')}")
|
||||
print(f" - 状态: {latest_task.get('status')}")
|
||||
print(f" - 进度: {latest_task.get('progress')}%")
|
||||
return latest_task.get('status'), latest_task.get('progress')
|
||||
return None, None
|
||||
else:
|
||||
print(f"\n✗ 获取任务失败")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def monitor_progress():
|
||||
"""持续监控处理进度"""
|
||||
print("\n" + "=" * 60)
|
||||
print("持续监控处理进度 (每10秒查询一次)")
|
||||
print("按 Ctrl+C 停止监控")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
transcription_completed = False
|
||||
summary_completed = False
|
||||
|
||||
while True:
|
||||
print(f"\n[{time.strftime('%H:%M:%S')}] 查询状态...")
|
||||
|
||||
# 查询转录状态
|
||||
trans_status, trans_progress = test_get_transcription_status()
|
||||
|
||||
# 如果转录完成,查询总结状态
|
||||
if trans_status == 'completed' and not transcription_completed:
|
||||
print(f"\n✓ 转录已完成!")
|
||||
transcription_completed = True
|
||||
|
||||
if transcription_completed:
|
||||
summ_status, summ_progress = test_get_llm_tasks()
|
||||
if summ_status == 'completed' and not summary_completed:
|
||||
print(f"\n✓ 总结已完成!")
|
||||
summary_completed = True
|
||||
break
|
||||
elif summ_status == 'failed':
|
||||
print(f"\n✗ 总结失败")
|
||||
break
|
||||
|
||||
# 检查转录是否失败
|
||||
if trans_status == 'failed':
|
||||
print(f"\n✗ 转录失败")
|
||||
break
|
||||
|
||||
# 如果全部完成,退出
|
||||
if transcription_completed and summary_completed:
|
||||
print(f"\n✓ 全部完成!")
|
||||
break
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠ 用户中断监控")
|
||||
except Exception as e:
|
||||
print(f"\n✗ 监控出错: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("\n")
|
||||
print("╔" + "═" * 58 + "╗")
|
||||
print("║" + " " * 12 + "upload_audio 接口测试" + " " * 23 + "║")
|
||||
print("║" + " " * 10 + "(测试 auto_summarize 参数)" + " " * 17 + "║")
|
||||
print("╚" + "═" * 58 + "╝")
|
||||
|
||||
print("\n请确保:")
|
||||
print("1. 后端服务正在运行 (http://localhost:8000)")
|
||||
print("2. 已修改脚本中的 AUTH_TOKEN 和 TEST_MEETING_ID")
|
||||
print("3. 已准备好测试音频文件")
|
||||
|
||||
input("\n按回车键开始测试...")
|
||||
|
||||
# 测试1: 查看当前转录状态
|
||||
test_get_transcription_status()
|
||||
|
||||
# 测试2: 查看当前LLM任务
|
||||
test_get_llm_tasks()
|
||||
|
||||
# 询问要测试哪种模式
|
||||
print("\n" + "-" * 60)
|
||||
print("请选择测试模式:")
|
||||
print("1. 仅转录 (auto_summarize=false)")
|
||||
print("2. 转录+自动总结 (auto_summarize=true)")
|
||||
print("3. 两种模式都测试")
|
||||
choice = input("请输入选项 (1/2/3): ")
|
||||
|
||||
if choice == '1':
|
||||
# 测试:仅转录
|
||||
if test_upload_audio(auto_summarize=False):
|
||||
print("\n⚠ 注意: 此模式下不会自动生成总结")
|
||||
print("如需生成总结,请手动调用: POST /meetings/{meeting_id}/generate-summary-async")
|
||||
elif choice == '2':
|
||||
# 测试:转录+自动总结
|
||||
if test_upload_audio(auto_summarize=True):
|
||||
print("\n" + "-" * 60)
|
||||
choice = input("是否要持续监控处理进度? (y/n): ")
|
||||
if choice.lower() == 'y':
|
||||
monitor_progress()
|
||||
elif choice == '3':
|
||||
# 两种模式都测试
|
||||
print("\n" + "=" * 60)
|
||||
print("测试模式1: 仅转录 (auto_summarize=false)")
|
||||
print("=" * 60)
|
||||
test_upload_audio(auto_summarize=False)
|
||||
|
||||
input("\n按回车键继续测试模式2...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试模式2: 转录+自动总结 (auto_summarize=true)")
|
||||
print("=" * 60)
|
||||
if test_upload_audio(auto_summarize=True):
|
||||
print("\n" + "-" * 60)
|
||||
choice = input("是否要持续监控处理进度? (y/n): ")
|
||||
if choice.lower() == 'y':
|
||||
monitor_progress()
|
||||
else:
|
||||
print("\n✗ 无效选项")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
print("=" * 60)
|
||||
print("\n总结:")
|
||||
print("- auto_summarize=false: 只执行转录,不自动生成总结")
|
||||
print("- auto_summarize=true: 执行转录后自动生成总结")
|
||||
print("- 默认值: true (向前兼容)")
|
||||
print("- 现有页面建议设置: auto_summarize=false")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
声纹采集API测试脚本
|
||||
|
||||
使用方法:
|
||||
1. 确保后端服务正在运行
|
||||
2. 修改 USER_ID 和 TOKEN 为实际值
|
||||
3. 准备一个10秒的WAV音频文件
|
||||
4. 运行: python test_voiceprint_api.py
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
USER_ID = 1 # 修改为实际用户ID
|
||||
TOKEN = "" # 登录后获取的token
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def test_get_template():
|
||||
"""测试获取朗读模板"""
|
||||
print("\n=== 测试1: 获取朗读模板 ===")
|
||||
url = f"{BASE_URL}/voiceprint/template"
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_get_status(user_id):
|
||||
"""测试获取声纹状态"""
|
||||
print(f"\n=== 测试2: 获取用户 {user_id} 的声纹状态 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_upload_voiceprint(user_id, audio_file_path):
|
||||
"""测试上传声纹"""
|
||||
print(f"\n=== 测试3: 上传声纹音频 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
|
||||
# 移除Content-Type,让requests自动设置multipart/form-data
|
||||
upload_headers = {
|
||||
"Authorization": f"Bearer {TOKEN}"
|
||||
}
|
||||
|
||||
with open(audio_file_path, 'rb') as f:
|
||||
files = {'audio_file': (audio_file_path.split('/')[-1], f, 'audio/wav')}
|
||||
response = requests.post(url, headers=upload_headers, files=files)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_delete_voiceprint(user_id):
|
||||
"""测试删除声纹"""
|
||||
print(f"\n=== 测试4: 删除用户 {user_id} 的声纹 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
response = requests.delete(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def login(username, password):
|
||||
"""登录获取token"""
|
||||
print("\n=== 登录获取Token ===")
|
||||
url = f"{BASE_URL}/auth/login"
|
||||
data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
response = requests.post(url, json=data)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('code') == '200':
|
||||
token = result['data']['token']
|
||||
print(f"登录成功,Token: {token[:20]}...")
|
||||
return token
|
||||
else:
|
||||
print(f"登录失败: {result.get('message')}")
|
||||
return None
|
||||
else:
|
||||
print(f"请求失败,状态码: {response.status_code}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("声纹采集API测试脚本")
|
||||
print("=" * 60)
|
||||
|
||||
# 步骤1: 登录(如果没有token)
|
||||
if not TOKEN:
|
||||
print("\n请先登录获取Token...")
|
||||
username = input("用户名: ")
|
||||
password = input("密码: ")
|
||||
TOKEN = login(username, password)
|
||||
if TOKEN:
|
||||
headers["Authorization"] = f"Bearer {TOKEN}"
|
||||
else:
|
||||
print("登录失败,退出测试")
|
||||
exit(1)
|
||||
|
||||
# 步骤2: 测试获取朗读模板
|
||||
test_get_template()
|
||||
|
||||
# 步骤3: 测试获取声纹状态
|
||||
test_get_status(USER_ID)
|
||||
|
||||
# 步骤4: 测试上传声纹(需要准备音频文件)
|
||||
audio_file = input("\n请输入WAV音频文件路径 (回车跳过上传测试): ")
|
||||
if audio_file.strip():
|
||||
test_upload_voiceprint(USER_ID, audio_file.strip())
|
||||
|
||||
# 上传后再次查看状态
|
||||
print("\n=== 上传后再次查看状态 ===")
|
||||
test_get_status(USER_ID)
|
||||
|
||||
# 步骤5: 测试删除声纹
|
||||
confirm = input("\n是否测试删除声纹? (yes/no): ")
|
||||
if confirm.lower() == 'yes':
|
||||
test_delete_voiceprint(USER_ID)
|
||||
|
||||
# 删除后再次查看状态
|
||||
print("\n=== 删除后再次查看状态 ===")
|
||||
test_get_status(USER_ID)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试worker线程是否正常工作
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
|
||||
# 创建服务实例
|
||||
service = AsyncMeetingService()
|
||||
|
||||
# 直接调用处理任务方法测试
|
||||
print("测试直接调用_process_tasks方法...")
|
||||
|
||||
# 设置worker_running为True
|
||||
service.worker_running = True
|
||||
|
||||
# 创建线程并启动
|
||||
thread = threading.Thread(target=service._process_tasks)
|
||||
thread.daemon = False # 不设置为daemon,确保能看到输出
|
||||
thread.start()
|
||||
|
||||
print(f"线程是否活动: {thread.is_alive()}")
|
||||
print("等待5秒...")
|
||||
|
||||
# 等待一段时间
|
||||
time.sleep(5)
|
||||
|
||||
# 停止worker
|
||||
service.worker_running = False
|
||||
thread.join(timeout=10)
|
||||
|
||||
print("测试完成")
|
||||
1
frontend
1
frontend
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 6cbe02fbf637e8cd3004c7947be5706a8cb814d7
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Stage 1: Build
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:18-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production with Nginx
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:alpine
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# imetting_frontend
|
||||
|
||||
”爱会议“前端代码库
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iMeeting - 智能会议助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 10240;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
|
||||
# 增加上传文件大小限制
|
||||
client_max_body_size 200M;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端API代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 增加API超时时间
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# 上传文件代理
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 静态资源缓存配置
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.3",
|
||||
"axios": "^1.6.2",
|
||||
"canvg": "^4.0.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"markmap-common": "^0.18.9",
|
||||
"markmap-lib": "^0.18.12",
|
||||
"markmap-view": "^0.18.12",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#667eea"/>
|
||||
<stop offset="100%" stop-color="#764ba2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 背景圆 -->
|
||||
<circle cx="64" cy="64" r="60" fill="url(#bg)"/>
|
||||
|
||||
<!-- 三个人物圆圈 - 极简设计 -->
|
||||
<circle cx="42" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
|
||||
<circle cx="64" cy="48" r="13" fill="#ffffff"/>
|
||||
<circle cx="86" cy="54" r="11" fill="#ffffff" opacity="0.95"/>
|
||||
|
||||
<!-- AI对话气泡 -->
|
||||
<g>
|
||||
<!-- 气泡主体 -->
|
||||
<rect x="36" y="74" width="56" height="26" rx="13" fill="#ffffff"/>
|
||||
<!-- 气泡尾巴 -->
|
||||
<path d="M 58 100 L 52 106 L 54 100 Z" fill="#ffffff"/>
|
||||
<!-- AI文字 -->
|
||||
<text x="52" y="91" fill="#667eea" font-size="16" font-weight="bold" font-family="system-ui, -apple-system, sans-serif">AI</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 951 B |
|
|
@ -0,0 +1,150 @@
|
|||
/* Global Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Input Styles */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Text Styles */
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-base { font-size: 1rem; }
|
||||
.text-lg { font-size: 1.125rem; }
|
||||
.text-xl { font-size: 1.25rem; }
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
.text-gray-500 { color: #64748b; }
|
||||
.text-gray-600 { color: #475569; }
|
||||
.text-gray-700 { color: #334155; }
|
||||
.text-gray-900 { color: #0f172a; }
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import apiClient from './utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from './config/api';
|
||||
import HomePage from './pages/HomePage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import MeetingDetails from './pages/MeetingDetails';
|
||||
import MeetingPreview from './pages/MeetingPreview';
|
||||
import CreateMeeting from './pages/CreateMeeting';
|
||||
import EditMeeting from './pages/EditMeeting';
|
||||
import AdminManagement from './pages/AdminManagement';
|
||||
import PromptManagementPage from './pages/PromptManagementPage';
|
||||
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
||||
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
||||
import ClientDownloadPage from './pages/ClientDownloadPage';
|
||||
import AccountSettings from './pages/AccountSettings';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load user from localStorage on app start
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem('iMeetingUser');
|
||||
console.log('Saved user from localStorage:', savedUser);
|
||||
if (savedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(savedUser);
|
||||
console.log('Parsed user:', parsedUser);
|
||||
setUser(parsedUser);
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved user:', error);
|
||||
localStorage.removeItem('iMeetingUser');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('iMeetingUser', JSON.stringify(userData));
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// 调用后端登出API撤销token
|
||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT));
|
||||
} catch (error) {
|
||||
console.error('Logout API error:', error);
|
||||
// 即使API调用失败也继续登出流程
|
||||
} finally {
|
||||
// 清除本地状态和存储
|
||||
setUser(null);
|
||||
localStorage.removeItem('iMeetingUser');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
user ? <Navigate to="/dashboard" /> : <HomePage onLogin={handleLogin} />
|
||||
} />
|
||||
<Route path="/dashboard" element={
|
||||
user ? (
|
||||
user.role_id === 1
|
||||
? <AdminDashboard user={user} onLogout={handleLogout} />
|
||||
: <Dashboard user={user} onLogout={handleLogout} />
|
||||
) : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/meetings/:meeting_id" element={
|
||||
user ? <MeetingDetails user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/meetings/create" element={
|
||||
user ? <CreateMeeting user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/meetings/edit/:meeting_id" element={
|
||||
user ? <EditMeeting user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/admin/management" element={
|
||||
user && user.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" />
|
||||
} />
|
||||
<Route path="/prompt-management" element={
|
||||
user ? <PromptManagementPage user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/knowledge-base" element={
|
||||
user ? <KnowledgeBasePage user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/knowledge-base/edit/:kb_id" element={
|
||||
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/account-settings" element={
|
||||
user ? <AccountSettings user={user} onUpdateUser={handleLogin} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/downloads" element={<ClientDownloadPage />} />
|
||||
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,79 @@
|
|||
/* 面包屑导航容器 */
|
||||
.breadcrumb-container {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.breadcrumb-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0.875rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 面包屑项 */
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 首页链接 */
|
||||
.breadcrumb-home {
|
||||
color: #667eea;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-home:hover {
|
||||
background: #eff6ff;
|
||||
color: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 分隔符 */
|
||||
.breadcrumb-separator {
|
||||
color: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 当前页 */
|
||||
.breadcrumb-current {
|
||||
color: #1e293b;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.breadcrumb-current svg {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb-content {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { Home, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './Breadcrumb.css';
|
||||
|
||||
/**
|
||||
* 面包屑导航组件
|
||||
* @param {string} currentPage - 当前页面名称
|
||||
* @param {string} icon - 当前页面图标(可选,lucide-react组件)
|
||||
*/
|
||||
const Breadcrumb = ({ currentPage, icon: Icon }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleHomeClick = () => {
|
||||
navigate('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-container">
|
||||
<div className="breadcrumb-content">
|
||||
<button className="breadcrumb-item breadcrumb-home" onClick={handleHomeClick}>
|
||||
<Home size={16} />
|
||||
<span>首页</span>
|
||||
</button>
|
||||
<ChevronRight size={16} className="breadcrumb-separator" />
|
||||
<div className="breadcrumb-item breadcrumb-current">
|
||||
{Icon && <Icon size={16} />}
|
||||
<span>{currentPage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breadcrumb;
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/* 客户端下载组件样式 */
|
||||
.client-downloads-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.downloads-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.platform-group {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.group-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.clients-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.client-download-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-download-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-info h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.875rem;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.system-req {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
flex-shrink: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.loading-message,
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.clients-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.client-download-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Smartphone, Monitor, Apple, ChevronRight, Cpu } from 'lucide-react';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||
import './ClientDownloads.css';
|
||||
|
||||
const ClientDownloads = () => {
|
||||
const [clients, setClients] = useState({
|
||||
mobile: [],
|
||||
desktop: [],
|
||||
terminal: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLatestClients();
|
||||
}, []);
|
||||
|
||||
const fetchLatestClients = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.LATEST));
|
||||
console.log('Latest clients response:', response);
|
||||
setClients(response.data || { mobile: [], desktop: [], terminal: [] });
|
||||
} catch (error) {
|
||||
console.error('获取客户端下载失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platformCode) => {
|
||||
const code = (platformCode || '').toUpperCase();
|
||||
|
||||
// 根据 platform_code 判断图标
|
||||
if (code.includes('IOS') || code.includes('MAC')) {
|
||||
return <Apple size={32} />;
|
||||
} else if (code.includes('ANDROID')) {
|
||||
return <Smartphone size={32} />;
|
||||
} else if (code.includes('TERM') || code.includes('MCU')) {
|
||||
return <Cpu size={32} />;
|
||||
} else {
|
||||
return <Monitor size={32} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformLabel = (client) => {
|
||||
// 优先使用 dict_data 的中文标签
|
||||
return client.label_cn || client.platform_code || '未知平台';
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="client-downloads-section">
|
||||
<div className="section-header">
|
||||
<h2>下载客户端</h2>
|
||||
</div>
|
||||
<div className="loading-message">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="client-downloads-section">
|
||||
<div className="section-header">
|
||||
<h2>下载客户端</h2>
|
||||
<p>选择适合您设备的版本</p>
|
||||
</div>
|
||||
|
||||
<div className="downloads-container">
|
||||
{/* 移动端 */}
|
||||
{clients.mobile && clients.mobile.length > 0 && (
|
||||
<div className="platform-group">
|
||||
<div className="group-header">
|
||||
<Smartphone size={24} />
|
||||
<h3>移动端</h3>
|
||||
</div>
|
||||
<div className="clients-list">
|
||||
{clients.mobile.map(client => (
|
||||
<a
|
||||
key={client.id}
|
||||
href={client.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="client-download-card"
|
||||
>
|
||||
<div className="card-icon">
|
||||
{getPlatformIcon(client.platform_code)}
|
||||
</div>
|
||||
<div className="card-info">
|
||||
<h4>{getPlatformLabel(client)}</h4>
|
||||
<div className="version-info">
|
||||
<span className="version">v{client.version}</span>
|
||||
{client.file_size && (
|
||||
<span className="file-size">{formatFileSize(client.file_size)}</span>
|
||||
)}
|
||||
</div>
|
||||
{client.min_system_version && (
|
||||
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="download-icon">
|
||||
<ChevronRight size={20} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 桌面端 */}
|
||||
{clients.desktop && clients.desktop.length > 0 && (
|
||||
<div className="platform-group">
|
||||
<div className="group-header">
|
||||
<Monitor size={24} />
|
||||
<h3>桌面端</h3>
|
||||
</div>
|
||||
<div className="clients-list">
|
||||
{clients.desktop.map(client => (
|
||||
<a
|
||||
key={client.id}
|
||||
href={client.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="client-download-card"
|
||||
>
|
||||
<div className="card-icon">
|
||||
{getPlatformIcon(client.platform_code)}
|
||||
</div>
|
||||
<div className="card-info">
|
||||
<h4>{getPlatformLabel(client)}</h4>
|
||||
<div className="version-info">
|
||||
<span className="version">v{client.version}</span>
|
||||
{client.file_size && (
|
||||
<span className="file-size">{formatFileSize(client.file_size)}</span>
|
||||
)}
|
||||
</div>
|
||||
{client.min_system_version && (
|
||||
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="download-icon">
|
||||
<Download size={20} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 专用终端 */}
|
||||
{clients.terminal && clients.terminal.length > 0 && (
|
||||
<div className="platform-group">
|
||||
<div className="group-header">
|
||||
<Cpu size={24} />
|
||||
<h3>专用终端</h3>
|
||||
</div>
|
||||
<div className="clients-list">
|
||||
{clients.terminal.map(client => (
|
||||
<a
|
||||
key={client.id}
|
||||
href={client.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="client-download-card"
|
||||
>
|
||||
<div className="card-icon">
|
||||
{getPlatformIcon(client.platform_code)}
|
||||
</div>
|
||||
<div className="card-info">
|
||||
<h4>{getPlatformLabel(client)}</h4>
|
||||
<div className="version-info">
|
||||
<span className="version">v{client.version}</span>
|
||||
{client.file_size && (
|
||||
<span className="file-size">{formatFileSize(client.file_size)}</span>
|
||||
)}
|
||||
</div>
|
||||
{client.min_system_version && (
|
||||
<p className="system-req">需要 {client.min_system_version} 或更高版本</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="download-icon">
|
||||
<Download size={20} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!clients.mobile?.length && !clients.desktop?.length && !clients.terminal?.length && (
|
||||
<div className="empty-message">暂无可用的客户端下载</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDownloads;
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
.confirm-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-dialog-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-dialog-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon.warning {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon.danger {
|
||||
background: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon.info {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.confirm-dialog-body {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.confirm-dialog-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.confirm-dialog-message {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #595959;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.cancel {
|
||||
background: white;
|
||||
color: #595959;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.cancel:hover {
|
||||
color: #262626;
|
||||
border-color: #40a9ff;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.warning {
|
||||
background: #fa8c16;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.warning:hover {
|
||||
background: #ff9c2e;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(250, 140, 22, 0.4);
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.danger {
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.danger:hover {
|
||||
background: #ff7875;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.4);
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.info {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn.confirm.info:hover {
|
||||
background: #40a9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.confirm-dialog-content {
|
||||
width: 95%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.confirm-dialog-icon svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.confirm-dialog-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.confirm-dialog-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.confirm-dialog-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import './ConfirmDialog.css';
|
||||
|
||||
const ConfirmDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = '确认操作',
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'warning' // 'warning', 'danger', 'info'
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onClose}>
|
||||
<div className="confirm-dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`confirm-dialog-icon ${type}`}>
|
||||
<AlertTriangle size={48} />
|
||||
</div>
|
||||
|
||||
<div className="confirm-dialog-body">
|
||||
<h3 className="confirm-dialog-title">{title}</h3>
|
||||
<p className="confirm-dialog-message">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="confirm-dialog-btn cancel"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`confirm-dialog-btn confirm ${type}`}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/* Content Viewer Component */
|
||||
.content-viewer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-tab {
|
||||
font-size: 1rem;
|
||||
color: #475569;
|
||||
padding: 16px 4px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-tab .ant-tabs-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-ink-bar {
|
||||
background: #667eea;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.content-viewer .ant-tabs-content-holder {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Tab Header with Actions */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Content Markdown Area */
|
||||
.content-markdown {
|
||||
line-height: 1.8;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.content-markdown h1 {
|
||||
color: #1e293b;
|
||||
font-size: 1.75rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.content-markdown h2 {
|
||||
color: #1e293b;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-markdown h3 {
|
||||
color: #1e293b;
|
||||
font-size: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.content-markdown p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-markdown ul,
|
||||
.content-markdown ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-markdown li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.content-markdown code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.content-markdown pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-markdown pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.content-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content-markdown th,
|
||||
.content-markdown td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content-markdown th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-markdown blockquote {
|
||||
border-left: 4px solid #667eea;
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { FileText, Brain } from 'lucide-react';
|
||||
import MindMap from './MindMap';
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import './ContentViewer.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
/**
|
||||
* ContentViewer - 纯展示组件,用于显示Markdown内容和脑图
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 组件只负责纯展示,不处理数据获取
|
||||
* 2. 父组件负责数据准备和导出功能
|
||||
* 3. 通过props传入已准备好的content
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.content - Markdown格式的内容(必须由父组件准备好)
|
||||
* @param {string} props.title - 标题(用于脑图显示)
|
||||
* @param {string} props.emptyMessage - 内容为空时的提示消息
|
||||
* @param {React.ReactNode} props.summaryActions - 总结tab的额外操作按钮(如导出)
|
||||
* @param {React.ReactNode} props.mindmapActions - 脑图tab的额外操作按钮(如导出)
|
||||
*/
|
||||
const ContentViewer = ({
|
||||
content,
|
||||
title,
|
||||
emptyMessage = '暂无内容',
|
||||
summaryActions,
|
||||
mindmapActions
|
||||
}) => {
|
||||
return (
|
||||
<div className="content-viewer">
|
||||
<Tabs defaultActiveKey="content">
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<FileText size={16} /> 摘要
|
||||
</span>
|
||||
}
|
||||
key="content"
|
||||
>
|
||||
<div className="tab-header">
|
||||
<h2><FileText size={20} /> AI总结</h2>
|
||||
{summaryActions && <div className="tab-actions">{summaryActions}</div>}
|
||||
</div>
|
||||
<div className="content-markdown">
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
className=""
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<Brain size={16} /> 脑图
|
||||
</span>
|
||||
}
|
||||
key="mindmap"
|
||||
>
|
||||
<div className="tab-header">
|
||||
<h2><Brain size={18} /> 思维导图</h2>
|
||||
{mindmapActions && <div className="tab-actions">{mindmapActions}</div>}
|
||||
</div>
|
||||
{content ? (
|
||||
<MindMap
|
||||
content={content}
|
||||
title={title}
|
||||
initialScale={1.8}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-content">等待内容生成后查看脑图</div>
|
||||
)}
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentViewer;
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
.datetime-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datetime-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.datetime-display:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.datetime-display:focus-within,
|
||||
.datetime-display:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.display-text {
|
||||
flex: 1;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.display-text.placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.datetime-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.datetime-picker-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
z-index: 20;
|
||||
margin-top: 4px;
|
||||
padding: 20px;
|
||||
min-width: 320px;
|
||||
max-height: 500px;
|
||||
height: auto;
|
||||
min-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picker-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.picker-section:last-of-type {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.picker-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-date-options,
|
||||
.quick-time-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quick-time-options {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.quick-option {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-option:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.quick-option.selected {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-date-input,
|
||||
.custom-time-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-time-input {
|
||||
background: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.date-input,
|
||||
.time-input {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
transition: all 0.2s ease;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-input:focus,
|
||||
.time-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.time-input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.picker-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-btn.cancel {
|
||||
background: #f8fafc;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.action-btn.cancel:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.action-btn.confirm {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.confirm:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.datetime-picker-panel {
|
||||
min-width: 280px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.quick-date-options {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.quick-time-options {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.picker-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 改进输入框在Safari中的显示 */
|
||||
.date-input::-webkit-calendar-picker-indicator,
|
||||
.time-input::-webkit-calendar-picker-indicator {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input::-webkit-calendar-picker-indicator:hover,
|
||||
.time-input::-webkit-calendar-picker-indicator:hover {
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Clock } from 'lucide-react';
|
||||
import './DateTimePicker.css';
|
||||
|
||||
const DateTimePicker = ({ value, onChange, placeholder = "选择会议时间" }) => {
|
||||
const [date, setDate] = useState('');
|
||||
const [time, setTime] = useState('');
|
||||
const [showQuickSelect, setShowQuickSelect] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// 组件卸载时清理状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setShowQuickSelect(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 初始化时间值
|
||||
useEffect(() => {
|
||||
if (value && !isInitialized) {
|
||||
const dateObj = new Date(value);
|
||||
if (!isNaN(dateObj.getTime())) {
|
||||
// 转换为本地时间字符串
|
||||
const timeZoneOffset = dateObj.getTimezoneOffset() * 60000;
|
||||
const localDate = new Date(dateObj.getTime() - timeZoneOffset);
|
||||
const isoString = localDate.toISOString();
|
||||
|
||||
setDate(isoString.split('T')[0]);
|
||||
setTime(isoString.split('T')[1].slice(0, 5));
|
||||
}
|
||||
setIsInitialized(true);
|
||||
} else if (!value && !isInitialized) {
|
||||
setDate('');
|
||||
setTime('');
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [value, isInitialized]);
|
||||
|
||||
// 当日期或时间改变时,更新父组件的值
|
||||
useEffect(() => {
|
||||
// 只在初始化完成后才触发onChange
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (date && time) {
|
||||
const dateTimeString = `${date}T${time}`;
|
||||
onChange?.(dateTimeString);
|
||||
} else if (!date && !time) {
|
||||
onChange?.('');
|
||||
}
|
||||
}, [date, time, isInitialized]); // 移除onChange依赖
|
||||
|
||||
// 快速选择时间的选项
|
||||
const timeOptions = [
|
||||
{ label: '09:00', value: '09:00' },
|
||||
{ label: '10:00', value: '10:00' },
|
||||
{ label: '11:00', value: '11:00' },
|
||||
{ label: '14:00', value: '14:00' },
|
||||
{ label: '15:00', value: '15:00' },
|
||||
{ label: '16:00', value: '16:00' },
|
||||
{ label: '17:00', value: '17:00' },
|
||||
];
|
||||
|
||||
// 快速选择日期的选项
|
||||
const getQuickDateOptions = () => {
|
||||
const today = new Date();
|
||||
const options = [];
|
||||
|
||||
// 今天
|
||||
options.push({
|
||||
label: '今天',
|
||||
value: today.toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
// 明天
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
options.push({
|
||||
label: '明天',
|
||||
value: tomorrow.toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
// 后天
|
||||
const dayAfterTomorrow = new Date(today);
|
||||
dayAfterTomorrow.setDate(today.getDate() + 2);
|
||||
options.push({
|
||||
label: '后天',
|
||||
value: dayAfterTomorrow.toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const quickDateOptions = getQuickDateOptions();
|
||||
|
||||
const formatDisplayText = () => {
|
||||
if (!date && !time) return placeholder;
|
||||
|
||||
if (date && time) {
|
||||
const dateObj = new Date(`${date}T${time}`);
|
||||
return dateObj.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
if (date) {
|
||||
const dateObj = new Date(date);
|
||||
return dateObj.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
const clearDateTime = () => {
|
||||
setDate('');
|
||||
setTime('');
|
||||
// 重置初始化状态,允许后续值的设定
|
||||
setIsInitialized(false);
|
||||
onChange?.('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="datetime-picker">
|
||||
<div className="datetime-display" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowQuickSelect(!showQuickSelect);
|
||||
}}>
|
||||
<Calendar size={18} />
|
||||
<span className={`display-text ${(!date && !time) ? 'placeholder' : ''}`}>
|
||||
{formatDisplayText()}
|
||||
</span>
|
||||
{(date || time) && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-btn"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearDateTime();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuickSelect && (
|
||||
<div className="datetime-picker-panel">
|
||||
<div className="picker-section">
|
||||
<h4>选择日期</h4>
|
||||
<div className="quick-date-options">
|
||||
{quickDateOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`quick-option ${date === option.value ? 'selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDate(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="custom-date-input">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDate(e.target.value);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="date-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picker-section">
|
||||
<h4>选择时间</h4>
|
||||
<div className="quick-time-options">
|
||||
{timeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`quick-option ${time === option.value ? 'selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTime(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="custom-time-input">
|
||||
<Clock size={16} />
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTime(e.target.value);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="time-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picker-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn cancel"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowQuickSelect(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn confirm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowQuickSelect(false);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuickSelect && (
|
||||
<div
|
||||
className="datetime-picker-overlay"
|
||||
onClick={() => setShowQuickSelect(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimePicker;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue