初始化

This commit is contained in:
zg
2026-07-15 16:08:17 +08:00
parent f0d70aded4
commit 4e0057ece5
89 changed files with 13512 additions and 0 deletions

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
__pycache__
*.pyc

10
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1 @@
"""中间件包"""

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

View File

@@ -0,0 +1 @@
"""路由包"""

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