41 KiB
MoKeeText 接入 Mokee Gateway 统一登录网关 — 实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将 MoKeeText 接入 Mokee Gateway 统一登录网关,实现用户登录认证、用户数据隔离、按钮权限控制。
Architecture: 前端所有 API 请求从直连后端改为经过网关(gw.server.zgitm.com),网关验证 Token 后注入 X-User-* 请求头转发到后端。后端通过 FastAPI 中间件读取请求头,存入 request.state.user,所有业务查询据此过滤用户数据。前端用 UserChip Widget 替代硬编码的 Admin 头像,新增路由守卫和 v-permission 指令。
Tech Stack: FastAPI (Python), Vue 3 + Vite, Axios, Mokee Gateway UserChip Widget
文件结构总览
mokeeText/
├── app.py # [修改] CORS 配置 + 注册用户上下文中间件
├── middleware/
│ └── user_context.py # [新建] FastAPI 用户上下文中间件
├── routes/
│ ├── notes.py # [修改] 查询加 created_by 过滤,写入取 X-User-Id
│ ├── websites.py # [修改] 同上
│ ├── import_api.py # [修改] 同上
│ ├── share.py # [修改] 同上
│ └── upload.py # [修改] 同上
├── src/ # Vue 3 前端源码
│ ├── main.js # [修改] 挂载后加载 UserChip Widget
│ ├── router.js # [修改] 添加路由守卫
│ ├── App.vue # [修改] 用 UserChip 占位替换硬编码 Admin
│ ├── composables/
│ │ └── useApi.js # [修改] 改为网关地址 + 携带 Token + X-System-Code
│ ├── utils/
│ │ └── request.js # [新建] Axios 实例(拦截器)
│ └── directives/
│ └── permission.js # [新建] v-permission 指令
├── .env # [新建] 开发环境变量
├── .env.production # [新建] 生产环境变量
├── index.html # [修改] 添加 UserChip CSS + process polyfill
└── vite.config.js # [修改] 确保 env 前缀配置
前置准备:网关平台操作
这些操作在网关平台的数据库中执行,不涉及本项目代码。
Task 0: 网关数据库初始化
执行位置: 网关平台数据库(非 mokeeText 数据库)
- Step 1: 执行菜单和角色初始化 SQL
执行接入文档 §6.2 的完整 SQL(创建目录、页面菜单、按钮权限、管理员角色、普通用户角色)。
-- 在网关数据库执行
-- 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(),'-','');
SET @menu_biz = 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', '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1, NULL),
(@menu_biz, @system_id, '0', '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1, NULL);
-- 2. 创建页面菜单(对应 MoKeeText 实际路由: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', 'Document', 2, 1, 1, 'notes:list'),
(REPLACE(UUID(),'-',''), @system_id, @menu_sys, '网站管理', '/websites', 'websites/index', 'Key', 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;
- Step 2: 创建网关管理员账号
执行接入文档 §7 的 SQL。
-- 在网关数据库执行
-- 1. 创建管理员用户(密码 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');
-- 2. 绑定到 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
);
-- 3. 分配 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
);
-- 4. 绑定到 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
);
-- 5. 分配本系统 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
);
- Step 3: 验证网关配置
在网关管理后台用 admin_mokeetext-edit / admin123 登录,确认:
- 能看到「mokee编辑器」系统
- 菜单树包含笔记管理、网站管理、分享管理
- 角色列表包含 admin 和 user
阶段一:后端改造
Task 1: 新建 FastAPI 用户上下文中间件
文件:
-
Create:
middleware/__init__.py -
Create:
middleware/user_context.py -
Step 1: 创建 middleware 包
mkdir -p middleware
# middleware/__init__.py
"""中间件包"""
- Step 2: 编写 UserContext 辅助类 + 中间件
# middleware/user_context.py
"""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
本地开发时(无网关),使用 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:
return UserContext.from_request(request).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"""
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
设计说明:
-
request.state.user存储当前用户信息,路由函数通过UserContext.from_request(request)获取 -
DEV_MODE=true环境变量或X-Dev-Mock-User请求头开启本地开发模式 -
生产环境无网关请求头时
user_info为空字典,不会错误地暴露数据 -
Step 3: 验证中间件可以导入
cd /e/AI/claude/new/mokeeText && python -c "from middleware.user_context import UserContextMiddleware, UserContext; print('OK')"
Expected: OK
Task 2: 修改 app.py — 注册中间件 + 更新 CORS
文件:
-
Modify:
app.py -
Step 1: 更新 CORS 配置 + 注册用户上下文中间件
将 app.py 中的 CORS 配置和中间件注册改为:
"""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 之后、请求日志之前注册
app.add_middleware(UserContextMiddleware)
# ... 后续代码保持不变 ...
关键变更:
allow_origins从["*"]改为显式列表,因为allow_credentials=True与*不兼容- 新增
UserContextMiddleware注册
- Step 2: 验证应用可以启动
cd /e/AI/claude/new/mokeeText && python -c "from app import app; print('App loaded OK, routes:', len(app.routes))"
Expected: App loaded OK, routes: N
Task 3: 改造后端路由 — 用户数据隔离
涉及 5 个文件。 核心思路:所有 created_by / updated_by 从 request.state.user 获取,所有查询加 created_by 过滤。
文件:
-
Modify:
routes/notes.py -
Modify:
routes/websites.py -
Modify:
routes/import_api.py -
Modify:
routes/share.py -
Modify:
routes/upload.py -
Step 1: 改造 routes/notes.py
当前 created_by 硬编码为 "admin",查询不过滤用户。改为从请求上下文获取。
在每个路由函数开头添加:
from fastapi import Request
from middleware.user_context import UserContext
# 在每个路由函数中:
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()
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 {...}
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, "笔记不存在")
# 更新字段...
note.updated_by = user_id
db.commit()
return {...}
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}
规则:
-
查询:加
.filter(Model.created_by == user_id) -
创建:
created_by=user_id, updated_by=user_id -
修改:先按
id + created_by查找,再updated_by=user_id -
删除:先按
id + created_by查找,再删除 -
Step 2: 改造 routes/websites.py
同样模式,涉及 Website、Account、Folder(note 类型和 website 类型)三个模型。改造要点:
from fastapi import Request
from middleware.user_context import UserContext
# 示例 — 获取网站列表
@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,
)
# ...
# Folder 同样需要过滤
@router.get("/api/folders")
def list_folders(type: str, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
folders = db.query(Folder).filter(
Folder.type == type,
Folder.created_by == user_id,
).order_by(Folder.sort_order).all()
return folders
- Step 3: 改造 routes/import_api.py
from middleware.user_context import UserContext
@router.post("/api/import/parse")
def parse_import(data: dict, db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
# ... 解析逻辑中创建数据时使用 user_id ...
website = Website(
name=...,
url=...,
created_by=user_id,
updated_by=user_id,
)
account = Account(
username=...,
password=...,
created_by=user_id,
updated_by=user_id,
)
- Step 4: 改造 routes/share.py
分享功能分两部分:公开查看(不鉴权)和分享管理(需鉴权)。
from middleware.user_context import UserContext
# 公开查看 — 不走网关,直接后端渲染 HTML,不回 Vue SPA
# 用户访问: https://mokeetext.server.zgitm.com/share/{token}
# 无需 request 参数,保持原样
@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, "分享链接已过期")
# ... 返回 HTMLResponse,保持原样
# 分享管理 — 走网关,需鉴权
@router.post("/api/notes/{note_id}/share")
def share_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, "笔记不存在")
# ... 生成 share_token
@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, "笔记不存在")
# ... 清除 share_token
@router.get("/api/shares")
def list_shares(db: Session = Depends(get_db), request: Request = None):
user_id = UserContext.get_user_id(request)
shares = db.query(Note).filter(
Note.share_token.isnot(None),
Note.created_by == user_id,
).all()
return shares
关键:GET /share/{token} 不走网关、不走 Vue SPA、不鉴权。分享链接格式为 https://mokeetext.server.zgitm.com/share/{token}(后端地址)。
- Step 5: 改造 routes/upload.py
from middleware.user_context import UserContext
@router.post("/api/upload/image")
async def upload_image(file: UploadFile = File(...), db: Session = Depends(get_db), request: Request = None):
"""上传图片 — 网关已通过 Authorization 头鉴权,后端记录创建者"""
user_id = UserContext.get_user_id(request)
data = await file.read()
upload = Upload(
filename=file.filename or "img",
content_type=file.content_type,
data=data,
size=len(data),
created_by=user_id,
)
db.add(upload)
db.commit()
db.refresh(upload)
return {"url": f"/api/file/{upload.id}"}
@router.get("/api/file/{file_id}")
def get_file(file_id: int, db: Session = Depends(get_db)):
"""读取图片 — 网关已通过 Cookie 鉴权(<img> 标签浏览器自动带 Cookie)
流程:浏览器加载 <img src="https://gw.server.zgitm.com/api/file/123">
→ 浏览器自动发送 .zgitm.com 域的 Cookie
→ 网关从 Cookie 取 Token 验证(Authorization 头为空时 fallback 到 Cookie)
→ 验证通过,注入 X-User-* 请求头,转发到后端
→ 后端直接返回图片数据
"""
upload = db.query(Upload).filter_by(id=file_id).first()
if not upload:
raise HTTPException(404, "文件不存在")
return Response(content=upload.data, media_type=upload.content_type)
设计说明:
-
POST /api/upload/image:JS fetch 发起,带上Authorization头 +X-System-Code,网关鉴权后注入X-User-Id -
GET /api/file/{id}:<img>标签浏览器发起,无法带 Authorization 头,但网关会从 URL 所在域(gw.server.zgitm.com)的 Cookie 中取 Token 验证。因为 Cookie domain 是.zgitm.com,所有子域请求都会自动携带 -
图片读取由网关统一鉴权,后端不需要自己做用户隔离(到达后端的请求已经是合法的)
-
Step 6: 验证所有路由改造完成
cd /e/AI/claude/new/mokeeText && python -c "
from app import app
# 检查所有路由的依赖注入是否包含 request: Request
for route in app.routes:
if hasattr(route, 'methods'):
print(f'{route.methods} {route.path}')
"
Expected: 列出所有 API 路由,确认没有遗漏。
阶段二:前端改造
Task 4: 环境变量 + Vite 配置
文件:
-
Create:
.env -
Create:
.env.production -
Modify:
vite.config.js -
Step 1: 创建 .env(开发环境)
# .env — 本地开发环境变量
VITE_GATEWAY_URL = http://localhost:4006
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit
开发时 Vite 代理到本地 FastAPI 后端,不走网关。
- Step 2: 创建 .env.production(生产环境)
# .env.production — 生产环境变量
VITE_GATEWAY_URL = https://gw.server.zgitm.com
VITE_LOGIN_URL = https://login.user.zgitm.com
VITE_SYSTEM_CODE = mokeetext-edit
生产环境前端请求走网关。
- Step 3: 确认 vite.config.js 已正确处理 env 前缀
当前 vite.config.js 不需要改动——Vite 默认将 VITE_ 前缀的变量暴露给客户端代码。
Task 5: 新建 Axios 实例 + 请求拦截器
文件:
-
Create:
src/utils/request.js -
Step 1: 安装 axios
cd /e/AI/claude/new/mokeeText && npm install axios
- Step 2: 创建 Axios 实例
// src/utils/request.js
import axios from 'axios'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
// 从 Cookie 读取 Token(登录后网关写入的跨域 Cookie)
function getToken() {
const match = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)
return match ? decodeURIComponent(match[1]) : null
}
const request = axios.create({
baseURL: GATEWAY_URL,
timeout: 30000,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器:401 自动跳转登录
request.interceptors.response.use(
res => {
// 网关统一响应格式 { code: 200, data: ..., msg: ... }
if (res.data && res.data.code === 200) {
return res.data
}
return Promise.reject(res.data)
},
err => {
if (err.response?.status === 401) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export { getToken }
export default request
- Step 3: 验证 axios 实例可导入
cd /e/AI/claude/new/mokeeText && node -e "require('./src/utils/request.js')" 2>&1 || echo "ESM — 需要在 Vite 环境中验证"
Task 6: 改造 useApi.js — 从 fetch 切换到 axios
文件:
-
Modify:
src/composables/useApi.js -
Step 1: 重写 useApi.js
// src/composables/useApi.js
//
// 开发模式:直连本地后端(localhost:4006),请求头注入 X-Dev-Mock-User
// 生产模式:走网关(gw.server.zgitm.com),请求头注入 Authorization + X-System-Code
const isDev = window.location.hostname === 'localhost'
// 本地开发:直连后端 + mock 用户
const DEV_BASE = 'http://localhost:4006'
async function devRequest(url, options = {}) {
const res = await fetch(DEV_BASE + url, {
headers: {
'Content-Type': 'application/json',
'X-Dev-Mock-User': 'admin', // 触发后端 DEV_MODE 默认用户
...options.headers,
},
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
// 生产:走网关(通过 axios 实例)
import request, { getToken } from '../utils/request.js'
async function gwRequest(url, options = {}) {
const method = (options.method || 'GET').toLowerCase()
const config = { method, url, headers: options.headers || {} }
if (options.body) {
config.data = JSON.parse(options.body)
}
const res = await request(config)
return res.data !== undefined ? res.data : res
}
const doRequest = isDev ? devRequest : gwRequest
export function useApi() {
const api = {
// Notes
listNotes: (folderId) => doRequest(folderId ? `/api/notes?folder_id=${folderId}` : '/api/notes'),
getNote: (id) => doRequest(`/api/notes/${id}`),
createNote: (data) => doRequest('/api/notes', { method: 'POST', body: JSON.stringify(data) }),
updateNote: (id, data) => doRequest(`/api/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteNote: (id) => doRequest(`/api/notes/${id}`, { method: 'DELETE' }),
shareNote: (id, ttl) => doRequest(`/api/notes/${id}/share`, { method: 'POST', body: JSON.stringify({ ttl }) }),
unshareNote: (id) => doRequest(`/api/notes/${id}/share`, { method: 'DELETE' }),
moveNote: (id, folderId) => doRequest(`/api/notes/${id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId }) }),
// Websites
listWebsites: (folderId) => doRequest(folderId ? `/api/websites?folder_id=${folderId}` : '/api/websites'),
getWebsite: (id) => doRequest(`/api/websites/${id}`),
createWebsite: (data) => doRequest('/api/websites', { method: 'POST', body: JSON.stringify(data) }),
updateWebsite: (id, data) => doRequest(`/api/websites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteWebsite: (id) => doRequest(`/api/websites/${id}`, { method: 'DELETE' }),
// Accounts
getAccount: (id) => doRequest(`/api/accounts/${id}`),
addAccount: (siteId, data) => doRequest(`/api/websites/${siteId}/accounts`, { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id, data) => doRequest(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccount: (id) => doRequest(`/api/accounts/${id}`, { method: 'DELETE' }),
moveAccount: (id, direction) => doRequest(`/api/accounts/${id}/move`, { method: 'PUT', body: JSON.stringify({ direction }) }),
// Folders
listFolders: (type) => doRequest(`/api/folders?type=${type}`),
createFolder: (data) => doRequest('/api/folders', { method: 'POST', body: JSON.stringify(data) }),
updateFolder: (id, data) => doRequest(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteFolder: (id) => doRequest(`/api/folders/${id}`, { method: 'DELETE' }),
// Import
parseImport: (text) => doRequest('/api/import/parse', { method: 'POST', body: JSON.stringify({ text }) }),
}
return api
}
设计说明:
-
开发模式(
localhost)直连 FastAPI 后端,通过X-Dev-Mock-User头触发后端的 DEV_DEFAULT_USER -
生产模式走网关,通过 axios 实例带 Token 和 X-System-Code
-
API 方法签名不变,上层 Vue 组件无需改动
-
Step 2: 同步改造 NoteEditor.vue 的图片上传(双地址方案)
NoteEditor.vue 里有一份独立的 API_BASE 和 fetch 调用,不走 useApi.js,需要单独改。
关键设计:图片上传走网关(鉴权),图片读取走后端(公开)。因为分享链接是公开访问的,<img> 标签只能指向无需鉴权的地址。
上传 POST: https://gw.server.zgitm.com/api/upload/image ← 网关(鉴权)
读取 GET: https://mokeetext.server.zgitm.com/api/file/123 ← 后端直连(公开)
// src/components/NoteEditor.vue — 改动点
// 现在(第 127 行)
const API_BASE = window.location.hostname === 'localhost' ? '' : 'https://mokeetext.server.zgitm.com'
// 接入后 — 拆成两个地址
const isDev = window.location.hostname === 'localhost'
const GW_BASE = isDev ? 'http://localhost:4006' : 'https://gw.server.zgitm.com'
// ↑ 上传走网关
const FILE_BASE = isDev ? 'http://localhost:4006' : 'https://mokeetext.server.zgitm.com'
// ↑ 图片读取走后端(公开,分享页也需要加载图片)
// uploadAndInsert 函数:
async function uploadAndInsert(file) {
const form = new FormData()
form.append('file', file)
// 上传通过网关(需要鉴权头)
const headers = {}
if (!isDev) {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (token) headers['Authorization'] = `Bearer ${token}`
headers['X-System-Code'] = 'mokeetext-edit'
} else {
headers['X-Dev-Mock-User'] = 'admin'
}
const res = await fetch(GW_BASE + '/api/upload/image', { method: 'POST', body: form, headers })
const data = await res.json()
if (data.url) {
// 图片 URL 指向后端直连(公开,分享页 + 编辑器都能加载)
const src = data.url.startsWith('http') ? data.url : FILE_BASE + data.url
editor?.chain().focus().setImage({ src }).run()
}
}
NoteEditor.vue 改动总结:API_BASE 拆成 GW_BASE + FILE_BASE,上传请求加鉴权头,~20 行改动。
Task 7: 路由守卫 — 未登录跳转登录页
文件:
-
Modify:
src/router.js -
Step 1: 添加路由守卫
import { createRouter, createWebHistory } from 'vue-router'
import NotesView from './views/NotesView.vue'
import WebsitesView from './views/WebsitesView.vue'
import SharesView from './views/SharesView.vue'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const routes = [
{ path: '/', redirect: '/notes' },
{ path: '/notes', name: 'notes', component: NotesView },
{ path: '/websites', name: 'websites', component: WebsitesView },
{ path: '/shares', name: 'shares', component: SharesView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:无 Token 则跳转统一登录页
router.beforeEach((to, _from, next) => {
// 分享链接查看页不需要登录
if (to.path.startsWith('/share/')) {
next()
return
}
// 本地开发模式:跳过登录检查
if (window.location.hostname === 'localhost') {
next()
return
}
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) {
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router
- Step 2: 为分享链接查看添加路由(如果尚未添加)
检查 router.js 是否已有 /share/:token 路由。如果没有,后续 Task 9 在 App.vue 改造时会处理。
Task 8: 新建 v-permission 指令
文件:
-
Create:
src/directives/permission.js -
Step 1: 创建 permission 指令
// src/directives/permission.js
// 从 Cookie 中读取 Token,解析 JWT payload 中的 permissions
function getPermissions() {
const token = document.cookie.match(/(?:^|;\s*)token=([^;]*)/)?.[1]
if (!token) return []
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.permissions ?? []
} catch {
return []
}
}
export const permission = {
mounted(el, binding) {
const permissions = getPermissions()
const required = binding.value
// 没有该权限则从 DOM 中移除元素
if (required && !permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
},
}
- Step 2: 在 main.js 中全局注册指令
修改 src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import { permission } from './directives/permission.js'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.directive('permission', permission)
app.mount('#app')
Task 9: 替换用户头像 — 接入 UserChip Widget + 更新路由守卫
文件:
-
Modify:
src/App.vue -
Modify:
index.html -
Modify:
src/main.js -
Step 1: 修改 index.html — 添加 UserChip CSS + process polyfill
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoKeeText</title>
<!-- Mokee UserChip Widget 样式 -->
<link rel="stylesheet" href="https://web.gw.zgitm.com/widgets/user-chip.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
- Step 2: 修改 App.vue — 替换硬编码 Admin 为 UserChip 占位
找到 App.vue 中 <header class="topbar"> 内的:
<div class="user-widget-h">
<span class="user-avatar">M</span>
<span style="font-size:13px; font-weight:500;">Admin</span>
</div>
替换为:
<!-- Mokee Gateway UserChip Widget 占位 -->
<div id="mokee-user-chip"
data-api-base="https://gw.server.zgitm.com"
data-cookie-domain=".zgitm.com"></div>
同时更新 .user-widget-h 样式为 UserChip 样式:
#mokee-user-chip { flex-shrink: 0; }
- Step 3: 修改 main.js — 在 mount 后加载 UserChip JS
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import { permission } from './directives/permission.js'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.directive('permission', permission)
app.mount('#app')
// 生产环境:动态加载 UserChip Widget(必须在 mount 之后,因为需要 DOM 中已有 #mokee-user-chip)
// 本地开发:不加载 Widget,使用硬编码的 mock 头像
if (window.location.hostname !== 'localhost') {
window.process = { env: {} } // polyfill:防止 "process is not defined"
const widgetScript = document.createElement('script')
widgetScript.src = 'https://web.gw.zgitm.com/widgets/user-chip.js'
document.body.appendChild(widgetScript)
}
- Step 4: 本地开发保留 mock 头像
在 App.vue 的 topbar 中,UserChip 占位旁边加一个开发环境 fallback:
<!-- 本地开发 mock 头像(生产环境被 UserChip 覆盖) -->
<div v-if="isDev" class="user-widget-h">
<span class="user-avatar">A</span>
<span style="font-size:13px; font-weight:500;">Admin(本地)</span>
</div>
<!-- Mokee Gateway UserChip Widget 占位 -->
<div id="mokee-user-chip"
data-api-base="https://gw.server.zgitm.com"
data-cookie-domain=".zgitm.com"
:style="{ display: isDev ? 'none' : '' }"></div>
在 <script setup> 中添加:
const isDev = window.location.hostname === 'localhost'
Task 10: 按钮权限控制 — 在业务组件中使用 v-permission
文件:
- Modify:
src/views/NotesView.vue(示例) - Modify:
src/views/WebsitesView.vue(示例)
这是可选增强,不是最小可运行版本的必要条件。根据需要逐步添加。
- Step 1: 在需要权限控制的按钮上加 v-permission
<!-- 示例:笔记页新增按钮 -->
<button class="btn btn-primary" v-permission="'notes:add'" @click="...">新建笔记</button>
<button class="btn btn-danger-ghost" v-permission="'notes:delete'" @click="...">删除</button>
- Step 2: 验证构建成功
cd /e/AI/claude/new/mokeeText && npm run build
Expected: static/dist/ 下生成新的构建产物。
阶段三:部署更新
Task 11: 后端环境变量更新
文件:
-
Modify:
docker-compose.yml(如有) -
Step 1: 确认 DEV_MODE 环境变量
在生产环境的 Docker Compose 或启动命令中,确保 DEV_MODE 不为 true:
# docker-compose.yml 后端服务
environment:
- DEV_MODE=false
- MYSQL_HOST=10.20.1.122
# ... 其他环境变量保持不变
- Step 2: 重新构建并部署后端
cd /data/mokee/mokeetext
docker compose build backend
docker compose up -d backend
- Step 3: 验证后端部署
curl -X GET https://mokeetext.server.zgitm.com/api/notes \
-H "X-Dev-Mock-User: admin"
Expected: 返回笔记列表(通过 X-Dev-Mock-User 模拟用户)。如果没有这个头且 DEV_MODE=false,应返回空或报错。
Task 12: 前端构建 + 部署
- Step 1: 安装依赖并构建
cd /e/AI/claude/new/mokeeText
npm install
npm run build
Expected: static/dist/ 更新。
- Step 2: 重新构建并部署前端
cd /data/mokee/mokeetext
docker compose build frontend
docker compose up -d frontend
- Step 3: 验证前端部署
浏览器访问 https://mokeetext.zgitm.com:
- 应自动跳转到
https://login.user.zgitm.com?systemCode=mokeetext-edit - 登录后应看到 UserChip 头像替代了之前的 Admin 显示
- API 请求应通过网关转发
对接测试清单
按接入文档 §8 逐项验证:
- 1. 访问
https://mokeetext.zgitm.com→ 自动跳转统一登录页 - 2. 使用
admin_mokeetext-edit / admin123登录 → 成功进入业务前端 - 3. 调用
GET /zgapi/v1/auth/user/info→ 返回用户信息 - 4. 后端
UserContext.get_user_id(request)能获取到用户ID(观察业务数据是否隔离) - 5. 退出登录后访问业务页面 → 重新跳转登录页
- 6. 创建笔记 → 数据库
created_by字段为登录用户ID(非硬编码 admin) - 7. 不同用户登录 → 只能看到自己创建的数据
- 8. UserChip Widget 显示正确(头像 + 下拉菜单 + 切换系统 + 退出登录)
已识别风险与对策
-
图片读取鉴权✅ 已解决 — 采用双地址方案:上传走网关(鉴权),读取走后端(公开)。<img>标签指向https://mokeetext.server.zgitm.com/api/file/{id}(后端直连),无论编辑器场景还是公开分享场景都能加载。上传通过网关gw.server.zgitm.com的 Authorization 头鉴权。后端GET /api/file/{id}保持公开,不鉴权。 -
本地开发不走网关 — 本地
localhost模式下跳过登录检查 + mock 用户,开发体验不受影响。但如果需要调试网关相关功能(如 UserChip、权限),需要部署到测试环境验证。 -
现有 admin 数据平滑过渡 — 所有历史数据的
created_by = "admin"。如果网关中admin用户的X-User-Id恰好也是"admin",则老数据自动归属该用户,无需迁移。如果网关中用户 ID 是 UUID 格式(如"a1b2c3d4..."),则需要写迁移脚本:UPDATE notes SET created_by = '<admin_uuid>', updated_by = '<admin_uuid>' WHERE created_by = 'admin'; UPDATE websites SET created_by = '<admin_uuid>', updated_by = '<admin_uuid>' WHERE created_by = 'admin'; UPDATE accounts SET created_by = '<admin_uuid>', updated_by = '<admin_uuid>' WHERE created_by = 'admin'; UPDATE folders SET created_by = '<admin_uuid>' WHERE created_by = 'admin'; UPDATE uploads SET created_by = '<admin_uuid>' WHERE created_by = 'admin'; -
用户注册/管理 — 接入网关后,用户管理在网关后台统一操作。MoKeeText 自身不需要注册/登录/用户管理功能。
执行建议
推荐执行顺序:
- Task 0(网关数据库初始化)— 先完成,不依赖代码
- Task 1-2(后端中间件 + CORS)— 后端基础设施
- Task 4-5(前端环境变量 + axios)— 前端基础设施
- Task 3(后端路由改造)— 核心业务改造,工作量最大
- Task 6-9(前端路由守卫 + API + Widget + 权限)— 前端核心改造
- Task 11-12(部署)— 最后上线
- 对接测试清单 — 逐项验证