news 2026/10/21 3:11:18

从零搭建轻量论坛系统:基于Node.js与MySQL的Web全栈实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建轻量论坛系统:基于Node.js与MySQL的Web全栈实战

1. 为什么我从买现成方案改成了自己搭一套

去年年中接了一个小组内部需求,要给一帮做技术分享的人弄一个能发帖、能回帖的讨论区。当时我第一反应是图省事:去注册一个现成的论坛服务,或者直接在服务器上装个开源论坛系统,改改配置就能用。但真往下研究的时候,发现事情没有想象中那么简单。先说结论,我最后没有用任何现成方案,而是自己从零搭了一套轻量论坛系统,前后从设计到上线大概用了两个周末。

1.1 现成论坛和开源成品卡在哪

现成的托管论坛服务确实快,注册完选个模板就能用,连服务器都不用管。但问题也很扎眼:用户数据、帖子内容全部放在对方那里,哪天对方调整收费策略或者倒闭,数据导出很麻烦,就算导出也只是没格式的文本,论坛的版块结构、账号资料、历史动态基本等于作废。对我来说,这是完全不能接受的。

开源成品我也认真看过几套,功能确实全,但部署成本一点都不低。比较流行的几套论坛软件动辄要求小两G内存起步,背后还挂着一堆服务。对于只跑二三十个活跃用户的小讨论区来说,这个资源开销明显是浪费的。更麻烦的是二次开发,我想加一个“按标签过滤帖子”的小功能,都得翻很深的源码去理解它的扩展机制。有时候为了一个小改动,反而要学习一整套插件系统,这不合理。

1.2 自己搭一套的核心收益

自己动手搭一套,最大的收益是代码完全在自己的掌控范围里。用户注册、发帖、回帖这些核心功能加起来,设计得好一点,核心代码量其实没多少。整个系统的数据流是透明的:谁发了什么内容,存进了哪张表,取出来之后经过了哪些处理,每一步都清清楚楚。

另外一个重要收获是学习价值。一套论坛系统麻雀虽小五脏俱全:有用户认证、有内容管理、有权限控制、有数据库设计、有前端交互、有部署上线,走完这一整套流程,你对一个完整Web项目的理解会拉高一个档次。后来我给别人做内部管理系统的需求,很多思路都是从那套论坛里迁移过去的。所以这篇文章的重点不只是“搭一个论坛”,而是把搭建过程中每一次决策背后的逻辑讲清楚。

2. 技术栈定型和数据库设计:动手前必须想明白的两件事

动手写代码之前,我花了大概半天时间做选型。技术栈的选择直接决定了后面开发的爽度,这个阶段省下的思考时间,后面大概率会加倍还回来。

2.1 技术栈选型的取舍

先说后端。我选了Node.js加Express,理由很简单:这套东西轻量、生态成熟、资料多,而且我打算写接口给前端用,Express本身就足够灵活。如果你更熟悉Python,用FastAPI也是一样的效果,关键是不要把精力浪费在框架的学习上。

数据库我选的是MySQL。论坛的数据天然是关系型的:用户、版块、主题、回复,这些实体之间有明确的关联。用MySQL配合外键和索引,查询和维护都很直觉。NoSQL虽然灵活,但在统计帖子数和回帖数这种场景下反而不顺手,还得自己维护冗余字段。

前端用了Vue 3加Vite。这个选择没什么好纠结的,纯粹是因为我对Vue的响应式数据流比较熟,开发速度快。你要用React也完全可以,论坛这种页面结构并不依赖某个特定框架。

这里有个小建议:不要在这个阶段过度设计。微服务、消息队列、Redis缓存这些东西,对一个日活几十人的小论坛来说就是负担,先把单体的极简版本跑通,后面真的有流量压力再演进。

2.2 数据库表如何设计

论坛系统最少需要四张表:用户表、版块表、主题表、回复表。再多就属于业务需求,比如收藏、点赞、私信,那些可以后面加,初期别碰。

用户表存账号和密码哈希。密码绝对不存明文,我用的是bcrypt加盐哈希。主题表里冗余了版块ID和用户ID,回复表里冗余了主题ID和用户ID,这样查询某个主题下的所有回复只需要一次简单的索引扫描。

CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, avatar VARCHAR(255) DEFAULT '', created_at DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sections ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, description VARCHAR(255) DEFAULT '', sort_order INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE threads ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, section_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_section_updated (section_id, updated_at), CONSTRAINT fk_thread_section FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE, CONSTRAINT fk_thread_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, thread_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL, KEY idx_thread_created (thread_id, created_at), CONSTRAINT fk_post_thread FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE, CONSTRAINT fk_post_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有个细节必须提醒:所有表统一用utf8mb4字符集。MySQL的utf8其实只能存基本多语言平面字符,遇到特殊符号或者冷门汉字会报错或者存成问号。utf8mb4才是完整的UTF-8支持。建表时随手写一下,后面能避开很多中文乱码问题。

主题表和回复表的索引设计也不难理解:主题表里以版块ID和更新时间作为联合索引,查询某个版块的帖子列表按更新时间倒序排列时会很快;回复表里以主题ID和创建时间作为联合索引,查某个主题下的回复列表时会很快。小数据量的表建不建索引差别不大,但养成习惯,数据涨起来就不慌。

3. 后端API实现:注册、登录、发帖和回帖的完整链路

后端我采取了很传统的分层方式:路由层负责接收请求,中间件负责校验,数据访问直接走MySQL查询。先不引入复杂的ORM,直接用参数化查询语句,一方面是因为这个项目体量不需要,另一方面是手写SQL能保持对查询的完全控制。

3.1 JWT登录态是怎么工作的

登录态我用JWT,流程不复杂:用户注册成功后,密码被bcrypt加盐哈希存进数据库。用户登录时,后端把提交的密码和库里存的哈希做一次比对,匹配成功就签发一个有效期七天的token返回给前端。前端把token存在localStorage里,之后每次请求都带一个Authorization: Bearer <token>头。后端用一个中间件统一解析和校验这个token,校验通过就把用户ID放进请求对象里,后面的路由可以直接用。

这样做的好处是后端不需要维护session记录,多个进程之间共享登录态没有任何额外成本。代价是token在失效前无法被主动吊销,但论坛这种轻场景完全能接受。

以下是注册和登录的两个核心接口,我删掉了与业务无关的校验逻辑,保留主干。

const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); app.post('/api/auth/register', async (req, res, next) => { try { const { username, password } = req.body; if (!username || !password || password.length < 6) { return res.status(400).json({ code: 400, message: '用户名和密码格式不正确' }); } const hash = await bcrypt.hash(password, 10); const [result] = await db.query( 'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, NOW())', [username, hash] ); res.status(201).json({ code: 0, data: { id: result.insertId } }); } catch (err) { next(err); } }); app.post('/api/auth/login', async (req, res, next) => { try { const { username, password } = req.body; const [rows] = await db.query( 'SELECT id, username, password_hash FROM users WHERE username = ?', [username] ); if (!rows.length) return res.status(401).json({ code: 401, message: '用户名或密码错误' }); const matched = await bcrypt.compare(password, rows[0].password_hash); if (!matched) return res.status(401).json({ code: 401, message: '用户名或密码错误' }); const token = jwt.sign({ uid: rows[0].id, name: rows[0].username }, process.env.JWT_SECRET, { expiresIn: '7d' }); res.json({ code: 0, data: { token, user: { id: rows[0].id, username: rows[0].username } } }); } catch (err) { next(err); } });

3.2 核心中间件和发帖回帖路由

校验token的中间件是这样写的。逻辑很直接:从请求头取出token,解析成功就放行,失败就返回401。

function auth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return res.status(401).json({ code: 401, message: '请先登录' }); try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.user = payload; next(); } catch (e) { res.status(401).json({ code: 401, message: '登录态已失效,请重新登录' }); } }

发帖接口最需要注意的是参数安全。不能信任前端传来的任何字段,要自己判断版块是否存在、标题是否为空、内容是否超过长度。SQL全部用参数化查询,防止拼接出来的查询语句被注入。

app.post('/api/threads', auth, async (req, res, next) => { try { const { section_id, title, content } = req.body; if (!section_id || !title || !content) { return res.status(400).json({ code: 400, message: '缺少必要字段' }); } const [sections] = await db.query('SELECT id FROM sections WHERE id = ?', [section_id]); if (!sections.length) return res.status(404).json({ code: 404, message: '版块不存在' }); const [result] = await db.query( 'INSERT INTO threads (section_id, user_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())', [section_id, req.user.uid, title.trim(), content.trim()] ); res.status(201).json({ code: 0, data: { id: result.insertId } }); } catch (err) { next(err); } });

回帖接口和发帖类似,但有个地方值得单独说:回帖成功之后,我应该同时更新主题表的updated_at,让这个主题在列表里排到前面去。这两个操作存在一致性关系,所以需要放在同一个事务里。

app.post('/api/threads/:id/posts', auth, async (req, res, next) => { const connection = await db.getConnection(); try { const threadId = Number(req.params.id); const { content } = req.body; if (!content) return res.status(400).json({ code: 400, message: '内容不能为空' }); await connection.beginTransaction(); const [threads] = await connection.query('SELECT id FROM threads WHERE id = ? FOR UPDATE', [threadId]); if (!threads.length) { await connection.rollback(); return res.status(404).json({ code: 404, message: '主题不存在' }); } await connection.query( 'INSERT INTO posts (thread_id, user_id, content, created_at) VALUES (?, ?, ?, NOW())', [threadId, req.user.uid, content.trim()] ); await connection.query( 'UPDATE threads SET updated_at = NOW() WHERE id = ?', [threadId] ); await connection.commit(); res.status(201).json({ code: 0, data: { threadId } }); } catch (err) { await connection.rollback(); next(err); } finally { connection.release(); } });

SELECT ... FOR UPDATE这个加锁写法可能会让新人困惑,它的作用是锁住这一行,防止两个回帖同时触发两次更新导致更新丢失。对于论坛这种低频写入场景,这个做法足够稳,而且不会带来性能问题。

3.3 数据校验和错误处理的细节

写这套API的时候,我给自己定了一个原则:所有用户输入进入数据库之前,先过一道校验。校验分两层:字段格式校验和业务存在性校验。格式校验关注的是“有没有、长度对不对”,比如用户名不能为空、标题不能超过200字;业务校验关注的是“这个对象是否存在”,比如发帖的版块ID是否真实存在、回帖的主题ID是否有效。

错误处理上,我用了Express默认的错误捕获中间件,所有路由里的异常都通过next(err)抛给统一的处理器,避免每个接口都写一遍try-catch的返参格式。开发早期我没做这一步,结果就是不同接口的报错格式五花八门,前端处理起来很头疼。后来统一成{ code: 非0, message: '说明' }格式,问题彻底消失。

4. 前端页面搭建:版块、帖子列表与详情页的交互设计

前端部分我按页面来组织,一共四个视图:版块首页、帖子列表页、帖子详情页、登录注册页。不做复杂的全局状态管理,用一个极简的响应式对象存当前登录用户就够了。

4.1 Vue工程里怎么组织这些页面

Vue Router里配置四个路由,分别对应上面的视图。版块首页进来以后,向后端请求所有版块,展示成卡片区。点击某个版块,进入帖子列表页,按更新时间倒序显示这个版块下的主题列表。帖子详情页展示主楼内容和所有回复,以及一个最底部的回帖输入框。

页面之间需要共享的数据其实很少,主要就是用户信息。我用一个类似这样的小store去管理:

import { reactive, computed } from 'vue'; export const store = reactive({ token: localStorage.getItem('token') || '', user: JSON.parse(localStorage.getItem('user') || 'null') }); export const isLoggedIn = computed(() => !!store.token);

在这里我必须提醒一点:localStorage本身会被XSS攻击读取,把token放在里面其实有安全隐患。如果你的站点涉及支付等级的安全要求,建议换httpOnly Cookie方案。但对于内部论坛来说,localStorage是简单够用的方案。我在前端渲染用户内容时都会做转义,避免直接插入HTML。

4.2 请求封装和登录态保持

axios请求封装是整个前端工程里最容易被忽略、但是最值得写好的模块。我把所有请求都走同一个实例,请求拦截器自动带token,响应拦截器统一处理401。这样后面每个页面写接口时,只需要关心业务数据,不用每次都处理鉴权错误。

const http = axios.create({ baseURL: '/api', timeout: 10000 }); http.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); http.interceptors.response.use( (res) => res.data, (err) => { if (err.response && err.response.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/login'); } return Promise.reject(err); } );

路由守卫的逻辑也很简单:目标路由标记了requiresAuth,而本地没有token就直接跳到登录页。注意,守卫只做前置的粗筛,最终的保护还是靠后端接口返回401来兜底,前端跳转只是一种体验优化。

帖子列表的分页我用了很朴素的方式:前端每页加载20条,后端SQL里写LIMIT 20 OFFSET ?。数据量达到几千条以后,这个做法会有点慢,但配合idx_section_updated索引,日常体验完全没有问题。真要优化,可以换成基于游标的分页,但没必要在小论坛上提前做。

5. 部署上线:服务器环境、Nginx代理与HTTPS

代码写完只是完成了60%的工作,论坛要真正跑起来给别人用,部署环节的坑一点也不比写代码少。

5.1 环境初始化和进程守护

我用的是一台配置不高的普通云主机,装好Node.js和MySQL之后,先做两件基础工作:创建一个专门跑应用的系统用户,不直接用root启动Node进程;给MySQL创建一个只针对论坛数据库有权限的账号,不用root账号连接应用。

数据库初始化的时候,我把前面那套建表SQL导入,然后创建论坛专用的数据库账号。Node进程的启动不用裸node命令,我用进程守护工具pm2来管理,好处是进程崩溃能自动重启,还能限制内存。pm2的配置文件很简洁:

module.exports = { apps: [ { name: 'light-forum', script: 'src/server.js', instances: 2, exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 }, max_memory_restart: '300M' } ] };

instances: 2表示开两个Node进程,cluster模式相当于在进程层面做负载均衡。对小论坛来说,两个进程完全够用,同时还能利用多核CPU。

5.2 Nginx反向代理与静态资源加速

Node直接监听3000端口,但对外不能裸奔这个端口。我用Nginx做反向代理,统一入口80和443端口,再把前端构建出来的静态资源交给Nginx托管,Node只负责API请求。Nginx配置如下:

server { listen 80; server_name forum.example.com; client_max_body_size 10m; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } location /static/ { alias /var/www/light-forum/dist/static/; expires 30d; add_header Cache-Control "public"; } }

client_max_body_size这句很容易被漏掉,但真的很重要。我在调试头像上传功能时,发现图片稍微大一点,Nginx就直接返回413错误,排查了半天才发现是默认的上传体积限制只有1M。提前写清楚,后面少一次折腾。

HTTPS我直接用免费的证书自动申请工具搞定,配置好之后把80端口的HTTP请求重定向到443。这里多说一句:别在已经正式运营之后才想起HTTPS,浏览器会一直警告“不安全”,用户信任感瞬间掉一半。上线第一天就配上,以后没有迁移成本。

6. 实测踩坑实录:两个让我查了一晚上的问题

部署完成之后,我自己先做了一轮完整测试,结果连踩两个坑,都是那种表面现象明显、实际根因藏得很深的类型。单独拉出来写,是因为排查思路比答案本身更有价值。

6.1 中文发帖全部变成问号的排查过程

现象是我通过页面提交一条中文主题,列表里看到的是“?????”,直接在数据库里查记录,中文也能正常显示。这个线索其实很有用,它说明数据进入数据库这一步没问题,问题极大可能出在API层到数据库连接层之间的某个环节。

排查链路是这样走的:

  1. 先用curl手动模拟一次POST请求,提交中文标题,结果后端返回成功,但数据库里存的是问号。
  2. 查看数据库连接配置,连接串里没有显式指定字符集。MySQL默认连接字符集在某些环境下是latin1,中文就被转成了问号。
  3. 修改连接配置,在连接参数里加上charset=utf8mb4。
  4. 再次提交中文,数据库显示正常。

这里还有一层容易踩的坑:如果服务端中间件在解析JSON时没有正确声明UTF-8编码,前端传过来的中文在进入业务代码之前就已经变形了。所以这一类问题要分成两半排查:一半在应用层,一半在数据库连接层。我这次两个问题同时存在,修完连接串之后,又把应用中间件里的字符处理统一确认了一遍才放心。

检查项结果处理方式
数据库表字符集utf8mb4已确认
数据库连接字符集默认值,非utf8mb4连接串显式指定
服务端JSON解析字符集默认utf-8保持默认并确认

6.2 登录状态刷新就丢失的根因

第二个问题更隐蔽。登录成功之后页面能正常访问,但只要按一下F5,立刻跳回登录页。第一反应是token没存住,打开浏览器开发者工具看Application面板,localStorage里明明有token和user数据。

继续检查路由守卫逻辑,发现守卫里只判断了内存store中的store.token,也就是初始执行时从localStorage读了一次。刷新页面后,整个JavaScript重新执行,store.token会重新从localStorage读取,理论上不应该有问题。

真正的问题出在守卫内部的另一个判断:页面刷新后,store.user在Vue的reactive对象里被重新初始化成了null,而我的守卫里额外加了一个判断:如果!store.user也跳登录页。保存用户信息的key写的是user,但我在某个页面里却用JSON.parse(localStorage.getItem('userInfo'))去取,两个key不一致。刷新的时候取不到内容,user成了null,于是守卫认为没有登录。

修复方案很简单,统一key,并且在守卫里只依赖token,不依赖user对象。修完之后我额外叮嘱自己一句:复杂逻辑上线前,一定要用无痕窗口做一次完整走查,因为无痕窗口里localStorage是空的,能最大限度模拟真实新用户路径,很多刷新才暴露的问题都能提前被发现。

6.3 关于排查链路的一点经验

这两个问题都不难,但都很耗时间,核心原因是刚开始排查时没有把“数据从浏览器到数据库”这条完整链条拆开。后来我养成了一个习惯:遇到这种诡异问题,先列出所有可能引入乱码或丢失数据的环节,再逐个用最直接的方式验证。比如中文变问号,就看看请求头、服务端接收值、SQL入参、数据库存储值,每一步都打印出来看过,问题范围立刻缩小一大半。

另外,日志要带上下文。我早期打印日志经常只输出一个值,看不出是哪个请求、哪个用户的哪个操作,调试效率极低。后来统一格式为时间 + 请求ID + 接口路径 + 关键参数 + 错误信息,排查问题的时间至少缩短了三分之一。

最后

两个周末实测下来,自己搭一套论坛的收获比想象中大。除了得到一个能跑的讨论区,更重要的是把注册、鉴权、发帖、回帖、部署、排查这条全链路完整走了一遍。后来我再做其他技术需求时,很多思路都能从这套小系统里迁移过来。

如果你也想自己试一次,我的建议是从极简版开始,先把用户、版块、主题、回复这四个核心跑通,不要一上来就加点赞、收藏、私聊。数据库表和接口算好边界,后面扩展功能其实就是加表和加接口的事。等你跑通一遍,再回头看那些现成论坛系统,至少能看明白它们为什么这样设计。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 3:11:02

自采样压缩思维链:高效降低LLM推理延迟与成本

在LLM推理优化这个圈子里&#xff0c;“思维链压缩”最近确实是讨论热度很高的方向。模型推理能力越强&#xff0c;CoT&#xff08;思维链&#xff09;就越长&#xff0c;几百上千个token的推理过程越来越常见&#xff0c;随之而来的延迟和成本问题也越发刺眼。某高校团队这次做…

作者头像 李华
网站建设 2026/10/21 3:09:24

51单片机DS18B20温度采集与NRF905无线传输实战程序

简介&#xff1a;这是一套面向单片机爱好者、电子类专业学生与DIY玩家的温度采集与无线传输实践方案&#xff0c;基于51单片机搭配NRF905无线模块&#xff0c;解决短距离温度数据采集与无线收发的问题。压缩包共34个文件&#xff0c;约80KB&#xff0c;以C语言源码、头文件、Ke…

作者头像 李华
网站建设 2026/10/21 3:09:22

SpringBoot+Vue+MyBatis+MySQL实战:网上超市管理系统核心业务闭环

看到“网上超市管理系统”这个标题&#xff0c;多数人的第一反应是&#xff1a;又是一个商城项目。但如果把SpringBoot、Vue、MyBatis、MySQL这四个词放在一起看&#xff0c;它其实是在讲一件事——如何用一套主流Java技术栈&#xff0c;把一个涉及商品、购物车、订单、库存、后…

作者头像 李华
网站建设 2026/10/21 3:08:57

MySQL百万数据统计优化:Redis预计算实战,从27秒到250毫秒

百万数据统计在 MySQL 里能慢到什么程度&#xff1f;这次我是真真切切体会到了。当时我手头维护的是一个电商后台系统&#xff0c;里面有一张订单明细表&#xff0c;负责给运营看板提供数据。最开始表里也就三四十万行&#xff0c;统计接口基本秒开。可等量级爬到两百多万行之后…

作者头像 李华
网站建设 2026/10/21 3:08:56

AI自动生成SOP:视频+语音+LLM,让工艺文档从2天缩短到1小时

一份SOP从动笔到最终发布&#xff0c;传统做法要多久&#xff1f;我在某装备制造企业做工艺数字化的时候&#xff0c;团队统计过一个平均数&#xff1a;2天。这还只是顺顺利利不出幺蛾子的情况。半天去产线拍照片&#xff0c;半天整理动作拆步骤&#xff0c;半天写描述画流程图…

作者头像 李华
网站建设 2026/10/21 3:08:54

WordPress图片Alt标签优化:设置、批量补全与避坑全指南

做WordPress站点优化的时候&#xff0c;很多朋友最容易忽略的一个细节&#xff0c;就是图片的alt标签。我曾经帮一位做美食博客的朋友排查流量问题&#xff0c;文章写得扎实、配图也算精美&#xff0c;但搜索引擎图片搜索几乎不进流量。后来打开页面源码一看&#xff0c;好几篇…

作者头像 李华