初始化
This commit is contained in:
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
|
||||
COPY app.py config.py models.py logger.py init_db.py file_service.py ./
|
||||
COPY routes/ ./routes/
|
||||
COPY middleware/ ./middleware/
|
||||
RUN mkdir -p /data/mokee/mokeetext/logs && mkdir -p static
|
||||
EXPOSE 4006
|
||||
CMD sh -c "python init_db.py && uvicorn app:app --host 0.0.0.0 --port 4006"
|
||||
62
backend/app.py
Normal file
62
backend/app.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""MoKeeText — FastAPI 入口"""
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from logger import logger
|
||||
from middleware.user_context import UserContextMiddleware
|
||||
from routes.notes import router as notes_router
|
||||
from routes.websites import router as websites_router
|
||||
from routes.import_api import router as import_router
|
||||
from routes.share import router as share_router
|
||||
from routes.upload import router as upload_router
|
||||
|
||||
app = FastAPI(title="MoKeeText", version="2.1.0")
|
||||
|
||||
# CORS — 允许网关域名、前端域名、后端域名跨域
|
||||
# 注意:allow_credentials=True 时不能使用 allow_origins=["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://mokeetext.zgitm.com",
|
||||
"https://mokeetext.server.zgitm.com", # 分享页 + 图片直连
|
||||
"https://gw.server.zgitm.com",
|
||||
"http://localhost:5173",
|
||||
],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
# 用户上下文中间件 — 在 CORS 之后注册,读取网关转发的 X-User-* 请求头
|
||||
app.add_middleware(UserContextMiddleware)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("MoKeeText 服务启动")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
logger.info("MoKeeText 服务关闭")
|
||||
|
||||
# 请求日志中间件
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
logger.info(f"{request.method} {request.url.path} → {response.status_code}")
|
||||
return response
|
||||
|
||||
# 静态文件
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# API 路由
|
||||
app.include_router(notes_router)
|
||||
app.include_router(websites_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(share_router)
|
||||
app.include_router(upload_router)
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
return FileResponse("static/favicon.ico") if __import__('os').path.exists("static/favicon.ico") else Response(status_code=204)
|
||||
20
backend/config.py
Normal file
20
backend/config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""数据库与应用配置"""
|
||||
import os
|
||||
|
||||
# MySQL 数据库配置
|
||||
DB_CONFIG = {
|
||||
"user": os.getenv("MYSQL_USER", "mokeetext"),
|
||||
"password": os.getenv("MYSQL_PASSWORD", "mokee."),
|
||||
"host": os.getenv("MYSQL_HOST", "10.20.1.122"),
|
||||
"port": int(os.getenv("MYSQL_PORT", "3306")),
|
||||
"database": os.getenv("MYSQL_DATABASE", "mokeetext"),
|
||||
}
|
||||
|
||||
|
||||
def get_db_url():
|
||||
"""构建数据库连接 URL"""
|
||||
return (
|
||||
f"mysql+pymysql://{DB_CONFIG['user']}:{DB_CONFIG['password']}"
|
||||
f"@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}"
|
||||
f"?charset=utf8mb4"
|
||||
)
|
||||
130
backend/file_service.py
Normal file
130
backend/file_service.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""文件对象存储服务客户端 — https://file.wg.zgitm.com
|
||||
|
||||
认证方式:一次性 Token,每次操作前获取
|
||||
系统编码:mokeetext-edit
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://file.wg.zgitm.com"
|
||||
SYSTEM_CODE = "mokeetext-edit"
|
||||
FOLDER_PATH = "/mokeetext-images/" # 图片统一存放目录
|
||||
|
||||
|
||||
def get_token(system_code: str = SYSTEM_CODE) -> str:
|
||||
"""获取一次性 Token"""
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/token",
|
||||
json={"systemCode": system_code},
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"获取文件服务 Token 失败: {data.get('message')}")
|
||||
return data["data"]["token"]
|
||||
|
||||
|
||||
def upload_file(
|
||||
file_data: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
upload_user: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
上传文件到文件服务
|
||||
|
||||
返回: {"fileId": "...", "fileName": "...", "fileSize": 123}
|
||||
"""
|
||||
token = get_token()
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/upload",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
files={"file": (filename, file_data, content_type)},
|
||||
data={
|
||||
"folderPath": FOLDER_PATH,
|
||||
"uploadUser": upload_user,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"上传文件失败: {data.get('message')}")
|
||||
return data["data"]
|
||||
|
||||
|
||||
def download_file(file_id: str) -> tuple[bytes, str, str]:
|
||||
"""
|
||||
从文件服务下载文件
|
||||
|
||||
返回: (binary_data, content_type, filename)
|
||||
"""
|
||||
token = get_token()
|
||||
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/download/{file_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(f"文件不存在: {file_id}")
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
err = resp.json()
|
||||
raise RuntimeError(f"下载文件失败: {err.get('message', resp.text)}")
|
||||
except ValueError:
|
||||
raise RuntimeError(f"下载文件失败: HTTP {resp.status_code}")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||
# 从 Content-Disposition 提取文件名
|
||||
filename = file_id
|
||||
disposition = resp.headers.get("Content-Disposition", "")
|
||||
if "filename*=" in disposition:
|
||||
# filename*=UTF-8''原始文件名
|
||||
parts = disposition.split("filename*=")
|
||||
if len(parts) > 1:
|
||||
fname = parts[1].strip()
|
||||
if "''" in fname:
|
||||
filename = fname.split("''", 1)[1]
|
||||
elif 'filename="' in disposition:
|
||||
filename = disposition.split('filename="')[1].rstrip('"')
|
||||
|
||||
return resp.content, content_type, filename
|
||||
|
||||
|
||||
def delete_file(file_id: str) -> bool:
|
||||
"""删除文件服务中的文件"""
|
||||
token = get_token()
|
||||
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/delete/{file_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") == 404:
|
||||
return False # 文件已不存在,也算删除成功
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"删除文件失败: {data.get('message')}")
|
||||
return True
|
||||
|
||||
|
||||
def ensure_folder() -> bool:
|
||||
"""确保图片存放目录存在"""
|
||||
token = get_token()
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/folder/create",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"folderPath": FOLDER_PATH,
|
||||
"createdBy": SYSTEM_CODE,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("code") == 400 and "已存在" in data.get("message", ""):
|
||||
return True # 文件夹已存在,没问题
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"创建文件夹失败: {data.get('message')}")
|
||||
return True
|
||||
94
backend/gateway-init.sql
Normal file
94
backend/gateway-init.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- =============================================
|
||||
-- mokee编辑器 菜单 & 角色初始化 SQL
|
||||
-- 系统编码: mokeetext-edit
|
||||
-- 执行位置: 网关平台数据库
|
||||
-- =============================================
|
||||
|
||||
-- 0. 获取系统 ID
|
||||
SET @system_id = (SELECT id FROM sys_system WHERE system_code = 'mokeetext-edit' AND is_deleted = 0 LIMIT 1);
|
||||
|
||||
-- 1. 创建目录
|
||||
SET @menu_sys = REPLACE(UUID(),'-','');
|
||||
|
||||
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
|
||||
(@menu_sys, @system_id, '0', '内容管理', '/', 'Layout', 'Document', 1, 1, 1, NULL);
|
||||
|
||||
-- 2. 创建页面菜单(对应实际路由: /notes, /websites, /shares)
|
||||
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '笔记', '/notes', 'notes/index', 'Edit', 2, 1, 1, 'notes:list'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '网站管理', '/websites', 'websites/index', 'Monitor', 2, 2, 1, 'websites:list'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '分享管理', '/shares', 'shares/index', 'Link', 2, 3, 1, 'shares:list');
|
||||
|
||||
-- 3. 创建按钮权限
|
||||
INSERT INTO sys_menu (id, system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status, permission) VALUES
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建笔记', NULL, NULL, '', 3, 1, 1, 'notes:add'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑笔记', NULL, NULL, '', 3, 2, 1, 'notes:edit'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除笔记', NULL, NULL, '', 3, 3, 1, 'notes:delete'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建网站', NULL, NULL, '', 3, 1, 1, 'websites:add'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '编辑网站', NULL, NULL, '', 3, 2, 1, 'websites:edit'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除网站', NULL, NULL, '', 3, 3, 1, 'websites:delete'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '创建分享', NULL, NULL, '', 3, 1, 1, 'shares:add'),
|
||||
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '删除分享', NULL, NULL, '', 3, 2, 1, 'shares:delete');
|
||||
|
||||
-- 4. 创建角色
|
||||
SET @role_admin = REPLACE(UUID(),'-','');
|
||||
SET @role_user = REPLACE(UUID(),'-','');
|
||||
|
||||
INSERT INTO sys_role (id, system_id, role_name, role_code, description, status) VALUES
|
||||
(@role_admin, @system_id, '管理员', 'admin', '拥有全部权限', 1),
|
||||
(@role_user, @system_id, '普通用户', 'user', '拥有基本权限', 1);
|
||||
|
||||
-- 5. 管理员获得全部菜单权限
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT REPLACE(UUID(),'-',''), @role_admin, m.id
|
||||
FROM sys_menu m WHERE m.system_id = @system_id;
|
||||
|
||||
-- 6. 普通用户只分配页面菜单(不含按钮权限)
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT REPLACE(UUID(),'-',''), @role_user, m.id
|
||||
FROM sys_menu m WHERE m.system_id = @system_id
|
||||
AND m.menu_type != 3;
|
||||
|
||||
-- 7. 创建管理员账号(密码 admin123 的 BCrypt 密文)
|
||||
INSERT INTO sys_user (id, username, password, real_name, email, phone, status, is_deleted)
|
||||
SELECT REPLACE(UUID(),'-',''), 'admin_mokeetext-edit',
|
||||
'$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi',
|
||||
'mokee编辑器管理员', 'admin@mokeetext-edit.com', NULL, 1, 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin_mokeetext-edit');
|
||||
|
||||
-- 8. 绑定到 gateway 系统(用于登录管理后台)
|
||||
INSERT INTO sys_user_system (id, user_id, system_id)
|
||||
SELECT REPLACE(UUID(),'-',''), u.id, s.id
|
||||
FROM sys_user u, sys_system s
|
||||
WHERE u.username = 'admin_mokeetext-edit'
|
||||
AND s.system_code = 'gateway'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_user_system us WHERE us.user_id = u.id AND us.system_id = s.id);
|
||||
|
||||
-- 9. 分配 gateway-app-admin 角色
|
||||
INSERT INTO sys_user_role (id, user_id, role_id)
|
||||
SELECT REPLACE(UUID(),'-',''), u.id, r.id
|
||||
FROM sys_user u, sys_role r, sys_system s
|
||||
WHERE u.username = 'admin_mokeetext-edit'
|
||||
AND r.role_code = 'gateway-app-admin'
|
||||
AND r.system_id = s.id
|
||||
AND s.system_code = 'gateway'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id);
|
||||
|
||||
-- 10. 绑定到 mokeetext-edit 系统
|
||||
INSERT INTO sys_user_system (id, user_id, system_id)
|
||||
SELECT REPLACE(UUID(),'-',''), u.id, s.id
|
||||
FROM sys_user u, sys_system s
|
||||
WHERE u.username = 'admin_mokeetext-edit'
|
||||
AND s.system_code = 'mokeetext-edit'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_user_system us WHERE us.user_id = u.id AND us.system_id = s.id);
|
||||
|
||||
-- 11. 分配本系统 admin 角色
|
||||
INSERT INTO sys_user_role (id, user_id, role_id)
|
||||
SELECT REPLACE(UUID(),'-',''), u.id, r.id
|
||||
FROM sys_user u, sys_role r, sys_system s
|
||||
WHERE u.username = 'admin_mokeetext-edit'
|
||||
AND r.role_code = 'admin'
|
||||
AND r.system_id = s.id
|
||||
AND s.system_code = 'mokeetext-edit'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id);
|
||||
59
backend/init_db.py
Normal file
59
backend/init_db.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库初始化 — 建表 + 插入默认数据"""
|
||||
import sys
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
from models import engine, Base, SessionLocal, Folder
|
||||
|
||||
|
||||
def migrate():
|
||||
"""数据库迁移 — 处理表结构变更"""
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
# V2.2: uploads 表新增 file_id 列,data 改为可空
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE uploads ADD COLUMN file_id VARCHAR(64) NULL AFTER content_type"))
|
||||
except Exception:
|
||||
pass # 列已存在则跳过
|
||||
try:
|
||||
conn.execute(text("ALTER TABLE uploads MODIFY COLUMN data LONGBLOB NULL"))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
print("[OK] 数据库迁移完成")
|
||||
|
||||
|
||||
def init_database():
|
||||
"""创建所有表,并插入默认文件夹"""
|
||||
# 建表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("[OK] 数据库表创建完成")
|
||||
|
||||
# 执行迁移
|
||||
migrate()
|
||||
|
||||
# 插入默认数据
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 笔记默认文件夹
|
||||
if db.query(Folder).filter_by(type="note").count() == 0:
|
||||
for name in ["工作", "个人", "学习"]:
|
||||
db.add(Folder(name=name, type="note"))
|
||||
db.add(Folder(name="未分类", type="note"))
|
||||
print("[OK] 笔记文件夹已创建: 工作、个人、学习、未分类")
|
||||
|
||||
# 网站默认文件夹
|
||||
if db.query(Folder).filter_by(type="website").count() == 0:
|
||||
for name in ["代码托管", "云服务", "开发工具"]:
|
||||
db.add(Folder(name=name, type="website"))
|
||||
db.add(Folder(name="未分类", type="website"))
|
||||
print("[OK] 网站文件夹已创建: 代码托管、云服务、开发工具、未分类")
|
||||
|
||||
db.commit()
|
||||
print("[OK] 默认数据插入完成")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
62
backend/logger.py
Normal file
62
backend/logger.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""日志配置 — 按天拆分 + 当前日志"""
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from datetime import datetime
|
||||
|
||||
LOG_DIR = os.getenv("LOG_DIR", os.path.join(os.path.dirname(__file__), "logs"))
|
||||
|
||||
|
||||
def setup_logger(name="mokeetext"):
|
||||
"""配置日志:一天一个文件 + current.log 始终为当天日志"""
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 避免重复添加 handler
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
# 格式
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)s %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# 按天轮转:每天午夜切一个新文件,保留 30 天
|
||||
daily_handler = TimedRotatingFileHandler(
|
||||
filename=os.path.join(LOG_DIR, "mokeetext.log"),
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
)
|
||||
daily_handler.suffix = "%Y-%m-%d"
|
||||
daily_handler.setFormatter(formatter)
|
||||
daily_handler.setLevel(logging.INFO)
|
||||
logger.addHandler(daily_handler)
|
||||
|
||||
# 当前日志软链接(始终指向今天的日志)
|
||||
current_log = os.path.join(LOG_DIR, "current.log")
|
||||
|
||||
# 控制台也输出
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 记录日志文件路径
|
||||
logger.info(f"日志目录: {LOG_DIR}")
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_log_path():
|
||||
"""返回今天的日志文件路径"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
return os.path.join(LOG_DIR, f"mokeetext.log{today}")
|
||||
|
||||
|
||||
# 单例
|
||||
logger = setup_logger()
|
||||
1
backend/middleware/__init__.py
Normal file
1
backend/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""中间件包"""
|
||||
94
backend/middleware/user_context.py
Normal file
94
backend/middleware/user_context.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Mokee Gateway 用户上下文中间件
|
||||
|
||||
网关转发请求时注入以下请求头,本中间件读取并存入 request.state.user:
|
||||
X-User-Id, X-User-Name, X-User-Account, X-User-Email,
|
||||
X-User-Post, X-User-Roles, X-System-Id, X-System-Code, X-System-Name
|
||||
|
||||
本地开发时(无网关),使用 DEV_MODE=true 环境变量或 X-Dev-Mock-User 请求头
|
||||
启用默认 admin 用户,无需启动网关即可调试。
|
||||
"""
|
||||
|
||||
import os
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi import Request
|
||||
|
||||
# 网关注入的所有请求头
|
||||
GATEWAY_HEADERS = [
|
||||
"X-User-Id",
|
||||
"X-User-Name",
|
||||
"X-User-Account",
|
||||
"X-User-Email",
|
||||
"X-User-Post",
|
||||
"X-User-Roles",
|
||||
"X-System-Id",
|
||||
"X-System-Code",
|
||||
"X-System-Name",
|
||||
]
|
||||
|
||||
# 本地开发默认用户(模拟网关注入)
|
||||
DEV_DEFAULT_USER = {
|
||||
"X-User-Id": "admin",
|
||||
"X-User-Name": "管理员",
|
||||
"X-User-Account": "admin",
|
||||
"X-User-Email": "admin@zgitm.com",
|
||||
"X-User-Post": "",
|
||||
"X-User-Roles": '[{"roleId":"1","roleName":"管理员","roleCode":"admin"}]',
|
||||
"X-System-Id": "1",
|
||||
"X-System-Code": "mokeetext-edit",
|
||||
"X-System-Name": "mokee编辑器",
|
||||
}
|
||||
|
||||
|
||||
class UserContext:
|
||||
"""用户上下文 — 提供快捷访问方法"""
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: Request) -> dict:
|
||||
"""从 request.state 获取用户信息"""
|
||||
return getattr(request.state, "user", {})
|
||||
|
||||
@staticmethod
|
||||
def get_user_id(request: Request) -> str:
|
||||
"""获取当前用户账号名(用于 created_by 显示),未登录返回 'admin'"""
|
||||
user = UserContext.from_request(request)
|
||||
# 优先用登录账号名(如 admin),其次用用户名,最后用 ID
|
||||
return user.get("X-User-Account") or user.get("X-User-Name") or user.get("X-User-Id", "admin")
|
||||
|
||||
@staticmethod
|
||||
def get_user_name(request: Request) -> str:
|
||||
return UserContext.from_request(request).get("X-User-Name", "")
|
||||
|
||||
@staticmethod
|
||||
def get_user_account(request: Request) -> str:
|
||||
return UserContext.from_request(request).get("X-User-Account", "")
|
||||
|
||||
@staticmethod
|
||||
def get_system_code(request: Request) -> str:
|
||||
return UserContext.from_request(request).get("X-System-Code", "")
|
||||
|
||||
|
||||
class UserContextMiddleware(BaseHTTPMiddleware):
|
||||
"""将网关注入的用户信息存入 request.state.user
|
||||
|
||||
生产环境:读取网关转发的 X-User-* 请求头
|
||||
本地开发:DEV_MODE=true 时使用 DEV_DEFAULT_USER
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
user_info = {}
|
||||
|
||||
# 读取网关注入的请求头
|
||||
for header in GATEWAY_HEADERS:
|
||||
value = request.headers.get(header)
|
||||
if value:
|
||||
user_info[header] = value
|
||||
|
||||
# 本地开发:无网关时使用默认用户
|
||||
if not user_info.get("X-User-Id"):
|
||||
dev_mode = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||
if dev_mode or request.headers.get("X-Dev-Mock-User"):
|
||||
user_info = DEV_DEFAULT_USER.copy()
|
||||
|
||||
request.state.user = user_info
|
||||
response = await call_next(request)
|
||||
return response
|
||||
135
backend/models.py
Normal file
135
backend/models.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""SQLAlchemy 数据模型"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
create_engine, Column, Integer, String, Text, DateTime, LargeBinary,
|
||||
Enum as SAEnum, ForeignKey, Table
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
||||
from config import get_db_url
|
||||
|
||||
engine = create_engine(get_db_url(), echo=False, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话(FastAPI 依赖注入)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ===== 文件夹 =====
|
||||
class Folder(Base):
|
||||
"""文件夹表 — 笔记和网站共用"""
|
||||
__tablename__ = "folders"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
type = Column(SAEnum("note", "website", name="folder_type"), nullable=False, default="note")
|
||||
parent_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_by = Column(String(100), nullable=False, default="admin")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
children = relationship("Folder", backref="parent", remote_side=[id])
|
||||
notes = relationship("Note", back_populates="folder", cascade="all, delete-orphan")
|
||||
websites = relationship("Website", back_populates="folder", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
# ===== 笔记 =====
|
||||
class Note(Base):
|
||||
"""笔记表"""
|
||||
__tablename__ = "notes"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
title = Column(String(500), nullable=False, default="无标题")
|
||||
content = Column(Text, nullable=True) # Tiptap 输出的 HTML
|
||||
folder_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
|
||||
created_by = Column(String(100), nullable=False, default="admin")
|
||||
updated_by = Column(String(100), nullable=False, default="admin")
|
||||
share_token = Column(String(64), nullable=True, unique=True)
|
||||
share_expires_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
folder = relationship("Folder", back_populates="notes")
|
||||
tags = relationship("Tag", secondary="note_tags", back_populates="notes")
|
||||
|
||||
|
||||
# ===== 笔记-标签关联 =====
|
||||
note_tags = Table(
|
||||
"note_tags",
|
||||
Base.metadata,
|
||||
Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
# ===== 标签 =====
|
||||
class Tag(Base):
|
||||
"""标签表"""
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
color = Column(String(20), default="#666666")
|
||||
|
||||
notes = relationship("Note", secondary="note_tags", back_populates="tags")
|
||||
|
||||
|
||||
# ===== 网站 =====
|
||||
class Website(Base):
|
||||
"""网站表"""
|
||||
__tablename__ = "websites"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
folder_id = Column(Integer, ForeignKey("folders.id"), nullable=True)
|
||||
created_by = Column(String(100), nullable=False, default="admin")
|
||||
updated_by = Column(String(100), nullable=False, default="admin")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
folder = relationship("Folder", back_populates="websites")
|
||||
accounts = relationship("Account", back_populates="website", cascade="all, delete-orphan",
|
||||
order_by="Account.sort_order")
|
||||
|
||||
|
||||
# ===== 账号 =====
|
||||
class Account(Base):
|
||||
"""账号表 — 存储网站登录凭据"""
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
website_id = Column(Integer, ForeignKey("websites.id", ondelete="CASCADE"), nullable=False)
|
||||
username = Column(String(500), nullable=False)
|
||||
password = Column(String(500), nullable=False) # 明文存储,后续加密
|
||||
remark = Column(Text, nullable=True)
|
||||
created_by = Column(String(100), nullable=False, default="admin")
|
||||
updated_by = Column(String(100), nullable=False, default="admin")
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
website = relationship("Website", back_populates="accounts")
|
||||
|
||||
|
||||
# ===== 上传文件 =====
|
||||
class Upload(Base):
|
||||
"""文件上传表 — 图片存储(新文件存文件服务,旧文件兼容 DB blob)"""
|
||||
__tablename__ = "uploads"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
filename = Column(String(255), nullable=False)
|
||||
content_type = Column(String(100), nullable=False)
|
||||
data = Column(LargeBinary(length=2**32-1), nullable=True) # 旧图片 LONGBLOB(兼容),新文件为 NULL
|
||||
file_id = Column(String(64), nullable=True) # 文件服务返回的 fileId(新文件)
|
||||
size = Column(Integer, default=0)
|
||||
created_by = Column(String(100), nullable=False, default="admin")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
sqlalchemy>=2.0.35
|
||||
pymysql>=1.1.0
|
||||
cryptography>=43.0.0
|
||||
python-multipart>=0.0.12
|
||||
requests>=2.31.0
|
||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""路由包"""
|
||||
61
backend/routes/import_api.py
Normal file
61
backend/routes/import_api.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""智能导入 API — 文本解析与匹配"""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from models import get_db, Website
|
||||
from middleware.user_context import UserContext
|
||||
from urllib.parse import urlparse
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["智能导入"])
|
||||
|
||||
|
||||
@router.post("/parse")
|
||||
def parse_import_text(data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""解析粘贴的文本,返回结构化结果 + 匹配信息"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "error": "文本为空"}
|
||||
|
||||
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||
if len(lines) < 3:
|
||||
return {"ok": False, "error": "至少需要 3 行:网址+网站名、账号、密码"}
|
||||
|
||||
# 解析首行: 网址 + 空格 + 网站名
|
||||
first_line = lines[0]
|
||||
space_idx = first_line.find(" ")
|
||||
if space_idx > 0:
|
||||
url = first_line[:space_idx].strip()
|
||||
site_name = first_line[space_idx + 1:].strip()
|
||||
else:
|
||||
url = first_line
|
||||
try:
|
||||
site_name = urlparse(url).hostname or url
|
||||
except Exception:
|
||||
site_name = url
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"url": url,
|
||||
"site_name": site_name,
|
||||
"username": lines[1] if len(lines) > 1 else "",
|
||||
"password": lines[2] if len(lines) > 2 else "",
|
||||
"remark": lines[3] if len(lines) > 3 else "",
|
||||
}
|
||||
|
||||
# 匹配已有网站(仅当前用户)
|
||||
existing = db.query(Website).options(joinedload(Website.accounts)).filter(
|
||||
Website.url == url, Website.created_by == user_id
|
||||
).first()
|
||||
if existing:
|
||||
result["matched_site"] = {
|
||||
"id": existing.id,
|
||||
"name": existing.name,
|
||||
"folder_id": existing.folder_id,
|
||||
"account_count": len(existing.accounts),
|
||||
}
|
||||
result["action"] = "append"
|
||||
else:
|
||||
result["matched_site"] = None
|
||||
result["action"] = "create"
|
||||
|
||||
return result
|
||||
159
backend/routes/notes.py
Normal file
159
backend/routes/notes.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""笔记相关路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from models import get_db, Note
|
||||
from middleware.user_context import UserContext
|
||||
|
||||
router = APIRouter(tags=["笔记"])
|
||||
|
||||
|
||||
@router.get("/api/notes")
|
||||
def list_notes(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取笔记列表(仅当前用户)"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
q = db.query(Note).filter(Note.created_by == user_id)
|
||||
if folder_id:
|
||||
q = q.filter(Note.folder_id == folder_id)
|
||||
return q.order_by(Note.updated_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/api/notes")
|
||||
def create_note(data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""创建笔记"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = Note(
|
||||
title=data.get("title", "无标题"),
|
||||
content=data.get("content", ""),
|
||||
folder_id=data.get("folder_id"),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
return {"id": note.id, "title": note.title, "content": note.content,
|
||||
"folder_id": note.folder_id, "created_by": note.created_by, "updated_by": note.updated_by,
|
||||
"created_at": str(note.created_at), "updated_at": str(note.updated_at)}
|
||||
|
||||
|
||||
@router.get("/api/notes/{note_id}")
|
||||
def get_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取单个笔记"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
return {"id": note.id, "title": note.title, "content": note.content,
|
||||
"folder_id": note.folder_id, "created_by": note.created_by, "updated_by": note.updated_by,
|
||||
"created_at": str(note.created_at), "updated_at": str(note.updated_at)}
|
||||
|
||||
|
||||
@router.put("/api/notes/{note_id}")
|
||||
def update_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""更新笔记"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
if "title" in data:
|
||||
note.title = data["title"]
|
||||
if "content" in data:
|
||||
note.content = data["content"]
|
||||
if "folder_id" in data:
|
||||
note.folder_id = data["folder_id"]
|
||||
note.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/api/notes/{note_id}")
|
||||
def delete_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""删除笔记"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 分享 =====
|
||||
import secrets
|
||||
|
||||
@router.post("/api/notes/{note_id}/share")
|
||||
def share_note(note_id: int, data: dict = {}, db: Session = Depends(get_db), request: Request = None):
|
||||
"""生成/刷新分享链接,可设置有效期"""
|
||||
from datetime import datetime, timedelta
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
note.share_token = secrets.token_urlsafe(16)
|
||||
|
||||
# 有效期: 7d / 30d / 1y / forever
|
||||
ttl = data.get("ttl", "forever")
|
||||
if ttl == "7d":
|
||||
note.share_expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
elif ttl == "30d":
|
||||
note.share_expires_at = datetime.utcnow() + timedelta(days=30)
|
||||
elif ttl == "1y":
|
||||
note.share_expires_at = datetime.utcnow() + timedelta(days=365)
|
||||
else:
|
||||
note.share_expires_at = None # 永久
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"share_token": note.share_token,
|
||||
"url": f"/share/{note.share_token}",
|
||||
"expires_at": note.share_expires_at.strftime("%Y-%m-%d %H:%M") if note.share_expires_at else "永久有效",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/notes/{note_id}/share")
|
||||
def unshare_note(note_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""取消分享"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
note.share_token = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 移动 =====
|
||||
@router.put("/api/notes/{note_id}/move")
|
||||
def move_note(note_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""移动笔记到另一个文件夹"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.created_by == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "笔记不存在")
|
||||
note.folder_id = data.get("folder_id")
|
||||
db.commit()
|
||||
return {"ok": True, "folder_id": note.folder_id}
|
||||
|
||||
|
||||
# ===== 分享管理 =====
|
||||
@router.get("/api/shares")
|
||||
def list_shares(db: Session = Depends(get_db), request: Request = None):
|
||||
"""列出当前用户已分享的笔记"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
notes = db.query(Note).filter(
|
||||
Note.share_token.isnot(None),
|
||||
Note.created_by == user_id,
|
||||
).order_by(Note.updated_at.desc()).all()
|
||||
result = []
|
||||
for n in notes:
|
||||
result.append({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"share_token": n.share_token,
|
||||
"share_url": f"/share/{n.share_token}",
|
||||
"share_expires_at": n.share_expires_at.strftime("%Y-%m-%d %H:%M") if n.share_expires_at else "永久有效",
|
||||
"created_by": n.created_by,
|
||||
"updated_at": str(n.updated_at),
|
||||
})
|
||||
return result
|
||||
75
backend/routes/share.py
Normal file
75
backend/routes/share.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""分享笔记公开查看"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from models import get_db, Note
|
||||
|
||||
router = APIRouter(tags=["分享"])
|
||||
|
||||
CSS = """
|
||||
:root{--text:#1F2329;--text2:#646A73;--text3:#8F959E;--border:#E5E6EB;--brand:#3370FF;--bg:#F5F6F8;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);background:var(--bg);min-height:100vh;}
|
||||
.wrap{max-width:1100px;margin:0 auto;padding:32px 16px;}
|
||||
.card{background:#fff;border-radius:12px;padding:36px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow-x:auto;word-wrap:break-word;overflow-wrap:break-word;max-width:100%;}
|
||||
h1{font-size:26px;font-weight:700;padding-bottom:16px;margin-bottom:16px;border-bottom:2px solid var(--border);line-height:1.3;}
|
||||
.meta{font-size:12px;color:var(--text3);margin-bottom:28px;display:flex;gap:20px;}
|
||||
.content{font-size:15px;line-height:1.9;color:var(--text);overflow-x:auto;word-wrap:break-word;overflow-wrap:break-word;}
|
||||
.content h2{font-size:20px;font-weight:600;margin:24px 0 10px;}
|
||||
.content h3{font-size:17px;font-weight:600;margin:18px 0 8px;}
|
||||
.content p{margin:8px 0;}
|
||||
.content ul,.content ol{padding-left:22px;margin:8px 0;}
|
||||
.content li{margin:4px 0;}
|
||||
.content blockquote{border-left:3px solid var(--brand);padding:4px 14px;margin:12px 0;color:var(--text2);}
|
||||
.content pre{background:#F7F8FA;padding:14px 18px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.7;font-family:"SF Mono","Fira Code","Consolas",monospace;}
|
||||
.content code{background:#F2F3F5;padding:2px 6px;border-radius:3px;font-size:13px;font-family:"SF Mono","Fira Code","Consolas",monospace;}
|
||||
.content pre code{background:none;padding:0;}
|
||||
.content table{border-collapse:collapse;width:100%;margin:12px 0;}
|
||||
.content th,.content td{border:1px solid var(--border);padding:8px 14px;text-align:left;}
|
||||
.content th{background:#F7F8FA;font-weight:600;font-size:13px;}
|
||||
.content img{max-width:100%;border-radius:8px;}
|
||||
.content mark{background:#FFF3CD;padding:2px 4px;border-radius:2px;}
|
||||
.footer{text-align:center;margin-top:24px;font-size:11px;color:var(--text3);}
|
||||
.footer a{color:var(--brand);text-decoration:none;}
|
||||
"""
|
||||
|
||||
TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{title} - MoKeeText</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>{title}</h1>
|
||||
<div class="meta">
|
||||
<span>创建于 {created_at}</span>
|
||||
<span>{created_by}</span>
|
||||
</div>
|
||||
<div class="content">{content}</div>
|
||||
</div>
|
||||
<div class="footer">Powered by <a href="/">MoKeeText</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/share/{token}")
|
||||
def view_shared(token: str, db: Session = Depends(get_db)):
|
||||
note = db.query(Note).filter_by(share_token=token).first()
|
||||
if not note:
|
||||
raise HTTPException(404, "链接无效或已取消分享")
|
||||
if note.share_expires_at and note.share_expires_at < datetime.utcnow():
|
||||
raise HTTPException(410, "分享链接已过期")
|
||||
|
||||
html = TEMPLATE.format(
|
||||
title=note.title,
|
||||
created_at=note.created_at.strftime('%Y-%m-%d %H:%M:%S') if note.created_at else '-',
|
||||
created_by=note.created_by or 'admin',
|
||||
content=note.content or '<p style="color:var(--text3);">暂无内容</p>',
|
||||
css=CSS,
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
101
backend/routes/upload.py
Normal file
101
backend/routes/upload.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""文件上传/读取 — 数据库存储(file_service.py 为可选备用方案,保留不删)"""
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from logger import logger
|
||||
from models import get_db, Upload
|
||||
from middleware.user_context import UserContext
|
||||
# 文件服务模块保留备用(不删除),如需切换取消下面注释即可
|
||||
# from file_service import upload_file, download_file, delete_file
|
||||
|
||||
router = APIRouter(tags=["上传"])
|
||||
|
||||
ALLOWED = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"}
|
||||
MAX_SIZE = 20 * 1024 * 1024 # 20MB
|
||||
|
||||
|
||||
@router.post("/api/upload/image")
|
||||
async def upload_image(file: UploadFile = File(...), db: Session = Depends(get_db), request: Request = None):
|
||||
"""上传图片 — 存入数据库 LONGBLOB"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
try:
|
||||
if file.content_type not in ALLOWED:
|
||||
raise HTTPException(400, f"不支持的图片格式: {file.content_type}")
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(400, f"图片不能超过 20MB,当前 {len(data)} 字节")
|
||||
|
||||
filename = file.filename or "img"
|
||||
logger.info(f"上传图片: {filename}, {len(data)} bytes, {file.content_type}, user={user_id}")
|
||||
|
||||
upload = Upload(
|
||||
filename=filename,
|
||||
content_type=file.content_type,
|
||||
data=data,
|
||||
size=len(data),
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(upload)
|
||||
db.commit()
|
||||
db.refresh(upload)
|
||||
|
||||
logger.info(f"图片已存入 DB, id={upload.id}")
|
||||
return {"url": f"/api/file/{upload.id}"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"上传失败: {e}")
|
||||
raise HTTPException(500, f"上传失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/api/file/{file_id}")
|
||||
def get_file(file_id: int, db: Session = Depends(get_db)):
|
||||
"""读取图片 — 公开访问(不走网关,供编辑器 <img> 标签和分享页加载)
|
||||
|
||||
优先从 DB blob 读取;如为文件服务存储则代理下载
|
||||
"""
|
||||
upload = db.query(Upload).filter_by(id=file_id).first()
|
||||
if not upload:
|
||||
raise HTTPException(404, "文件不存在")
|
||||
|
||||
# DB blob(主存储方式)
|
||||
if upload.data:
|
||||
return Response(content=upload.data, media_type=upload.content_type)
|
||||
|
||||
# 文件服务存储(兼容之前上传的文件)
|
||||
if upload.file_id:
|
||||
from file_service import download_file
|
||||
try:
|
||||
data, content_type, _ = download_file(upload.file_id)
|
||||
return Response(content=data, media_type=content_type)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "文件在文件服务中不存在")
|
||||
except Exception as e:
|
||||
logger.error(f"从文件服务下载失败: {e}")
|
||||
raise HTTPException(500, f"下载文件失败: {str(e)}")
|
||||
|
||||
raise HTTPException(404, "文件数据不存在")
|
||||
|
||||
|
||||
@router.delete("/api/file/{file_id}")
|
||||
def delete_file_api(file_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""删除图片 — 从 DB 和文件服务中删除"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
upload = db.query(Upload).filter_by(id=file_id).first()
|
||||
if not upload:
|
||||
raise HTTPException(404, "文件不存在")
|
||||
|
||||
# 如果存在文件服务记录,同步删除
|
||||
if upload.file_id:
|
||||
from file_service import delete_file
|
||||
try:
|
||||
delete_file(upload.file_id)
|
||||
logger.info(f"文件服务删除成功, fileId={upload.file_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"文件服务删除失败: {e}")
|
||||
|
||||
db.delete(upload)
|
||||
db.commit()
|
||||
logger.info(f"图片记录已删除, db_id={file_id}, user={user_id}")
|
||||
return {"ok": True}
|
||||
221
backend/routes/websites.py
Normal file
221
backend/routes/websites.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""网站管理相关路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from models import get_db, Website, Account, Folder
|
||||
from middleware.user_context import UserContext
|
||||
|
||||
router = APIRouter(tags=["网站管理"])
|
||||
|
||||
|
||||
# ===== 网站 CRUD =====
|
||||
|
||||
@router.get("/api/websites")
|
||||
def list_websites(folder_id: int = None, db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取网站列表(仅当前用户)"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
q = db.query(Website).options(joinedload(Website.accounts)).filter(Website.created_by == user_id)
|
||||
if folder_id:
|
||||
q = q.filter(Website.folder_id == folder_id)
|
||||
return q.order_by(Website.name).all()
|
||||
|
||||
|
||||
@router.post("/api/websites")
|
||||
def create_website(data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""创建网站"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
website = Website(
|
||||
name=data.get("name", ""),
|
||||
url=data.get("url", ""),
|
||||
folder_id=data.get("folder_id"),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(website)
|
||||
db.commit()
|
||||
db.refresh(website)
|
||||
return {"id": website.id, "name": website.name, "url": website.url,
|
||||
"folder_id": website.folder_id}
|
||||
|
||||
|
||||
@router.get("/api/websites/{website_id}")
|
||||
def get_website(website_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取网站详情(含账号列表)"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
website = db.query(Website).options(joinedload(Website.accounts)).filter(
|
||||
Website.id == website_id, Website.created_by == user_id
|
||||
).first()
|
||||
if not website:
|
||||
raise HTTPException(404, "网站不存在")
|
||||
return website
|
||||
|
||||
|
||||
@router.put("/api/websites/{website_id}")
|
||||
def update_website(website_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""更新网站信息"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
website = db.query(Website).filter(Website.id == website_id, Website.created_by == user_id).first()
|
||||
if not website:
|
||||
raise HTTPException(404, "网站不存在")
|
||||
for field in ["name", "url", "folder_id"]:
|
||||
if field in data:
|
||||
setattr(website, field, data[field])
|
||||
website.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(website)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/api/websites/{website_id}")
|
||||
def delete_website(website_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""删除网站"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
website = db.query(Website).filter(Website.id == website_id, Website.created_by == user_id).first()
|
||||
if not website:
|
||||
raise HTTPException(404, "网站不存在")
|
||||
db.delete(website)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 账号 CRUD =====
|
||||
|
||||
@router.post("/api/websites/{website_id}/accounts")
|
||||
def add_account(website_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""添加账号"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
account = Account(
|
||||
website_id=website_id,
|
||||
username=data.get("username", ""),
|
||||
password=data.get("password", ""),
|
||||
remark=data.get("remark", ""),
|
||||
sort_order=data.get("sort_order", 0),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return {"id": account.id, "username": account.username, "remark": account.remark, "sort_order": account.sort_order}
|
||||
|
||||
|
||||
@router.get("/api/accounts/{account_id}")
|
||||
def get_account(account_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取单个账号"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
|
||||
if not account:
|
||||
raise HTTPException(404, "账号不存在")
|
||||
return {"id": account.id, "username": account.username,
|
||||
"password": account.password, "remark": account.remark}
|
||||
|
||||
|
||||
@router.put("/api/accounts/{account_id}")
|
||||
def update_account(account_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""更新账号"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
|
||||
if not account:
|
||||
raise HTTPException(404, "账号不存在")
|
||||
for field in ["username", "password", "remark", "sort_order"]:
|
||||
if field in data:
|
||||
setattr(account, field, data[field])
|
||||
account.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/accounts/{account_id}")
|
||||
def delete_account(account_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""删除账号"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
|
||||
if not account:
|
||||
raise HTTPException(404, "账号不存在")
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/api/accounts/{account_id}/move")
|
||||
def move_account(account_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""移动账号顺序: direction = 'up' | 'down'"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
account = db.query(Account).filter(Account.id == account_id, Account.created_by == user_id).first()
|
||||
if not account:
|
||||
raise HTTPException(404, "账号不存在")
|
||||
|
||||
direction = data.get("direction", "down")
|
||||
website_id = account.website_id
|
||||
|
||||
# 同网站下按 sort_order 排序的所有账号
|
||||
siblings = db.query(Account).filter_by(website_id=website_id).order_by(Account.sort_order).all()
|
||||
|
||||
idx = next((i for i, a in enumerate(siblings) if a.id == account_id), None)
|
||||
if idx is None:
|
||||
return {"ok": False}
|
||||
|
||||
if direction == "up" and idx > 0:
|
||||
siblings[idx].sort_order, siblings[idx-1].sort_order = siblings[idx-1].sort_order, siblings[idx].sort_order
|
||||
elif direction == "down" and idx < len(siblings) - 1:
|
||||
siblings[idx].sort_order, siblings[idx+1].sort_order = siblings[idx+1].sort_order, siblings[idx].sort_order
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 文件夹 API =====
|
||||
|
||||
@router.get("/api/folders")
|
||||
def list_folders(type: str = "website", db: Session = Depends(get_db), request: Request = None):
|
||||
"""获取文件夹列表(仅当前用户)"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
return db.query(Folder).filter(
|
||||
Folder.type == type,
|
||||
Folder.created_by == user_id,
|
||||
).order_by(Folder.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/api/folders")
|
||||
def create_folder(data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""创建文件夹"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
folder = Folder(
|
||||
name=data.get("name", "新文件夹"),
|
||||
type=data.get("type", "website"),
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
db.commit()
|
||||
db.refresh(folder)
|
||||
return {"id": folder.id, "name": folder.name, "type": folder.type}
|
||||
|
||||
|
||||
@router.put("/api/folders/{folder_id}")
|
||||
def update_folder(folder_id: int, data: dict, db: Session = Depends(get_db), request: Request = None):
|
||||
"""重命名文件夹"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
folder = db.query(Folder).filter(Folder.id == folder_id, Folder.created_by == user_id).first()
|
||||
if not folder:
|
||||
raise HTTPException(404, "文件夹不存在")
|
||||
if "name" in data:
|
||||
folder.name = data["name"]
|
||||
db.commit()
|
||||
return {"ok": True, "id": folder.id, "name": folder.name}
|
||||
|
||||
|
||||
@router.delete("/api/folders/{folder_id}")
|
||||
def delete_folder(folder_id: int, db: Session = Depends(get_db), request: Request = None):
|
||||
"""删除文件夹(文件夹下的内容变为未分类)"""
|
||||
user_id = UserContext.get_user_id(request)
|
||||
folder = db.query(Folder).filter(Folder.id == folder_id, Folder.created_by == user_id).first()
|
||||
if not folder:
|
||||
raise HTTPException(404, "文件夹不存在")
|
||||
# 将该文件夹下的网站/笔记移到未分类(仅限当前用户)
|
||||
from models import Website, Note
|
||||
if folder.type == "website":
|
||||
db.query(Website).filter_by(folder_id=folder_id, created_by=user_id).update({"folder_id": None})
|
||||
else:
|
||||
db.query(Note).filter_by(folder_id=folder_id, created_by=user_id).update({"folder_id": None})
|
||||
db.delete(folder)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user