简介这是一份面向大学计算机课程实践的C#五子棋完整项目源码包主打人机对战与局域网联机两种模式覆盖AI搜索、TCP/IP通信、数据库存取和界面交互等核心知识点适合学生做课程设计、期末项目或教师教学参考。zip压缩包内共200个文件主体是53个.cs源文件辅以33个.resx资源、30个.resources和47张.png图片另有9个dll、4个mp3背景音乐文件以及mdf/ldf数据库文件整体约53.01MB目录与工程结构完整可直接打开学习。该项目已有615人学习下载界面基于Windows Forms实现包含AI落子、悔棋、背景音乐、玩家战绩存储等功能并附可运行的exe和配置文件便于对照源码查看运行效果。对想快速理解五子棋从界面、逻辑到数据存储完整实现并在此基础上拓展AI难度或网络功能的学习者这份源码能提供清晰的模块划分、调用关系和排错思路。1. 很多人交上去的五子棋作业为什么一到答辩就被问倒“C# 五子棋大学项目”这类题目几乎是每个计算机专业学生绕不过去的课设。你很快就能做出一个能下棋的窗体黑白双方轮流点一点五子连珠就弹窗报赢。但真正到了答辩现场老师问一句“数据库表怎么设计的”“这局棋能回放吗”“换台电脑数据还在吗”很多人就卡住了。原因很简单大部分代码把棋盘存在窗体里的二维数组程序一关对局记录全部清零数据库工具只是建了个空库应付检查。这篇文不讲花哨界面只讲怎么把一个完整的 C# 五子棋项目做成“源码 数据库”都经得起问的课设三张表存对局每一步落子都落库支持悔棋、复盘、AI 自战验证。目标读者就是正在赶课设、想拿这个项目去答辩的人新手能照着做熟手也能看到边界条件。2. 先定数据边界把棋盘、对局与玩家拆成三张表2.1 数据库选型SQLite 还是 SQL Server课程设计怎么选大多数课程设计默认用 SQL Server因为老师熟悉也可以直接用 SSMS 看到表结构。但实际开发时我一般会劝你先想清楚答辩环境。SQL Server 需要安装服务、配置连接串、处理 Windows 认证很多学生在本机调通换到老师的演示电脑上就报“连接失败”。如果学校没有硬性规定数据库类型优先选 SQLite 更省事一个 .db 文件就能带走不需要额外服务代码里用 Microsoft.Data.Sqlite 这个 NuGet 包就能跑。如果答辩要求必须用 SQL Server那只需要把连接串换成Server.;DatabaseGobang;Trusted_ConnectionTrue;再把参数化查询里的前缀保留好下层数据访问代码基本可以复用。选型上还有一个容易被忽略的问题数据库文件放在哪。很多人直接把gobang.db放在项目根目录调试时没问题但程序运行时的工作目录其实是bin\Debug数据库和程序不在一块就出现“运行时报错找不到数据库文件”。解决方法是获取程序集所在目录再把数据库路径拼出来。这个坑在第 5 章专门讲。本章先用 SQLite 建库因为它是课程设计里最不容易翻车的方案也是“源码 数据库”里最直观能演示的实物。2.2 表结构与建库脚本落地的关键字段不只黑白棋一个合格的五子棋数据库至少要能回答三个问题玩家是谁、这局棋是何时下的、每一步落在哪个格子。如果只建一张表存“当前棋盘”那么复盘就无从谈起老师也会直接看出这个数据库是摆设。我常用的表结构是分成三张表players存玩家信息games存每一局对战的主记录moves存每一步落子。moves表是整个项目的核心它让“复盘”成为可能。下面是 SQLite 方言的建表脚本CREATE TABLE players ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now)) ); CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_black_id INTEGER NOT NULL, player_white_id INTEGER NOT NULL, board_size INTEGER NOT NULL DEFAULT 15, started_at TEXT NOT NULL, ended_at TEXT, result TEXT NOT NULL DEFAULT Ongoing, winner_color TEXT, FOREIGN KEY (player_black_id) REFERENCES players(id), FOREIGN KEY (player_white_id) REFERENCES players(id) ); CREATE TABLE moves ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL, move_no INTEGER NOT NULL, player_id INTEGER NOT NULL, color TEXT NOT NULL, row_no INTEGER NOT NULL, col_no INTEGER NOT NULL, move_time TEXT DEFAULT (datetime(now)), UNIQUE (game_id, move_no), FOREIGN KEY (game_id) REFERENCES games(id), FOREIGN KEY (player_id) REFERENCES players(id) );关键点在于moves表里game_id和move_no的联合唯一约束保证每一手棋在对局里有确定的序号不会出现两步棋都是第 1 手的情况。result字段用来存BlackWin、WhiteWin、Draw、Ongoing等状态ended_at在终局时更新。这样设计后一个玩家可以参加多局游戏同一局又能记录所有落子完全符合第三范式答辩时也容易解释。建库脚本可以保存成schema.sql放在项目里程序启动时执行一次保证全新环境下也能自动建表。我在DbInitializer里通常写一个EnsureCreated方法读入 SQL 文件并执行比用可视化工具手动建库更可靠也更容易被老师检查到“源码”。public static class DbInitializer { public static void EnsureCreated(SqliteConnection conn) { var sql File.ReadAllText(schema.sql); var cmd conn.CreateCommand(); cmd.CommandText sql; cmd.ExecuteNonQuery(); } }这段代码逻辑很直接程序启动时如果发现数据库文件不存在先创建空库再执行schema.sql里的建表语句。File.ReadAllText读取的是当前工作目录下的 SQL 文件所以你需要把schema.sql的文件属性设为“如果较新则复制”到输出目录否则换台电脑又会找不到表。2.3 连接串与数据访问层用 Dapper 还是 SqlClient数据库访问层课程设计用不着 Entity Framework那会让整个项目膨胀也不好解释。我一般直接用 ADO.NET 的SqliteCommand配合 Dapper 做封装写起来少查询也直观。先安装两个 NuGet 包Microsoft.Data.Sqlite和Dapper。建立连接的代码统一放在一个静态工厂里public static class DbFactory { private static readonly string BaseDir AppDomain.CurrentDomain.BaseDirectory; public static string DbPath Path.Combine(BaseDir, gobang.db); public static SqliteConnection OpenConnection() { var conn new SqliteConnection($Data Source{DbPath}); conn.Open(); using var cmd conn.CreateCommand(); cmd.CommandText PRAGMA foreign_keys ON;; cmd.ExecuteNonQuery(); return conn; } }连接串里的Data Source指向程序运行目录下的gobang.db参数PRAGMA foreign_keys ON是 SQLite 里启用外键约束的开关。SQLite 默认不强制外键如果不开启games表里存一个不存在的player_id也不会报错答辩时被问到数据完整性就会被扣分。用 Dapper 开始一局对战比如先把两个玩家插入players再插入gamespublic int StartGame(string blackName, string whiteName) { using var conn DbFactory.OpenConnection(); using var tx conn.BeginTransaction(); var blackId conn.ExecuteScalarint( INSERT INTO players (name) VALUES (name); SELECT last_insert_rowid();, new { name blackName }, tx); var whiteId conn.ExecuteScalarint( INSERT INTO players (name) VALUES (name); SELECT last_insert_rowid();, new { name whiteName }, tx); var gameId conn.ExecuteScalarint( INSERT INTO games (player_black_id, player_white_id, started_at) VALUES (b, w, datetime(now)); SELECT last_insert_rowid();, new { b blackId, w whiteId }, tx); tx.Commit(); return gameId; }这里最有必要说明的是事务两个玩家插入和游戏创建必须在一个事务里如果中间任何一步失败整个游戏记录都回滚不会出现只存了一个玩家名、游戏却建不起来的脏数据。last_insert_rowid()是 SQLite 获取自增主键的方法Dapper 会把返回的第一列转换为int。这段代码就是“把游戏流程接上数据库”的第一块地基。到这里数据库边界已经清楚玩家表、游戏表、落子表。后面的算法和界面最终都围绕这三张表做读写。3. 棋盘核心算法从落子判定到五子连珠检测3.1 用二维数组还是位棋盘15 路棋盘的数据结构选择五子棋游戏棋盘数据结构是核心。常见做法是用一个int[15,15]二维数组0 表示空1 表示黑子2 表示白子。为什么不推荐用 225 位组成的“位棋盘”位棋盘在专业 AI 里常用来做并行评分性能高但在课程设计里代码可读性和答辩讲解远比那点性能重要。二维数组索引直观board[7,7]就是正中间任何人看代码都懂。我通常会封装一个Board类把规则判断集中在一起避免窗体代码里到处操作数组public class Board { private readonly int[,] _grid; public int Size { get; } public const int Empty 0; public const int Black 1; public const int White 2; public Board(int size 15) { Size size; _grid new int[size, size]; } public bool InBounds(int row, int col) row 0 row Size col 0 col Size; public bool CanPlace(int row, int col) InBounds(row, col) _grid[row, col] Empty; public void Place(int row, int col, int color) { if (!CanPlace(row, col)) throw new InvalidOperationException(落子位置不合法); _grid[row, col] color; } public int GetCell(int row, int col) _grid[row, col]; }InBounds和CanPlace是两道防线。鼠标点击时PictureBox的坐标系可能算出负数坐标或者系统在棋盘边缘点到超出数组的位置CanPlace会先拦一道。真正落子时Place方法再校验一次防止调用方绕过界面直接写入。这是“防御式编程”在课程设计里最值得展示的地方答辩老师一看就知道你不是在裸写逻辑。3.2 落子合法性与五子连珠检测实现五子连珠检测是判断胜负的算法核心。常见做法是从最后落下的棋子出发沿四个方向水平、垂直、两条对角线分别向两边数相同颜色的连续棋子数累计 ≥5 就判胜。方向可以抽象出来private static readonly (int dr, int dc)[] Directions { (0, 1), // 水平 (1, 0), // 垂直 (1, 1), // 主对角线 (1, -1) // 副对角线 };对应的胜负判断方法如下public bool IsWin(int row, int col, int color) { foreach (var (dr, dc) in Directions) { int count 1; int r row dr; int c col dc; while (InBounds(r, c) _grid[r, c] color) { count; r dr; c dc; } r row - dr; c col - dc; while (InBounds(r, c) _grid[r, c] color) { count; r - dr; c - dc; } if (count 5) return true; } return false; }这段代码的逻辑是以当前落子为原点先向一个方向数遇到非本颜色棋子或出界就停再向反方向数两个方向数量之和加上自身就是连子总数。count 5判断的是“至少五连”也就是说如果出现六个或七个连续同色棋子也会被判定为胜。这和普遍认知里的五子棋规则一致也避免算“长连是否禁手”的争议。如果要做更严格的连珠规则就需要多判断一手第 3.3 节会展开。3.3 胜负判定的边界处理禁手、复盘、以及数据库落库时机课程设计里的五子棋默认规则通常是“无禁手”也就是黑白双方任何一方只要连成五个或更多就算赢。但答辩老师如果懂棋可能问“黑棋双三、长连算不算犯规”。这里要提前想清楚然后在代码里写注释说明“本项目采用无禁手规则符合普通五子棋大众规则专业连珠规则需要额外判断禁手。”如果确实想做禁手难点在于盘面扫描落一手黑棋后要检查以这个点为中心是否形成两个活三、或一个活四、或长连。这需要在四个方向上统计棋形比如数出“连续棋子数、两端是否有空位”来区分活三和冲四。代码量会翻倍而且很容易算错。我的建议是课设不要碰禁手把精力放在数据库和复盘上再加一个 AI 自战答辩的深度已经足够。你的项目说明里写清楚采用无禁手规则就没人能说这是缺陷。落库时机也是容易踩坑的地方。按什么顺序执行“落子、画棋、写数据库、判断胜负”我见过不少代码先更新界面再写数据库写库失败时界面已经显示棋子数据库里却没有这一手。正确顺序是先用CanPlace校验落子合法性然后调用Board.Place更新内存棋盘紧接着把这一手写入moves表写库成功后刷新界面最后调用IsWin判断这局是否结束。为什么先把内存棋盘更新了再落库如果先写库再更新内存棋盘而 UI 线程恰好在这中间抛异常内存棋盘和数据库又是一致性碎裂的状态。把Place和SubmitMove放在同一个业务流程里再加上第 4 章讲的事务就能保证如果写库失败内存棋盘也能回退。4. 把游戏流程接上数据库存档、复盘与悔棋的落库方案4.1 对局开始/结束时要写哪些记录对局开始第一步不是创建棋盘而是调用StartGame写入players和games。这个操作产生的gameId会贯穿整个对局后续每一步落子都必须带上它。终局时要更新games表的ended_at、result、winner_color否则数据库里永远是一堆“未结束”的脏数据复盘时也无法分辨哪些对局是完整的。终局写库方法public void FinishGame(int gameId, string result, string winnerColor) { using var conn DbFactory.OpenConnection(); using var tx conn.BeginTransaction(); conn.Execute( UPDATE games SET ended_at datetime(now), result result, winner_color winner WHERE id id, new { result, winner winnerColor, id gameId }, tx); tx.Commit(); }这里result可以是BlackWin、WhiteWin或DrawwinnerColor是B或W。注意Draw情况没有胜方winnerColor传null即可。什么时候算“和棋”如果棋盘下满了还没有五连我在IsBoardFull方法里检测所有格子是否都非空。很多课设不做和棋但老师追问时你还是要有这个分支。棋盘满了的判断很简单public bool IsBoardFull() { for (int r 0; r Size; r) for (int c 0; c Size; c) if (_grid[r, c] Empty) return false; return true; }在Place之后不用每次都扫描全盘只要该方没有IsWin再检查剩余空位数即可。当然这两个判断都只在落子后触发不会影响性能。4.2 悔棋、复盘与历史加载的实现命令悔棋不能只从内存棋盘拿走最后一颗子。如果数据库里没同步删除重新复盘就会出现最后一手凭空消失、数据库却还有记录的矛盾。正确策略是先删数据库里的最后一手再从数据库重新构建棋盘。这样即使内存状态乱了也能用数据把状态“洗”回来。先读取最后一手public Move GetLastMove(int gameId) { using var conn DbFactory.OpenConnection(); return conn.QueryFirstOrDefaultMove( SELECT * FROM moves WHERE game_id gameId ORDER BY move_no DESC LIMIT 1, new { gameId }); }再删除它public void UndoLastMove(int gameId) { using var conn DbFactory.OpenConnection(); using var tx conn.BeginTransaction(); var last GetLastMove(gameId); if (last null) return; conn.Execute( DELETE FROM moves WHERE game_id gameId AND move_no move_no, new { gameId, move_no last.MoveNo }, tx); tx.Commit(); }这个“先读后删”不能用DELETE ... ORDER BY move_no DESC LIMIT 1一步完成因为 SQLite 的DELETE不支持直接按子查询排序限制删除行。所以必须分两步先拿到move_no再按唯一约束删除。复盘功能用的是相反方向按move_no升序把所有的落子读出来然后在一个新棋盘上逐手Place绘制每一步的棋子。这样你就能支持老师现场点“下一步”“上一步”。核心加载命令public ListMove LoadMoves(int gameId) { using var conn DbFactory.OpenConnection(); return conn.QueryMove( SELECT * FROM moves WHERE game_id gameId ORDER BY move_no ASC, new { gameId }).AsList(); }注意排序一定要加ASC不要把顺序搞反。复盘时每加载一手就把对应的棋子画在棋盘上同时在界面上显示这是第几手、谁下的、落在哪个坐标。这些信息都是从moves表查出来的没有数据库根本做不了。4.3 数据库写入失败时的回退策略写库失败是课设里最常见的隐藏 bug。比如gobang.db文件被另一个进程占用或者磁盘只读写入抛出异常后界面会怎样如果异常没有被捕获程序直接崩溃如果异常被吞掉界面可能多了一颗数据库里不存在的棋子。所以每一步落子的完整流程必须是一个幂等的写库操作事务保证要么全部成功要么全部失败。下面是一个完整的SubmitMovepublic void SubmitMove(int gameId, int playerId, int row, int col, int color) { using var conn DbFactory.OpenConnection(); using var tx conn.BeginTransaction(); var moveNo conn.ExecuteScalarint( SELECT COALESCE(MAX(move_no), 0) 1 FROM moves WHERE game_id gameId, new { gameId }, tx); conn.Execute( INSERT INTO moves (game_id, move_no, player_id, color, row_no, col_no, move_time) VALUES (gameId, moveNo, playerId, color, row, col, datetime(now)), new { gameId, moveNo, playerId, color, row, col }, tx); tx.Commit(); }moveNo的值是从MAX(move_no)1算出来的而不是用自增主键id。原因在于id可能因为删除操作产生空洞比如悔棋删掉了第 5 手再落子时新的自增id是 8但这一手的move_no应该还是 6。用MAX(move_no)1计算保证棋谱序号始终连续。另外所有数据访问方法都要有统一的异常出口。界面调用SubmitMove时如果捕获到异常应该直接终止本次绘制并把Board.Place的棋子回滚回去。最简单的方法是在 UI 层先拿到当前棋盘引用调用Place前做一个Rollback快照或者按第 5 章的方式用lock串行化数据访问让失败概率降到最低。5. 课设答辩避坑数据库连接、并发与数据一致性常见问题排查5.1 本机跑得好好的换台电脑就报“数据库文件被占用”或“找不到驱动程序”现象在你电脑上运行正常用 U 盘把项目拷到答辩机器上双击运行程序直接弹“unable to open database file”或者找不到SQLite.Interop.dll。原因一是数据库路径用的是开发时的绝对路径换机器后Data SourceC:\Users\...\gobang.db自然不存在二是早期System.Data.SQLite依赖原生 DLL没有同时拷贝x64和x86文件夹运行时就会加载失败。解决数据库路径必须用AppDomain.CurrentDomain.BaseDirectory拼接也就是程序运行目录数据库文件放到项目里并把“复制到输出目录”设为“始终复制”。驱动方面建议使用Microsoft.Data.Sqlite它是纯托管实现不需要额外 DLL。路径统一用这一种写法public static string DbPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, gobang.db);这样只要你把bin下的整个发布目录拷走程序和数据天然在一起不需要改任何配置。答辩前一定要测试一次干净环境删掉bin和obj重新编译后直接运行不要依赖 Visual Studio 帮你指路。5.2 数据库里存的全是当前棋盘没有过程记录复盘只能看最后一手现象老师打开数据库文件发现里面有一张表存着当前棋盘每个格子一个字段关掉程序前最后一盘棋确实在。但是他想看看上一盘是怎么下的永远只有最终局面。原因你用的表结构是“棋盘快照”式的比如BoardState表每次落子都更新同一行根本没有按时间或序数保存过程。复盘需要的是“每一步棋子落点”的序列而不是某个时刻的静态画面。解决把表结构改成第 2 章的moves表每一手一行带有move_no。复盘就是按move_no顺序把落子重放一遍这个机制答辩时演示非常直观。如果在已经交了初版之后才意识到这个问题补救方案是先跑一局图片、文字描述当前存储方案外再补一个moves表并让新游戏都写入它。不过最好一开始就用过程表别走弯路。5.3 落子后界面能画出来数据库里却少一手先写库还是先画棋现象两个人快速对弈界面棋盘上棋子越来越多但查看moves表发现步数和界面棋子数对不上比如界面有 30 手库里只有 28 行。原因落子时先调用了board.Place和Invalidate()刷新界面然后才异步调用SubmitMove。异步写库一旦失败异常被 UI 线程吞掉或者异步方法还没执行完程序就继续响应下一次点击数据就丢了。解决把“写库”作为落子的前置条件写成功再更新界面。代码结构应该是try { _db.SubmitMove(_gameId, _currentPlayer.Id, targetRow, targetCol, currentColor); } catch (Exception ex) { MessageBox.Show(这一步落子失败请重试 ex.Message); return; } _board.Place(targetRow, targetCol, currentColor); DrawPiece(targetRow, targetCol, currentColor); if (_board.IsWin(targetRow, targetCol, currentColor)) { FinishGameAndShowResult(); }这样写界面永远和数据库一致即使系统崩溃moves表也保存了所有成功落下的棋子。需要注意的是不能把MessageBox放在一个循环里否则快速点击多个位置时会弹出多个窗口这里用一个_isProcessing标志位做重入保护。5.4 AI 对战算法在数据库里的死锁问题落子太快导致写库频繁现象你自己实现了一个简单的 AIAI 在后台线程计算并自动落子人机对战也正常。但某次 AI 落子后立刻点“悔棋”程序界面卡住几秒后弹出 SQLite 数据库被锁的异常。原因SQLite 的写入锁是数据库级别的一个写事务没提交时另一个写事务会等待。AI 线程和 UI 线程同时操作同一个SqliteConnection连接默认不共享两个连接之间互相锁冲突。解决所有数据库访问方法都收敛到同一个静态DbLock对象任何时刻只允许一个线程执行写操作。最简单的是在DbFactory里加一个锁private static readonly object DbLock new object(); public static T ExecuteInLockT(FuncSqliteConnection, T action) { lock (DbLock) { using var conn OpenConnection(); return action(conn); } }然后所有数据方法都改成ExecuteInLock调用模式return ExecuteInLock(c ...)。这样即使 AI 线程和 UI 线程同时落子只有一个线程能进入写事务另一个会等待不会死锁。这个锁放在进程内足够因为你的程序本身是单进程。如果用的是 SQL Server则不需要这个锁数据库自己会处理行锁。5.5 存档跨电脑打开不丢棋路径与事务缺一不可现象你在一台电脑上保存了十局对局把项目文件夹整个复制到另一台电脑打开历史记录列表有十局但点进去某些局只加载出前几步后面步骤丢失。原因复制文件时只拷贝了程序数据库文件因为“复制到输出目录”属性没设置依然是旧版本或者某些对局在未Commit时程序被强杀数据库文件里只有部分移动记录。SQLite 通过journal文件保证事务强杀进程可能导致未提交事务回滚但如果连接没正确释放也可能留下半写状态。解决数据库文件必须放进项目根目录并设置“复制到输出目录”为“始终复制”同时每个对局写入都用事务确保崩溃回滚不会留下半截记录。还有一个排查办法复制数据库前后对比文件大小十局对局的库通常几十 KB 到几百 KB如果还停留在初始的几 KB那一定是复制错了文件。答辩前做个“换机测试”把发布目录压缩解压到另一台电脑完整走一遍开局、落子、保存、复盘确认数据都正常再提交。6. 让项目更值得投入加一个能“复盘”的 AI 自战模式与验证方法课程设计做到“人人对战 数据库存档”已经是标准分但如果你想让答辩再多一个亮点可以加一个 AI 自战模式。这个模式不需要多聪明甚至不需要什么智能评估函数只要 AI 能随机或有简单策略地落子让黑白双方自动下完整局最后把整盘棋写进数据库。它的真正价值不是展示 AI 算法而是构建一个“自动生成测试数据”的机器用它来验证复盘功能是否可靠。AI 落子策略走最简单的权重法遍历所有空位优先选择距离已有棋子最近的空位再在候选里随机选一个。代码不长但足够让对局不出现离谱的跳变。核心是让 AI 每走一步都调用同一套SubmitMove这样数据库里自动积累完整的棋谱序列。一局结束后再从数据库LoadMoves重新构建棋盘与内存中的原始棋盘逐格比对var replayBoard new Board(15); foreach (var move in moves) { var color move.Color B ? Board.Black : Board.White; replayBoard.Place(move.Row, move.Col, color); } bool isMatch true; for (int r 0; r 15; r) for (int c 0; c 15; c) if (replayBoard.GetCell(r, c) ! actualBoard.GetCell(r, c)) isMatch false;这个验证的意义在于只要 AI 自战跑上几十局每一局都能完整重放你的数据库设计就真正经得起考验。我自己的习惯是每改一次落库逻辑先不手动点击下棋而是让 AI 自战跑 20 局跑完以后检查有没有哪局重放对不上。以前偷懒直接手动落几子就提交结果答辩演示复盘时发现有一手加载顺序错了当场很被动。从那以后凡是涉及数据库流程的功能我都会用自动对局去跑一遍。这个习惯不仅让项目更扎实也能让你在回答“数据怎么保证一致性”时有一个真实的方法论而不是嘴上说说。如果还有余力可以在复盘窗口上显示每一手的时间戳或者从games表统计黑方胜率。这些都是从现有数据库里直接查出来的不需要再建新表。最后建议把所有对局数据保留下来答辩时随便挑一局现场一步步回放比放 PPT 有说服力得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?