# 鸡蛋论坛 MCP 服务 · Agent 安装说明书

> **给正在阅读这份文件的 Agent**：你的任务是把鸡蛋论坛的 MCP 服务安装到你自己的运行环境里，
> 装完后你就拥有直接操作论坛的能力（拉帖子、读通知、回帖、搜索）。
> 请按步骤顺序执行。**第 1 步必须先向用户索取论坛账号密码，拿到之前不要继续。**
>
> 文档地址（可转发给用户或其他 Agent）：`https://101.42.5.199:3012/mcp-setup.md`
> 站内协议页（面向人类的简介与下载入口）：`https://101.42.5.199:3012/mcp`

## 装好后你能做什么

| 能力 | 工具 |
| --- | --- |
| 拉取论坛消息流（最新/热门/按分区，游标翻页） | `forum_feed` |
| 读帖子正文 + 楼层回复 | `forum_topic` |
| 查看我的通知（回复/@我）与未读数 | `forum_notifications` |
| 标记通知已读（按 id 或全部） | `forum_mark_read` |
| 以用户账号身份回复指定帖子 | `forum_reply_topic` |
| 关键词搜索（帖子/彩蛋筐/蛋库） | `forum_search` |
| 确认当前配置的账号身份 | `forum_whoami` |

服务本体是一个**零第三方依赖**的 Node.js（≥ 22）MCP server，走 stdio 协议，
包装论坛公开 API `https://101.42.5.199:3012/api/v1`。私信功能暂未开放。

---

## 第 1 步 · 向用户索取论坛账号（必做，先问再做）

**停下来**，向用户提出下面的问题（可根据对话语境改写，但别跳过）：

> 接入鸡蛋论坛 MCP 需要论坛账号。请提供你的用户名和密码——我会用它登录一次换取 30 天的
> token，之后只保存 token、不保存密码。
> 如果你还没有账号，可以去 https://101.42.5.199:3012/register 自行注册一个再回来；
> 或者你明确同意由我代注册，告诉我你想要的用户名即可。

注意：

- **不要**猜测、使用默认值或占位符继续往下装。
- 拿到 token（不是密码）的用户可以直接跳到第 3 步。
- 论坛是真实人类社区，以谁的名义发帖谁负责——回复类操作前先跟用户确认目标帖子和内容。

## 第 2 步 · 登录换取 token

```bash
FORUM=https://101.42.5.199:3012
TOKEN=$(curl -s -X POST $FORUM/api/v1/auth/login \
  -H 'content-type: application/json' -H 'x-client: native' \
  -d '{"username":"<用户给的用户名>","password":"***"}' \
  | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
echo "token 前缀: ${TOKEN:0:8}…"
```

要点：

- `x-client: native` 头必须带：不带的话服务端只发网页 cookie，不返回 token。
- 成功响应形如 `{"ok":true,"data":{"token":"…","user":{…}}}`；`ok:false` 时按 `error.message`
  处理（`用户名或密码错误` → 回到第 1 步找用户核实；没有密码的邮箱验证码账号无法这样登录）。
- token 即网站会话，30 天有效；**别把完整 token 打进日志或贴给用户以外的任何人**。
- HTTPS 证书由 Let's Encrypt 签发，正常校验即可通过，无需 `-k`。

## 第 3 步 · 落地 MCP 服务文件

若本机已存在 `/home/ubuntu/forum-mcp/server.js`（Agent 恰好跑在论坛那台服务器上），
直接跳到第 4 步，并把下面 `<安装目录>` 记为 `/home/ubuntu/forum-mcp`。

否则：`mkdir -p <安装目录>`，把下面四个文件原样写入。校验环境：`node -v` ≥ 22。

### forum-client.js —论坛 API 客户端（登录/token 缓存/401 自动重登）

```javascript
'use strict';

// 鸡蛋论坛 /api/v1 的 HTTP 客户端封装。
// 凭证三种来源，按优先级：FORUM_TOKEN 环境变量 > ~/.config/forum-mcp/token.json 缓存 > 用
// FORUM_USER/FORUM_PASS 走 POST /auth/login 现登（带 X-Client: native 才会拿到 token）。
// 401 自动作废缓存重登一次再重试；重登仍 401 说明密码错了，把 API 的原始错误抛出去。

const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');

const BASE = (process.env.FORUM_BASE || 'http://127.0.0.1:3003').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const TOKEN_FILE = process.env.FORUM_TOKEN_FILE || path.join(os.homedir(), '.config', 'forum-mcp', 'token.json');

class ForumApiError extends Error {
  constructor(code, message, { status = 0, details } = {}) {
    super(message);
    this.name = 'ForumApiError';
    this.code = code;
    this.status = status;
    this.details = details;
  }
}

function readCachedToken() {
  try {
    const t = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
    return typeof t.token === 'string' && t.token ? t.token : null;
  } catch {
    return null;
  }
}

function writeCachedToken(token) {
  try {
    fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true, mode: 0o700 });
    fs.writeFileSync(
      TOKEN_FILE,
      JSON.stringify({ token, saved_at: new Date().toISOString() }, null, 2),
      { mode: 0o600 },
    );
  } catch (e) {
    // 缓存写不进去不影响本进程使用，只影响下次冷启动要不要重新登录
    console.error(`[forum-mcp] 无法写入 token 缓存 ${TOKEN_FILE}: ${e.message}`);
  }
}

function clearCachedToken() {
  try { fs.unlinkSync(TOKEN_FILE); } catch { /* 本就没有 */ }
}

// auth 语义与 /api/v1 的 optionalAuth 对齐：
//   'optional' — 有凭证就带上，没有就匿名访问（feed/topic/search 游客可读）；
//   'required' — 没有可用凭证时先登录，登录不了直接报 NO_CREDENTIALS（通知/回复/私信类写接口）。
async function http(method, p, { query = {}, body, token } = {}) {
  const url = new URL(API + p);
  for (const [k, v] of Object.entries(query)) {
    if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
  }
  const headers = { accept: 'application/json', 'x-client': 'native' };
  if (token) headers.authorization = `Bearer ${token}`;
  let payload;
  if (body !== undefined) {
    headers['content-type'] = 'application/json';
    payload = JSON.stringify(body);
  }
  let res;
  try {
    res = await fetch(url, { method, headers, body: payload });
  } catch (e) {
    throw new ForumApiError('NETWORK', `无法连接论坛服务 ${BASE}：${e.message}`);
  }
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch { /* 走下面的非 JSON 分支 */ }
  if (!json || typeof json !== 'object') {
    throw new ForumApiError('BAD_RESPONSE', `非 JSON 响应（HTTP ${res.status}）：${text.slice(0, 200)}`, { status: res.status });
  }
  if (json.ok === false) {
    const err = json.error || {};
    throw new ForumApiError(err.code || 'UNKNOWN', err.message || '未知错误', { status: res.status, details: err.details });
  }
  return json; // { ok:true, data, meta? }
}

async function login() {
  const username = process.env.FORUM_USER;
  const password = process.env.FORUM_PASS;
  if (!username || !password) {
    throw new ForumApiError(
      'NO_CREDENTIALS',
      '缺少论坛登录凭证：请在 MCP server 的 env 里配置 FORUM_TOKEN，或 FORUM_USER + FORUM_PASS',
    );
  }
  const json = await http('POST', '/auth/login', { body: { username, password } });
  const token = json && json.data && json.data.token;
  if (!token) {
    throw new ForumApiError('LOGIN_FAILED', '登录成功但未返回 token（服务端可能不认 X-Client: native）');
  }
  writeCachedToken(token);
  return token;
}

let memToken = null;

// mode='optional' 时不主动登录：只用现成的 token（env > 内存 > 缓存文件），可能返回 null。
// mode='required' 时拿不到就触发登录。
async function resolveToken(mode) {
  if (process.env.FORUM_TOKEN) return process.env.FORUM_TOKEN;
  if (memToken) return memToken;
  const cached = readCachedToken();
  if (cached) return (memToken = cached);
  if (mode === 'required') return (memToken = await login());
  return null;
}

async function apiCall(method, p, { query, body } = {}, { auth = 'required' } = {}) {
  let token = await resolveToken(auth);
  try {
    return await http(method, p, { query, body, token });
  } catch (e) {
    const authFailed = e instanceof ForumApiError && (e.status === 401 || e.code === 'UNAUTHORIZED');
    // 只有「确实带了 token 却 401」才值得重登；匿名请求 401 是缺凭证，重试也没用
    if (authFailed && token && auth === 'required') {
      clearCachedToken();
      memToken = null;
      token = await resolveToken('required');
      return http(method, p, { query, body, token });
    }
    throw e;
  }
}

module.exports = { apiCall, ForumApiError, BASE, API };
```

### tools.js — 七个工具的定义与参数校验

```javascript
'use strict';

// 工具定义：每个工具 = MCP 声明（name/description/inputSchema）+ run(args) 处理器。
// run 返回的对象由 server.js 序列化进 content[0].text；参数校验失败/论坛返回
// {ok:false,...} 都抛 ForumApiError，由 server.js 转成 isError 结果。
// 论坛 API 信封：成功 {ok:true, data, meta?}，meta.next_cursor 是不透明翻页串。

const { apiCall, ForumApiError } = require('./forum-client');

// ---------- 参数校验小工具 ----------

function badArg(msg) {
  throw new ForumApiError('BAD_ARGUMENT', msg);
}

function assertString(v, name, { min = 1, max = Infinity } = {}) {
  if (typeof v !== 'string') badArg(`${name} 必须是字符串`);
  const s = v.trim();
  if (s.length < min) badArg(`${name} 不能为空`);
  if (s.length > max) badArg(`${name} 最长 ${max} 字（当前 ${s.length}）`);
  return s;
}

function assertInt(v, name, { min = -Infinity, max = Infinity } = {}) {
  if (typeof v !== 'number' || !Number.isInteger(v)) badArg(`${name} 必须是整数`);
  if (v < min || v > max) badArg(`${name} 需在 ${min}..${max} 之间`);
  return v;
}

function optInt(v, name, opts) {
  return v === undefined || v === null ? undefined : assertInt(v, name, opts);
}

function assertEnum(v, name, values) {
  if (!values.includes(v)) badArg(`${name} 只能是 ${values.join(' / ')} 之一`);
  return v;
}

// ---------- 工具表 ----------

const tools = [
  {
    name: 'forum_feed',
    description:
      '拉取鸡蛋论坛的消息流（帖子列表）。sort=latest 按最后活跃时间（置顶帖优先），hot 按热度；' +
      'category 传分区 slug（如 jichang）可只看某分区，省略则看全站。' +
      '翻页用上一页返回的 meta.next_cursor 作为 cursor 传入。',
    inputSchema: {
      type: 'object',
      properties: {
        sort: { type: 'string', enum: ['latest', 'hot'], description: '排序方式，默认 latest' },
        category: { type: 'string', description: '分区 slug，省略=全站' },
        limit: { type: 'integer', minimum: 1, maximum: 50, description: '每页条数，默认 20' },
        cursor: { type: 'string', description: '上一页 meta.next_cursor 返回的不透明游标' },
      },
    },
    async run(a) {
      const r = await apiCall(
        'GET', '/feed',
        {
          query: {
            sort: a.sort === undefined ? undefined : assertEnum(a.sort, 'sort', ['latest', 'hot']),
            category: a.category,
            limit: optInt(a.limit, 'limit', { min: 1, max: 50 }),
            cursor: a.cursor,
          },
        },
        { auth: 'optional' },
      );
      return { topics: r.data, meta: r.meta };
    },
  },

  {
    name: 'forum_topic',
    description:
      '读取一个帖子：正文（topic，Markdown 原文在 topic.content，渲染后 HTML 在 topic.content_html）' +
      '加前若干楼回复（replies，按楼层升序，floor 为楼号）。翻页同 forum_feed，用 replies_meta.next_cursor。',
    inputSchema: {
      type: 'object',
      properties: {
        topic_id: { type: 'integer', description: '帖子 id（话题页 URL /t/数字 里那个）' },
        replies_limit: { type: 'integer', minimum: 1, maximum: 50, description: '回复每页条数，默认 20' },
        replies_cursor: { type: 'string', description: '回复翻页游标（上一页 replies_meta.next_cursor）' },
      },
      required: ['topic_id'],
    },
    async run(a) {
      const id = assertInt(a.topic_id, 'topic_id', { min: 1 });
      const [t, rp] = await Promise.all([
        apiCall('GET', `/topics/${id}`, {}, { auth: 'optional' }),
        apiCall(
          'GET', `/topics/${id}/replies`,
          { query: { limit: optInt(a.replies_limit, 'replies_limit', { min: 1, max: 50 }), cursor: a.replies_cursor } },
          { auth: 'optional' },
        ),
      ]);
      return { topic: t.data, replies: rp.data, replies_meta: rp.meta };
    },
  },

  {
    name: 'forum_notifications',
    description:
      '获取我的论坛通知（有人回复我的帖子、@我 等）：items 按时间倒序，unread 是未读总数。' +
      '每条含 type（reply/mention 等）、topic_id（涉及的帖子）、excerpt（摘要）、is_read。' +
      '处理完的通知用 forum_mark_read 标已读。',
    inputSchema: {
      type: 'object',
      properties: {
        limit: { type: 'integer', minimum: 1, maximum: 50, description: '每页条数，默认 20' },
        cursor: { type: 'string', description: '翻页游标（上一页 meta.next_cursor）' },
      },
    },
    async run(a) {
      const [list, cnt] = await Promise.all([
        apiCall('GET', '/notifications', { query: { limit: optInt(a.limit, 'limit', { min: 1, max: 50 }), cursor: a.cursor } }, { auth: 'required' }),
        apiCall('GET', '/notifications/unread-count', {}, { auth: 'required' }),
      ]);
      return { unread: cnt.data.unread, items: list.data, meta: list.meta };
    },
  },

  {
    name: 'forum_mark_read',
    description:
      '把通知标记为已读。传 ids（通知 id 数组，来自 forum_notifications）只标指定的几条；' +
      '省略 ids 会把全部未读一次标完。限流 30 次/分钟。',
    inputSchema: {
      type: 'object',
      properties: {
        ids: {
          type: 'array', items: { type: 'integer' }, maxItems: 200,
          description: '要标记的通知 id 列表；省略=全部标已读；传空数组=什么都不做',
        },
      },
    },
    async run(a) {
      let body;
      if (a.ids === undefined || a.ids === null) {
        body = {}; // 缺省 ids = 标记全部未读（论坛 API 语义）
      } else {
        if (!Array.isArray(a.ids)) badArg('ids 必须是整数数组');
        body = { ids: a.ids.map((v, i) => assertInt(v, `ids[${i}]`, { min: 1 })) };
      }
      const r = await apiCall('POST', '/notifications/read', { body }, { auth: 'required' });
      return r.data; // { updated: 本次真正翻成已读的行数 }
    },
  },

  {
    name: 'forum_reply_topic',
    description:
      '回复指定帖子（在帖子下追加一层楼）。content 为 Markdown 正文，1-5000 字，支持 @用户名 提及。' +
      '限流 8 次/分钟；帖子被锁定会返回 FORBIDDEN。成功返回新楼层的完整 DTO（含 floor 楼号与 id）。' +
      '注意：这会以当前账号的名义公开发帖。',
    inputSchema: {
      type: 'object',
      properties: {
        topic_id: { type: 'integer', description: '要回复的帖子 id' },
        content: { type: 'string', description: '回复正文（Markdown）' },
      },
      required: ['topic_id', 'content'],
    },
    async run(a) {
      const id = assertInt(a.topic_id, 'topic_id', { min: 1 });
      const content = assertString(a.content, 'content', { min: 1, max: 5000 });
      const r = await apiCall('POST', `/topics/${id}/replies`, { body: { content } }, { auth: 'required' });
      return r.data; // Reply DTO
    },
  },

  {
    name: 'forum_search',
    description:
      '按关键词搜索鸡蛋论坛，返回 topics（帖子）、eggs（彩蛋筐福利）、dan（蛋库）三段结果。' +
      '空关键词返回空结果而不是报错。',
    inputSchema: {
      type: 'object',
      properties: {
        q: { type: 'string', description: '关键词（服务端最多取 60 字）' },
        limit: { type: 'integer', minimum: 1, maximum: 20, description: '每段条数，默认 10' },
      },
      required: ['q'],
    },
    async run(a) {
      const q = assertString(a.q, 'q', { min: 1, max: 200 });
      const r = await apiCall(
        'GET', '/search',
        { query: { q, limit: optInt(a.limit, 'limit', { min: 1, max: 20 }) } },
        { auth: 'optional' },
      );
      return r.data;
    },
  },

  {
    name: 'forum_whoami',
    description:
      '查询当前 MCP 服务实际使用的论坛账号（用户名、积分、等级等）并验证凭证有效。' +
      '排查登录/权限问题、或回复前确认「我以谁的身份说话」时先用它。',
    inputSchema: { type: 'object', properties: {} },
    async run() {
      const r = await apiCall('GET', '/users/me', {}, { auth: 'required' });
      const u = r.data;
      return { username: u.username, id: u.id, points: u.points, level: u.level, is_admin: u.is_admin };
    },
  },
];

const byName = new Map(tools.map((t) => [t.name, t]));

module.exports = { tools, byName };
```

### server.js — stdio JSON-RPC 循环（MCP 协议入口）

```javascript
'use strict';

// 鸡蛋论坛 MCP 服务（stdio 传输，换行分隔的 JSON-RPC 2.0）。
// 零依赖手写实现：本协议里 agent 挂载只需要 initialize / ping / tools/list / tools/call
// 四个交互，引 SDK 反而多一层供应链。工具的业务逻辑全在 tools.js + forum-client.js。

const readline = require('node:readline');
const { tools, byName } = require('./tools');
const { ForumApiError, BASE } = require('./forum-client');

const SERVER_INFO = { name: 'jidan-forum-mcp', version: '0.1.0' };
// 收到不认识的新版本时回退到最老的稳定版本（协议规范要求 server 表态一个双方都懂的版本）
const KNOWN_PROTOCOL_VERSIONS = new Set(['2024-11-05', '2025-03-26', '2025-06-18']);
const FALLBACK_PROTOCOL_VERSION = '2024-11-05';

const log = (...args) => console.error('[forum-mcp]', ...args);
const write = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
const result = (id, res) => write({ jsonrpc: '2.0', id, result: res });
const rpcError = (id, code, message, data) =>
  write({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });

// 工具出错 = result 里带 isError 的文本内容（MCP 约定：业务错误不走 JSON-RPC 错误通道）
function toolErrorText(e) {
  if (e instanceof ForumApiError) {
    const status = e.status ? ` HTTP ${e.status}` : '';
    const details = e.details === undefined ? '' : ` details=${JSON.stringify(e.details)}`;
    return `论坛接口调用失败 [${e.code}]${status}：${e.message}${details}`;
  }
  return `MCP 工具执行异常：${e && e.stack ? e.stack : String(e)}`;
}

async function handle(reqMsg) {
  const { id, method, params } = reqMsg || {};
  const isRequest = id !== undefined && id !== null;

  if (method === 'initialize') {
    const requested = params && params.protocolVersion;
    return result(id, {
      protocolVersion: KNOWN_PROTOCOL_VERSIONS.has(requested) ? requested : FALLBACK_PROTOCOL_VERSION,
      capabilities: { tools: {} },
      serverInfo: SERVER_INFO,
    });
  }

  // 一切 notifications/*（含 initialized）与未知通知都不回话
  if (!isRequest) return;

  switch (method) {
    case 'ping':
      return result(id, {});

    case 'tools/list':
      return result(id, {
        tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
      });

    case 'tools/call': {
      const name = params && params.name;
      const args = (params && params.arguments) || {};
      const tool = typeof name === 'string' ? byName.get(name) : undefined;
      if (!tool) return rpcError(id, -32602, `未知工具：${String(name)}`);
      try {
        const data = await tool.run(args);
        return result(id, { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
      } catch (e) {
        log(`tools/call ${name} 失败：`, e && e.message);
        return result(id, { content: [{ type: 'text', text: toolErrorText(e) }], isError: true });
      }
    }

    default:
      return rpcError(id, -32601, `方法不存在：${String(method)}`);
  }
}

function main() {
  if (process.env.FORUM_MCP_DEBUG) log(`启动，base=${BASE}，工具 ${tools.length} 个`);
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
  rl.on('line', (line) => {
    line = line.trim();
    if (!line) return;
    let msg;
    try {
      msg = JSON.parse(line);
    } catch {
      // 坏行只回 parse error（协议要求 id 为 null），不能让进程崩
      return rpcError(null, -32700, 'JSON 解析失败');
    }
    handle(msg).catch((e) => rpcError(msg.id ?? null, -32603, `内部错误：${e && e.message}`));
  });
  rl.on('close', () => process.exit(0));
}

if (require.main === module) main();
```

### package.json

```json
{
  "name": "jidan-forum-mcp",
  "version": "0.1.0",
  "private": true,
  "description": "鸡蛋论坛 MCP 服务：拉取帖子/通知、回复帖子（stdio，零依赖，包装线上 /api/v1）",
  "main": "server.js",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "start": "node server.js"
  }
}
```

## 第 4 步 · 把你自己的 MCP 配置接上这个服务

`<安装目录>` = 第 3 步写入文件的绝对路径（如 `~/forum-mcp` 展开后的 `/home/xxx/forum-mcp`）。

### 如果你在 ZCode 里

编辑 `~/.zcode/cli/config.json`（不存在就创建），在 `mcp.servers` 下**合并**加入（保留已有其他 server）：

```json
{
  "jidan-forum": {
    "command": "node",
    "args": ["<安装目录>/server.js"],
    "env": {
      "FORUM_BASE": "https://101.42.5.199:3012",
      "FORUM_TOKEN": "<第 2 步拿到的 token>"
    }
  }
}
```

保存后重启会话，或在 ZCode「设置 → MCP」里重连 `jidan-forum`。

### 如果你在其他支持 MCP 的客户端（Claude Desktop / Cline / Cursor 等）

把同样的对象放进其配置的 `mcpServers` 键下；各客户端配置文件路径不同，不确定就询问用户。
env 三个键的含义：`FORUM_BASE` 论坛地址、`FORUM_TOKEN` 会话凭证（推荐）。
也支持不给 token、改给 `FORUM_USER`/`FORUM_PASS`：服务会自动登录并把 token 缓存到
`~/.config/forum-mcp/token.json`（0600），过期自动重登。

## 第 5 步 · 验证与日常使用

1. 调用 `forum_whoami`——返回的用户名应是第 1 步用户的账号。不对就检查 env 配置。
2. 调用 `forum_feed`（limit=3）确认可读。
3. 提醒用户：`forum_reply_topic` 是**公开发帖**（限流 8 次/分钟）、`forum_mark_read`
   会改动他们的通知状态；执行前把要发的内容给用户过目。
4. 想浏览论坛直接说「拉一下最新帖子」即可；翻页用返回的 `meta.next_cursor`。

### 故障排查

| 现象 | 处理 |
| --- | --- |
| `NO_CREDENTIALS` | env 里没配 `FORUM_TOKEN`（或 USER/PASS），回到第 4 步 |
| `UNAUTHORIZED 用户名或密码错误`（自动重登也失败） | 密码错了或账号无密码，找用户核实 |
| token 原来好用突然 401 | 会话过期/在网页端登出吊销了 → 重跑第 2 步换发新 token |
| `NETWORK 无法连接论坛服务` | 先 `curl -s https://101.42.5.199:3012/api/v1/feed` 探活，再检查 `FORUM_BASE` |
| 回复被 `RATE_LIMITED` | 等一分钟，8 次/分钟是站内限流，不是故障 |
| fetch/`node:sqlite` 报错 | Node 版本 < 22，升级 Node |

### 反馈

服务有问题或有新需求（比如私信）：站内「问题反馈」页提交，或在站务公告区发帖 @烟火。

---

*文档版本：2026-10-13 · 若内容更新会在 URL 上换 `?v=` 参数；看到旧版先试着拉一次最新文档。*
