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

41 KiB
Raw Permalink Blame History

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)

# ... 后续代码保持不变 ...

关键变更:

  1. allow_origins["*"] 改为显式列表,因为 allow_credentials=True* 不兼容
  2. 新增 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_byrequest.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

同样模式,涉及 WebsiteAccountFoldernote 类型和 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/imageJS 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_BASEfetch 调用,不走 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 显示正确(头像 + 下拉菜单 + 切换系统 + 退出登录)

已识别风险与对策

  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..."),则需要写迁移脚本:

    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. 对接测试清单 — 逐项验证