1217 lines
41 KiB
Markdown
1217 lines
41 KiB
Markdown
# 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. 对接测试清单 — 逐项验证
|