Files
mokeeText/docs/superpowers/plans/2026-06-23-mokeetext-gateway-integration.md
2026-07-15 16:08:17 +08:00

1217 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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创建目录、页面菜单、按钮权限、管理员角色、普通用户角色
```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。
```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 包**
```bash
mkdir -p middleware
```
```python
# middleware/__init__.py
"""中间件包"""
```
- [ ] **Step 2: 编写 UserContext 辅助类 + 中间件**
```python
# 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: 验证中间件可以导入**
```bash
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 配置和中间件注册改为:
```python
"""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)
# ... 后续代码保持不变 ...
```
**关键变更:**
1. `allow_origins``["*"]` 改为显式列表,因为 `allow_credentials=True``*` 不兼容
2. 新增 `UserContextMiddleware` 注册
- [ ] **Step 2: 验证应用可以启动**
```bash
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"`,查询不过滤用户。改为从请求上下文获取。
在每个路由函数开头添加:
```python
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 类型)三个模型。改造要点:
```python
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**
```python
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**
分享功能分两部分:**公开查看**(不鉴权)和**分享管理**(需鉴权)。
```python
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**
```python
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: 验证所有路由改造完成**
```bash
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开发环境**
```bash
# .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生产环境**
```bash
# .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**
```bash
cd /e/AI/claude/new/mokeeText && npm install axios
```
- [ ] **Step 2: 创建 Axios 实例**
```javascript
// 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 实例可导入**
```bash
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**
```javascript
// 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 ← 后端直连(公开)
```
```javascript
// 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: 添加路由守卫**
```javascript
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 指令**
```javascript
// 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`
```javascript
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**
```html
<!-- 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">` 内的:
```html
<div class="user-widget-h">
<span class="user-avatar">M</span>
<span style="font-size:13px; font-weight:500;">Admin</span>
</div>
```
替换为:
```html
<!-- 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 样式:
```css
#mokee-user-chip { flex-shrink: 0; }
```
- [ ] **Step 3: 修改 main.js — 在 mount 后加载 UserChip JS**
```javascript
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
```html
<!-- 本地开发 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>` 中添加:
```javascript
const isDev = window.location.hostname === 'localhost'
```
---
### Task 10: 按钮权限控制 — 在业务组件中使用 v-permission
**文件:**
- Modify: `src/views/NotesView.vue`(示例)
- Modify: `src/views/WebsitesView.vue`(示例)
这是**可选增强**,不是最小可运行版本的必要条件。根据需要逐步添加。
- [ ] **Step 1: 在需要权限控制的按钮上加 v-permission**
```html
<!-- 示例:笔记页新增按钮 -->
<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: 验证构建成功**
```bash
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`
```yaml
# docker-compose.yml 后端服务
environment:
- DEV_MODE=false
- MYSQL_HOST=10.20.1.122
# ... 其他环境变量保持不变
```
- [ ] **Step 2: 重新构建并部署后端**
```bash
cd /data/mokee/mokeetext
docker compose build backend
docker compose up -d backend
```
- [ ] **Step 3: 验证后端部署**
```bash
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: 安装依赖并构建**
```bash
cd /e/AI/claude/new/mokeeText
npm install
npm run build
```
Expected: `static/dist/` 更新。
- [ ] **Step 2: 重新构建并部署前端**
```bash
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 显示正确(头像 + 下拉菜单 + 切换系统 + 退出登录)
---
## 已识别风险与对策
1. ~~**图片读取鉴权**~~**已解决** — 采用双地址方案:上传走网关(鉴权),读取走后端(公开)。`<img>` 标签指向 `https://mokeetext.server.zgitm.com/api/file/{id}`(后端直连),无论编辑器场景还是公开分享场景都能加载。上传通过网关 `gw.server.zgitm.com` 的 Authorization 头鉴权。后端 `GET /api/file/{id}` 保持公开,不鉴权。
2. **本地开发不走网关** — 本地 `localhost` 模式下跳过登录检查 + mock 用户,开发体验不受影响。但如果需要调试网关相关功能(如 UserChip、权限需要部署到测试环境验证。
3. **现有 admin 数据平滑过渡** — 所有历史数据的 `created_by = "admin"`。如果网关中 `admin` 用户的 `X-User-Id` 恰好也是 `"admin"`,则老数据自动归属该用户,无需迁移。如果网关中用户 ID 是 UUID 格式(如 `"a1b2c3d4..."`),则需要写迁移脚本:
```sql
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';
```
4. **用户注册/管理** — 接入网关后用户管理在网关后台统一操作。MoKeeText 自身不需要注册/登录/用户管理功能。
---
## 执行建议
**推荐执行顺序:**
1. Task 0网关数据库初始化— 先完成,不依赖代码
2. Task 1-2后端中间件 + CORS— 后端基础设施
3. Task 4-5前端环境变量 + axios— 前端基础设施
4. Task 3后端路由改造— 核心业务改造,工作量最大
5. Task 6-9前端路由守卫 + API + Widget + 权限)— 前端核心改造
6. Task 11-12部署— 最后上线
7. 对接测试清单 — 逐项验证