<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>RichZDS · 赛博终端</title>
<link>https://richzds.pages.dev/</link>
<description>不会编码的 Isaac 的个人博客：Go / Java / Vue / AI 应用的项目档案与学习笔记。</description>
<language>zh-CN</language>
<atom:link href="https://richzds.pages.dev/rss.xml" rel="self" type="application/rss+xml"/>
<lastBuildDate>Tue, 29 Sep 2026 10:58:27 GMT</lastBuildDate>
<item>
<title>系统上线：RICHZDS//NET 接入完成</title>
<link>https://richzds.pages.dev/posts/hello-world/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/hello-world/</guid>
<pubDate>Tue, 29 Sep 2026 10:00:00 GMT</pubDate>
<category>博客</category><category>前端</category><category>Cloudflare</category>
<description><![CDATA[这个博客怎么搭起来的：零依赖的静态生成器、赛博朋克皮肤、Cloudflare Pages 部署，以及接下来要写什么。]]></description>
<content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">SYS.NOTE // 注意</p><p>欢迎接入。按键盘左上角的 <kbd>`</kbd>（或 <kbd>Ctrl</kbd> + <kbd>K</kbd>）可以打开站内终端，输入 <code>help</code> 看看能做什么。</p></div>
<p>这里是 RichZDS 的个人博客。GitHub 上的签名是「最尊重模块化的选手」，所以这个站点本身也按模块拆开：内容、生成、样式、动效、部署，各管各的。</p>
<h2 id="这里会写什么">这里会写什么<a class="h-anchor" href="#这里会写什么" aria-label="链接到本节">#</a></h2>
<p>GitHub 上的项目大概分这几条线，博客也按这几条线来写：</p>
<div class="table-wrap"><table>
<thead><tr><th>方向</th><th>代表项目</th><th>关键词</th></tr></thead>
<tbody>
<tr><td>Go 后端</td><td><a href="/posts/trangle-agency-forum/">三角机构论坛</a>、<a href="/posts/go-lock-bootcamp/">Go 锁机制训练营</a></td><td>GoFrame、WebSocket、RabbitMQ、并发</td></tr>
<tr><td>Java 后端</td><td><a href="/posts/boss-recruitment-ai/">BOSS 招聘系统</a>、<a href="/posts/layered-architecture/">运动场馆管理系统</a></td><td>Spring Boot 3、MyBatis-Plus、分层</td></tr>
<tr><td>AI 应用</td><td><a href="/posts/zhiwen-ai-writer/">织文 · AI 全文生成</a>、BOSS 的简历优化</td><td>FastAPI、Spring AI、DeepSeek</td></tr>
<tr><td>学习笔记</td><td><a href="/posts/sdut-study-tips/">SDUT 学习小 tip</a></td><td>408、课程经验</td></tr>
</tbody>
</table></div>
<h2 id="这个站是怎么搭的">这个站是怎么搭的<a class="h-anchor" href="#这个站是怎么搭的" aria-label="链接到本节">#</a></h2>
<p>整个博客没有用框架，也没有 <code>node_modules</code>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">richzds-blog/
├─ content/posts/     # 文章：Markdown + frontmatter
├─ src/               # 生成器：Markdown 渲染、代码高亮、页面模板、GitHub 数据
├─ static/            # 样式、脚本、字体
├─ build.mjs          # node build.mjs → dist/
└─ scripts/deploy.sh  # 构建 + wrangler pages deploy</code></pre></figure>
<ul>
<li><strong>生成</strong>：<code>build.mjs</code> 读取 <code>content/posts/*.md</code>，用自己写的 Markdown 渲染器转成 HTML。代码高亮也在构建时做完，浏览器里不跑高亮脚本。</li>
<li><strong>数据</strong>：「项目矩阵」和「技能芯片」是构建时从 GitHub API 拉的，缓存在 <code>data/github.json</code>，12 小时内不重复请求。</li>
<li><strong>动效</strong>：数字雨是一张 <code>&lt;canvas&gt;</code>，切到后台会暂停；故障字、扫描线、霓虹闪烁全是 CSS；系统开了「减少动态效果」时会自动关掉。</li>
<li><strong>体积</strong>：两个字体子集加起来约 25 KB，CSS 和 JS 都是手写的，页面不加载任何第三方资源。</li>
</ul>
<h2 id="写一篇新文章">写一篇新文章<a class="h-anchor" href="#写一篇新文章" aria-label="链接到本节">#</a></h2>
<p>在 <code>content/posts/</code> 下新建一个 <code>.md</code> 文件：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">MARKDOWN</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-markdown">---
title: 文章标题
date: 2026-10-01 20:00
tags: [Go, 并发]
summary: 一句话摘要，会出现在卡片和 RSS 里
---

正文从这里开始。</code></pre></figure>
<p>然后：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">BASH</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-bash"><span class="t-cmd">npm</span> run dev       <span class="t-com"># 本地预览 http://localhost:4173，改了文件自动重建</span>
<span class="t-cmd">npm</span> run deploy    <span class="t-com"># 构建并发布到 Cloudflare Pages</span></code></pre></figure>
<h2 id="一些彩蛋">一些彩蛋<a class="h-anchor" href="#一些彩蛋" aria-label="链接到本节">#</a></h2>
<ul>
<li>终端里试试 <code>neofetch</code>、<code>theme pink</code>、<code>rain off</code>；</li>
<li>在页面上（不是终端里）按一遍 ↑↑↓↓←→←→BA；</li>
<li>首页天际线上那块竖着的霓虹招牌，写的就是这个站的主题。</li>
</ul>
<hr>
<p>接下来几篇会把 GitHub 上的项目挨个拆开讲。发现 bug 或者有想法，欢迎到 <a href="https://github.com/RichZDS" target="_blank" rel="noopener">GitHub</a> 提 issue。</p>]]></content:encoded>
</item>
<item>
<title>三角机构论坛：GoFrame + Vue 3 的 TRPG 社区全栈拆解</title>
<link>https://richzds.pages.dev/posts/trangle-agency-forum/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/trangle-agency-forum/</guid>
<pubDate>Tue, 29 Sep 2026 09:00:00 GMT</pubDate>
<category>Go</category><category>GoFrame</category><category>Vue</category><category>WebSocket</category><category>RabbitMQ</category><category>Docker</category>
<description><![CDATA[给《三角机构》TRPG 做的社区：论坛、多人聊天室、分部与收容库、文件上传。拆开看 GoFrame 分层、RabbitMQ 广播的聊天室和 Docker Compose 一键部署。]]></description>
<content:encoded><![CDATA[<p>《三角机构》是一款 TRPG（桌上角色扮演游戏）。跑团需要的东西很杂：规则讨论、角色卡、团内聊天、资料分享。<a href="https://github.com/RichZDS/Trangleagent" target="_blank" rel="noopener">Trangleagent</a> 把这些做进了一个站里：后端 Go，前端 Vue 3，整套服务用 Docker Compose 拉起来。</p>
<h2 id="总览">总览<a class="h-anchor" href="#总览" aria-label="链接到本节">#</a></h2>
<div class="table-wrap"><table>
<thead><tr><th>服务</th><th>端口</th><th>作用</th></tr></thead>
<tbody>
<tr><td>frontend</td><td>80</td><td>Nginx 托管前端静态文件，同时反代 API</td></tr>
<tr><td>backend</td><td>8888</td><td>GoFrame 后端</td></tr>
<tr><td>mysql</td><td>3306</td><td>业务数据</td></tr>
<tr><td>minio</td><td>9000 / 9001</td><td>对象存储（API / 控制台）</td></tr>
<tr><td>rabbitmq</td><td>5672 / 15672</td><td>聊天消息广播（AMQP / 管理界面）</td></tr>
</tbody>
</table></div>
<p>后端 <code>go.mod</code> 里的主要依赖：</p>
<ul>
<li><code>gogf/gf/v2</code> v2.9.5：Web 框架，自带 ORM、配置、日志和代码生成；</li>
<li><code>minio-go/v7</code>：上传文件到 MinIO；</li>
<li><code>rabbitmq/amqp091-go</code>：连接 RabbitMQ；</li>
<li><code>patrickmn/go-cache</code>：进程内缓存；</li>
<li><code>jordan-wright/email</code>：发邮件；</li>
<li><code>brianvoe/gofakeit/v6</code>：<code>cmd/seeder</code> 用它造测试数据。</li>
</ul>
<p>前端是 Vue 3 + Vite + Ant Design Vue，帖子编辑器用的是 <code>md-editor-v3</code>，请求代码由 <code>npm run swagger:generate</code>（<code>zerone api</code>）根据接口文档生成。</p>
<h2 id="goframe-的分层">GoFrame 的分层<a class="h-anchor" href="#goframe-的分层" aria-label="链接到本节">#</a></h2>
<p>GoFrame 的单仓库模板把一个接口拆成几层，这个项目基本照着来：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">Backend/
├─ api/            # 请求 / 响应结构体，按模块和版本分：api/forum/v1/...
├─ internal/
│  ├─ controller/  # 接收请求，调用 service
│  ├─ service/     # 接口定义
│  ├─ logic/       # 接口实现：Forum / chat / room / user / trace ...
│  ├─ dao/         # 数据访问，由 gf gen dao 生成
│  └─ model/       # 数据库实体、业务模型
└─ manifest/       # 配置、SQL、Docker、部署清单</code></pre></figure>
<p><code>logic</code> 里的每个模块在 <code>init()</code> 里把自己注册到 <code>service</code>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">GO</span><span class="code-title">room.go</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-go"><span class="t-kw">type</span> sRoom <span class="t-kw">struct</span>{}

<span class="t-kw">func</span> <span class="t-fn">New</span>() *sRoom {
	<span class="t-kw">return</span> &amp;sRoom{}
}

<span class="t-kw">func</span> <span class="t-fn">init</span>() {
	service.<span class="t-fn">RegisterRoom</span>(<span class="t-fn">New</span>())
}</code></pre></figure>
<p>这样 <strong>controller 只依赖 service 接口</strong>，不关心实现在哪。要换实现、写测试，只动一层。</p>
<p>业务模块基本对应游戏里的概念：</p>
<ul>
<li><strong>forum</strong>：版块、帖子、评论；</li>
<li><strong>room</strong>：跑团房间，配合下面的 WebSocket 聊天室；</li>
<li><strong>department</strong>：三角机构的分部，记录分部名称、经理、地址、散逸端数量，还有一个「天气」字段；</li>
<li><strong>containment</strong>：收容库，记录异常体、负责的特工、所属散逸端；</li>
<li><strong>trace</strong>：按用户、角色、部门查询红 / 黄 / 蓝三条轨迹。</li>
</ul>
<h2 id="多人聊天室websocket-rabbitmq">多人聊天室：WebSocket + RabbitMQ<a class="h-anchor" href="#多人聊天室websocket-rabbitmq" aria-label="链接到本节">#</a></h2>
<p>聊天室的设计文档（<code>Backend/docs/CHAT_WEBSOCKET.md</code>）写得很清楚：</p>
<ol>
<li>前端连接 <code>ws://host/ws/chat</code>，可以用 <code>?token=xxx</code> 带上 JWT；</li>
<li>客户端发送 <code>join</code> / <code>leave</code> / <code>chat</code> 三种消息，带上 <code>roomId</code>、<code>userId</code>、<code>nickname</code>、<code>roleName</code>；</li>
<li>后端把消息发布到 RabbitMQ 的 <code>chatroom_broadcast</code> <strong>fanout 交换器</strong>；</li>
<li>每个后端实例绑定一条独立队列，消费到消息后，推给<strong>本实例</strong>里在这个房间的 WebSocket 连接。</li>
</ol>
<p>消息格式：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JSON</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-json">{
  <span class="t-prop">"roomId"</span>: <span class="t-str">"1"</span>,
  <span class="t-prop">"userId"</span>: <span class="t-str">"123"</span>,
  <span class="t-prop">"nickname"</span>: <span class="t-str">"用户昵称"</span>,
  <span class="t-prop">"roleName"</span>: <span class="t-str">"角色名"</span>,
  <span class="t-prop">"message"</span>: <span class="t-str">"消息内容"</span>,
  <span class="t-prop">"type"</span>: <span class="t-str">"join|leave|chat"</span>,
  <span class="t-prop">"time"</span>: <span class="t-num">1710000000000</span>
}</code></pre></figure>
<p>为什么要绕一圈 RabbitMQ？因为 WebSocket 连接是<strong>有状态</strong>的。用户 A 连在实例 1，用户 B 连在实例 2，实例 1 收到 A 的消息，在本地找不到 B。fanout 交换器把每条消息复制给所有实例，每个实例只负责推给自己手上的连接，后端就能水平扩展了。</p>
<div class="callout callout-tip"><p class="callout-title">SYS.TIP // 提示</p><p>文档里还有一句：RabbitMQ 没配置或者连不上时，<strong>自动降级为单实例本地广播</strong>。本地开发不装 MQ 也能跑，这个降级很实用。</p></div>
<h2 id="骰子与三重升华">骰子与「三重升华」<a class="h-anchor" href="#骰子与三重升华" aria-label="链接到本节">#</a></h2>
<p><code>Backend/utils/Dice.go</code> 支持 4、8、10、12、20、24、30、100 面骰。另一个函数 <code>Chaos</code> 把不是 3 的点数加起来，恰好出现三个 3 时直接返回 0，注释写的是「三重升华」：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">GO</span><span class="code-title">Dice.go</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-go"><span class="t-kw">func</span> <span class="t-fn">Chaos</span>(dice []int) (sum int) {
	<span class="t-com">// 如果 dice 里面有三个三，sum 为 0</span>
	trible := <span class="t-num">0</span>
	<span class="t-kw">for</span> i := <span class="t-num">0</span>; i &lt; <span class="t-fn">len</span>(dice); i++ {
		<span class="t-kw">if</span> dice[i] != <span class="t-num">3</span> {
			sum += dice[i]
		}
		<span class="t-kw">if</span> dice[i] == <span class="t-num">3</span> {
			trible++
		}
	}
	<span class="t-kw">if</span> trible == <span class="t-num">3</span> { <span class="t-com">// 三重升华</span>
		<span class="t-kw">return</span> <span class="t-num">0</span>
	}
	<span class="t-kw">return</span> sum
}</code></pre></figure>
<p>顺手记一个可以改进的地方：<code>Dice</code> 每次调用都会执行 <code>rand.Seed(time.Now().UnixNano())</code>。Go 1.20 起 <code>rand.Seed</code> 已经弃用，全局随机源在启动时会自动播种。项目用的是 Go 1.25，可以直接删掉这一行，或者换成 <code>math/rand/v2</code>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">GO</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-go"><span class="t-kw">import</span> <span class="t-str">"math/rand/v2"</span>

<span class="t-kw">func</span> <span class="t-fn">Dice</span>(num, side int) []int {
	result := <span class="t-fn">make</span>([]int, num)
	<span class="t-kw">for</span> i := <span class="t-kw">range</span> result {
		result[i] = rand.<span class="t-fn">IntN</span>(side) + <span class="t-num">1</span>
	}
	<span class="t-kw">return</span> result
}</code></pre></figure>
<h2 id="文件上传minio">文件上传：MinIO<a class="h-anchor" href="#文件上传minio" aria-label="链接到本节">#</a></h2>
<p>帖子图片和资料文件存在 MinIO，配置在 <code>manifest/config/config.yaml</code>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">YAML</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-yaml"><span class="t-prop">minio</span>:
  <span class="t-prop">endpoint</span>: <span class="t-str">"localhost:9000"</span>
  <span class="t-prop">accessKey</span>: <span class="t-str">"minioadmin"</span>
  <span class="t-prop">secretKey</span>: <span class="t-str">"minioadmin"</span>
  <span class="t-prop">bucket</span>: <span class="t-str">"trangleagent"</span>
  <span class="t-prop">useSSL</span>: <span class="t-lit">false</span></code></pre></figure>
<p>图片要能被直接访问，需要给 bucket 配一条只读的公开策略（只放开 <code>s3:GetObject</code>）。</p>
<div class="callout callout-warning"><p class="callout-title">SYS.WARN // 警告</p><p><code>minioadmin / minioadmin</code>、<code>guest / guest</code> 都是默认账号，只适合本地开发。部署到公网前一定要改掉，也别把真实配置提交进仓库。</p></div>
<h2 id="一键部署">一键部署<a class="h-anchor" href="#一键部署" aria-label="链接到本节">#</a></h2>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">BASH</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-bash"><span class="t-com"># 构建并启动所有服务</span>
<span class="t-cmd">docker</span> compose up <span class="t-flag">-d</span> <span class="t-flag">--build</span>

<span class="t-com"># 看后端日志</span>
<span class="t-cmd">docker</span> compose logs <span class="t-flag">-f</span> backend</code></pre></figure>
<p>第一次启动时，MySQL 会自动执行 <code>Backend/manifest/sql/</code> 下的初始化脚本；MinIO 要先在控制台建好 <code>trangleagent</code> bucket；注册第一个用户就能登录。</p>
<h2 id="小结">小结<a class="h-anchor" href="#小结" aria-label="链接到本节">#</a></h2>
<ul>
<li>GoFrame 的 <code>api → controller → service → logic → dao</code> 让每层只关心一件事；</li>
<li>WebSocket 有状态，多实例广播交给 RabbitMQ 的 fanout 交换器；</li>
<li>依赖组件（MQ、对象存储）都有降级或默认配置，本地开发成本低；</li>
<li>骰子这类小工具函数值得补单元测试，顺便把弃用的 API 换掉。</li>
</ul>
<p>前端的早期版本在 <a href="https://github.com/RichZDS/TrangleAgentWeb" target="_blank" rel="noopener">TrangleAgentWeb</a> 仓库。</p>]]></content:encoded>
</item>
<item>
<title>Go 锁机制情景训练营：12 关，从 goroutine 打到 CAS</title>
<link>https://richzds.pages.dev/posts/go-lock-bootcamp/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/go-lock-bootcamp/</guid>
<pubDate>Tue, 29 Sep 2026 08:00:00 GMT</pubDate>
<category>Go</category><category>并发</category><category>锁</category><category>数据竞争</category>
<description><![CDATA[一个没有参考答案的 Go 并发练习仓库：每关一个生活化场景，先写预测、再运行、再改代码，最后用 -race 验收。]]></description>
<content:encoded><![CDATA[<p>学并发最怕「看懂了」：读完 <code>sync.Mutex</code> 的文档觉得很简单，一写就出数据竞争。<a href="https://github.com/RichZDS/golangexe" target="_blank" rel="noopener">golangexe</a> 换了个练法：<strong>先预测，再运行；先自己改，再要提示</strong>。仓库里没有参考答案。</p>
<h2 id="玩法">玩法<a class="h-anchor" href="#玩法" aria-label="链接到本节">#</a></h2>
<p>每一关是一个目录，里面有 <code>README.md</code>（场景和任务）、<code>main.go</code>（有问题的代码）和 <code>main_test.go</code>（验收测试）：</p>
<ol>
<li>按顺序进一关，读 README；</li>
<li><strong>运行前先写下预测</strong>：结果对不对？每次都一样吗？</li>
<li>从项目根目录运行；</li>
<li>只改 <code>main.go</code>，不许改测试来绕过要求；</li>
<li>普通测试和竞态检测都要通过。</li>
</ol>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">BASH</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-bash"><span class="t-cmd">go</span> run ./level01
<span class="t-cmd">go</span> run <span class="t-flag">-race</span> ./level01
<span class="t-cmd">go</span> test <span class="t-flag">-v</span> ./level01
<span class="t-cmd">go</span> test <span class="t-flag">-race</span> <span class="t-flag">-v</span> ./level01</code></pre></figure>
<p>求助也分了三档：「给我第 N 关一个提示」只给方向；「讲讲第 N 关的思路」解释机制但不给代码；「解答第 N 关」才给完整方案。拿去配合 AI 助手用正合适：想让它帮忙，又不想被剧透。</p>
<h2 id="关卡地图">关卡地图<a class="h-anchor" href="#关卡地图" aria-label="链接到本节">#</a></h2>
<div class="table-wrap"><table>
<thead><tr><th>关卡</th><th>场景</th><th>知识点</th></tr></thead>
<tbody>
<tr><td>01</td><td>忙碌的咖啡店</td><td>goroutine 与调度</td></tr>
<tr><td>02</td><td>搬运队协作</td><td>WaitGroup 与 channel</td></tr>
<tr><td>03</td><td>游戏积分榜</td><td>数据竞争与 Mutex</td></tr>
<tr><td>04</td><td>公共打印机</td><td>defer 解锁与锁范围</td></tr>
<tr><td>05</td><td>银行账户</td><td>保护数据不变量</td></tr>
<tr><td>06</td><td>热门文章服务</td><td>RWMutex</td></tr>
<tr><td>07</td><td>账户互转</td><td>死锁与锁顺序</td></tr>
<tr><td>08</td><td>仓库取货</td><td>sync.Cond</td></tr>
<tr><td>09</td><td>配置中心启动</td><td>sync.Once</td></tr>
<tr><td>10</td><td>高并发计数服务</td><td>锁竞争与分片锁</td></tr>
<tr><td>11</td><td>限量商品抢购</td><td>atomic 与 CAS</td></tr>
<tr><td>12</td><td>演唱会抢票系统</td><td>综合实战</td></tr>
</tbody>
</table></div>
<p>注意第 06 和第 10 关：starter 代码的测试一开始就是绿的。06 关还要你人工观察读多写少的场景并改进，10 关要实现优化并用多轮 benchmark 对比。<strong>测试通过不等于学完了。</strong></p>
<h2 id="挑两关细看">挑两关细看<a class="h-anchor" href="#挑两关细看" aria-label="链接到本节">#</a></h2>
<h3 id="level-03少掉的积分">Level 03：少掉的积分<a class="h-anchor" href="#level-03少掉的积分" aria-label="链接到本节">#</a></h3>
<p>20 名计分工人同时处理得分事件，每人登记 5,000 次一分，理论总分 100,000，但程序时不时会少分。</p>
<p>问题出在 <code>score += points</code> 不是一步完成的，而是<strong>读取、计算、写回</strong>三步。两个 goroutine 交错执行时：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">工人 A 读到 score = 41     工人 B 读到 score = 41
工人 A 算出 42             工人 B 算出 42
工人 A 写回 42             工人 B 写回 42   ← 丢了一分</code></pre></figure>
<p>这就是「丢失更新」。关卡 README 里有一句值得反复读：</p>
<blockquote><p>普通运行偶尔得到正确分数并不能证明安全；反过来，错误总分展示了结果问题，却不能替代竞态检测。</p></blockquote>
<p>所以验收标准是两条同时满足：结果精确等于 100,000，<strong>并且</strong> <code>go test -race</code> 没有报告。也不能靠减少工人或者改成串行来「修好」，要找出最小的正确临界区。</p>
<h3 id="level-07相反方向的转账">Level 07：相反方向的转账<a class="h-anchor" href="#level-07相反方向的转账" aria-label="链接到本节">#</a></h3>
<p>A 转 B、B 转 A 几乎同时发生。代码先锁付款方、再锁收款方，于是两个 goroutine 各拿一把锁、互相等对方，死锁了。</p>
<p>死锁要同时满足四个条件：<strong>互斥、持有并等待、不可抢占、循环等待</strong>。前三个是锁本身的性质，能拆掉的只有「循环等待」。这一关的要求是：</p>
<ul>
<li>不按「付款方 / 收款方」的角色决定加锁顺序，而是按<strong>系统内唯一、不可变的账户 ID</strong> 建立全局顺序；</li>
<li>拿到两把锁之后按业务方向改余额，再按加锁的相反顺序解锁；</li>
<li>自己转给自己要单独处理：Go 的 <code>sync.Mutex</code> <strong>不可重入</strong>，同一把锁锁两次会把自己卡死。</li>
</ul>
<p>测试里设了 500 毫秒超时，失败时会明确报超时，而不是让整个测试进程挂住。两把锁之间那 20 毫秒的暂停只是为了稳定复现危险的交错，它不是根因，也不能靠删掉它来「修复」。</p>
<h2 id="为什么值得这么练">为什么值得这么练<a class="h-anchor" href="#为什么值得这么练" aria-label="链接到本节">#</a></h2>
<ul>
<li><strong>先预测</strong>，逼你在脑子里把调度过一遍，比直接看答案记得牢；</li>
<li><strong><code>-race</code></strong> 把「偶尔出错」变成「每次都报错」，这是 Go 并发最好用的工具；</li>
<li><strong>不给答案、分级提示</strong>，卡住本身就成了学习的一部分。</li>
</ul>
<p>仓库地址：<a href="https://github.com/RichZDS/golangexe" target="_blank" rel="noopener">RichZDS/golangexe</a>。建议从第一关开始，别跳关。</p>]]></content:encoded>
</item>
<item>
<title>BOSS 招聘系统：Spring Boot 3 + Vue 3，再接一个 DeepSeek 简历优化</title>
<link>https://richzds.pages.dev/posts/boss-recruitment-ai/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/boss-recruitment-ai/</guid>
<pubDate>Tue, 29 Sep 2026 07:00:00 GMT</pubDate>
<category>Java</category><category>Spring Boot</category><category>Vue</category><category>Spring AI</category><category>DeepSeek</category>
<description><![CDATA[求职者、Boss、管理员三种角色的招聘系统：岗位、简历、投递、面试全流程，用注解 + AOP 做权限，最后用 Spring AI 接 DeepSeek 做简历优化。]]></description>
<content:encoded><![CDATA[<p><a href="https://github.com/RichZDS/BOSS" target="_blank" rel="noopener">BOSS</a> 是一个招聘系统：Boss 发岗位，求职者投简历，Boss 处理投递、安排面试，管理员兜底。最后一次提交是 <code>feat(ai): 集成DeepSeek AI优化简历功能</code>，所以这篇从业务流程讲到 AI 接入。</p>
<h2 id="技术栈">技术栈<a class="h-anchor" href="#技术栈" aria-label="链接到本节">#</a></h2>
<div class="table-wrap"><table>
<thead><tr><th>层</th><th>选型</th></tr></thead>
<tbody>
<tr><td>后端框架</td><td>Spring Boot 3.2.5</td></tr>
<tr><td>数据访问</td><td>MyBatis-Plus 3.5.9（带代码生成器）+ MySQL</td></tr>
<tr><td>会话</td><td>Spring Session + Redis</td></tr>
<tr><td>接口文档</td><td>Knife4j（OpenAPI 3）</td></tr>
<tr><td>文件存储</td><td>腾讯云 COS（简历附件）</td></tr>
<tr><td>AI</td><td>Spring AI 的 DeepSeek starter 1.0.0</td></tr>
<tr><td>前端</td><td>Vue 3 + Vite + TypeScript + Pinia + Ant Design Vue</td></tr>
<tr><td>前端请求</td><td><code>@umijs/openapi</code> 根据后端文档生成接口代码</td></tr>
</tbody>
</table></div>
<h2 id="三种角色一条流水线">三种角色，一条流水线<a class="h-anchor" href="#三种角色一条流水线" aria-label="链接到本节">#</a></h2>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">求职者：注册 → 写简历 → 浏览岗位 → 投递 → 查看进度 → 面试
Boss  ：维护公司 → 发布岗位 → 处理投递（接受 / 拒绝 / 待定）→ 安排面试
管理员：用户管理 → 全局投递管理</code></pre></figure>
<p>数据库里对应七张核心表：<code>user</code>、<code>company</code>、<code>job_posting</code>、<code>resume</code>、<code>application</code>（投递）、<code>boss_application_decision</code>（投递处理）、<code>interview</code>（面试）。前端路由也按角色分：<code>/user/resumes</code>、<code>/user/applications</code> 是求职者的，<code>/boss/company</code>、<code>/boss/applications</code> 是 Boss 的，<code>/admin/applications</code> 是管理员的。</p>
<h2 id="权限一个注解-一个切面">权限：一个注解 + 一个切面<a class="h-anchor" href="#权限一个注解-一个切面" aria-label="链接到本节">#</a></h2>
<p>接口上标一个 <code>@AuthCheck</code>，写明需要的角色：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-meta">@Target</span>(<span class="t-type">ElementType</span>.<span class="t-type">METHOD</span>)
<span class="t-meta">@Retention</span>(<span class="t-type">RetentionPolicy</span>.<span class="t-type">RUNTIME</span>)
<span class="t-kw">public</span> <span class="t-meta">@interface</span> <span class="t-type">AuthCheck</span> {
    <span class="t-type">String</span> <span class="t-fn">mustRole</span>() <span class="t-kw">default</span> <span class="t-str">""</span>;
}</code></pre></figure>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-meta">@AuthCheck</span>(mustRole = <span class="t-type">UserConstant</span>.<span class="t-type">ADMIN_ROLE</span>)</code></pre></figure>
<p><code>AuthInterceptor</code> 用 <code>@Around(&quot;@annotation(authCheck)&quot;)</code> 拦截这些方法：取当前登录用户，被封禁（<code>status == 0</code>）的直接拒绝，管理员直接放行，需要 Boss 权限的接口只放 Boss 进去。</p>
<p>权限逻辑集中在一个地方，controller 里一行注解就够了，这就是切面的价值。</p>
<h2 id="ai-简历优化">AI 简历优化<a class="h-anchor" href="#ai-简历优化" aria-label="链接到本节">#</a></h2>
<p><code>ResumeController</code> 里有一个 <code>POST .../ai/optimize</code> 接口，交给 <code>AiServiceImpl</code> 处理。它用 Spring AI 的 <code>ChatClient</code> 包了一层 DeepSeek：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-type">ChatClient</span> chatClient = <span class="t-type">ChatClient</span>.<span class="t-fn">builder</span>(deepSeekChatModel).<span class="t-fn">build</span>();
<span class="t-type">String</span> response = chatClient.<span class="t-fn">prompt</span>()
        .<span class="t-fn">system</span>(<span class="t-type">SYSTEM_PROMPT</span>)
        .<span class="t-fn">user</span>(userMessage)
        .<span class="t-fn">call</span>()
        .<span class="t-fn">content</span>();</code></pre></figure>
<p>系统提示词把模型设定成「有 10 年以上招聘经验的简历分析师」，给了三条优化原则：</p>
<ol>
<li>标题简洁有力，突出核心职位和关键技能；</li>
<li>个人摘要控制在 100–200 字，突出核心竞争力；</li>
<li>详细内容用 <strong>STAR 法则</strong>（情境、任务、行动、结果）描述经历，尽量量化。</li>
</ol>
<p>还特别要求「保持内容真实，在原有基础上润色」，最后规定<strong>只返回 JSON</strong>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JSON</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-json">{
  <span class="t-prop">"resumeTitle"</span>: <span class="t-str">"优化后的简历标题"</span>,
  <span class="t-prop">"summary"</span>: <span class="t-str">"优化后的个人摘要"</span>,
  <span class="t-prop">"content"</span>: <span class="t-str">"优化后的详细内容"</span>
}</code></pre></figure>
<p>模型不一定听话，经常会把 JSON 包在 Markdown 代码块里。所以 <code>extractJson</code> 会先找 <code>```json</code> 代码块，把里面的内容抠出来，再交给 Jackson 解析。解析失败统一抛 <code>BusinessException</code>，前端拿到的是可读的错误信息，而不是一串堆栈。</p>
<div class="callout callout-tip"><p class="callout-title">SYS.TIP // 提示</p><p>让大模型输出结构化数据，三件事缺一不可：提示词里给出<strong>完整的 JSON 示例</strong>；解析前<strong>剥掉代码块</strong>；解析失败要有<strong>明确的业务错误</strong>。</p></div>
<h2 id="可以继续打磨的地方">可以继续打磨的地方<a class="h-anchor" href="#可以继续打磨的地方" aria-label="链接到本节">#</a></h2>
<ul>
<li>封禁状态的检查写在 <code>mustRole</code> 分支里，没标 <code>@AuthCheck</code> 的接口不会经过这一步。如果希望所有登录接口都拦住被封账号，可以把检查挪到 <code>getLoginUser</code> 里统一做。</li>
<li><code>ChatController</code> 里还留着 <code>/chat/test</code>、<code>/chat/model</code> 两个调试接口，上线前最好删掉或者加上权限。</li>
<li>AI 返回的原始内容会整段打进 info 日志，简历里有个人信息，生产环境建议降到 debug 或者脱敏。</li>
</ul>
<h2 id="小结">小结<a class="h-anchor" href="#小结" aria-label="链接到本节">#</a></h2>
<p>这个项目把一个中等规模的 CRUD 系统做全了：三种角色、七张表、完整的投递流程，再用注解和切面把权限收到一处。AI 部分最有意思的不是调接口，而是<strong>怎么让模型稳定地吐出能被程序解析的 JSON</strong>。</p>]]></content:encoded>
</item>
<item>
<title>织文：用 FastAPI + Vue 3 做一个 AI 全文生成器</title>
<link>https://richzds.pages.dev/posts/zhiwen-ai-writer/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/zhiwen-ai-writer/</guid>
<pubDate>Tue, 29 Sep 2026 06:00:00 GMT</pubDate>
<category>Python</category><category>FastAPI</category><category>Vue</category><category>AI</category><category>OpenAI 兼容接口</category>
<description><![CDATA[输入主题、受众、风格和提纲，生成带标题、摘要和提纲的 Markdown 全文。没有 API Key 也能跑的演示模式，加上任意 OpenAI 兼容接口。]]></description>
<content:encoded><![CDATA[<p><a href="https://github.com/RichZDS/langchain_demo" target="_blank" rel="noopener">织文</a>是一个前后端分离的 AI 写作示例：前端 Vue 3 + Ant Design Vue，后端 FastAPI，接口按 RESTful 风格设计。填好主题、写作目的、目标读者、风格、字数、关键词和提纲，就能生成一篇完整的文章。</p>
<h2 id="结构">结构<a class="h-anchor" href="#结构" aria-label="链接到本节">#</a></h2>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">langchain_demo/
├─ frontend/                 # Vue 3 + Ant Design Vue，端口 9000
│  └─ src/
│     ├─ App.vue             # 生成指导、表单、结果与历史
│     ├─ api.ts              # REST API 客户端
│     └─ styles.css
└─ backend/                  # FastAPI，端口 9001
   ├─ app/
   │  ├─ routers/            # RESTful 路由
   │  ├─ services/           # AI 生成服务
   │  ├─ repository.py       # 内存文章仓库
   │  └─ main.py
   ├─ scripts/export_openapi.py
   └─ tests/</code></pre></figure>
<p>开发时 Vite 把 <code>/api</code> 代理到 <code>http://127.0.0.1:9001</code>，前后端各跑各的端口，不用处理跨域。</p>
<h2 id="rest-api">REST API<a class="h-anchor" href="#rest-api" aria-label="链接到本节">#</a></h2>
<div class="table-wrap"><table>
<thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>GET</code></td><td><code>/api/v1/health</code></td><td>健康检查</td></tr>
<tr><td><code>POST</code></td><td><code>/api/v1/articles</code></td><td>生成并创建文章</td></tr>
<tr><td><code>GET</code></td><td><code>/api/v1/articles</code></td><td>获取文章列表（<code>skip</code> / <code>limit</code> 分页）</td></tr>
<tr><td><code>GET</code></td><td><code>/api/v1/articles/{id}</code></td><td>获取文章详情</td></tr>
<tr><td><code>DELETE</code></td><td><code>/api/v1/articles/{id}</code></td><td>删除文章</td></tr>
</tbody>
</table></div>
<p>「生成」被建模成<strong>创建一个文章资源</strong>：<code>POST /articles</code> 成功返回 <code>201 Created</code>；AI 服务出错时返回 <code>502 Bad Gateway</code>，并附上 <code>GENERATION_FAILED</code> 错误码。上游坏了就说上游坏了，这个状态码选得很准。</p>
<p>FastAPI 自带 Swagger（<code>/docs</code>）和 ReDoc（<code>/redoc</code>），<code>scripts/export_openapi.py</code> 还能把 OpenAPI JSON 导出来，给其他工具用。</p>
<h2 id="两种生成模式">两种生成模式<a class="h-anchor" href="#两种生成模式" aria-label="链接到本节">#</a></h2>
<p><code>ArticleGenerator.generate()</code> 根据环境变量 <code>AI_MODE</code> 选路：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">PYTHON</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-python"><span class="t-kw">async</span> <span class="t-kw">def</span> <span class="t-fn">generate</span>(<span class="t-lit">self</span>, request: <span class="t-type">ArticleCreate</span>) -&gt; <span class="t-type">GeneratedArticle</span>:
    <span class="t-kw">if</span> settings.ai_mode.<span class="t-fn">lower</span>() == <span class="t-str">"openai"</span>:
        <span class="t-kw">if</span> <span class="t-kw">not</span> settings.ai_api_key:
            <span class="t-kw">raise</span> <span class="t-fn">GenerationError</span>(<span class="t-str">"AI_MODE=openai 时必须配置 AI_API_KEY"</span>)
        <span class="t-kw">return</span> <span class="t-kw">await</span> <span class="t-lit">self</span>.<span class="t-fn">_generate_with_openai</span>(request)
    <span class="t-kw">return</span> <span class="t-lit">self</span>.<span class="t-fn">_generate_demo</span>(request)</code></pre></figure>
<ul>
<li><strong>demo 模式（默认）</strong>：不调任何模型，用模板按「为什么值得关注 → 先搭框架 → 可执行步骤 → 常见误区 → 结语」拼出一篇结构完整的文章。没有 Key 也能完整演示前后端流程，写测试也不用 mock 网络。</li>
<li><strong>openai 模式</strong>：用 <code>httpx</code> 直接请求 <code>{AI_BASE_URL}/chat/completions</code>。<code>AI_BASE_URL</code> 换成任何 OpenAI 兼容的服务都行。</li>
</ul>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">POWERSHELL</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-powershell"><span class="t-var">$env:AI_MODE</span> = <span class="t-str">"openai"</span>
<span class="t-var">$env:AI_API_KEY</span> = <span class="t-str">"你的密钥"</span>
<span class="t-var">$env:AI_BASE_URL</span> = <span class="t-str">"https://api.openai.com/v1"</span>
<span class="t-var">$env:AI_MODEL</span> = <span class="t-str">"gpt-4.1-mini"</span></code></pre></figure>
<h2 id="提示词怎么写">提示词怎么写<a class="h-anchor" href="#提示词怎么写" aria-label="链接到本节">#</a></h2>
<p>系统消息只做一件事，约束输出格式：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">你是一名专业中文编辑。输出严格的 JSON 对象，字段必须为
title、abstract、outline（字符串数组）、content（Markdown 全文）。</code></pre></figure>
<p>请求里还带了 <code>response_format: {&quot;type&quot;: &quot;json_object&quot;}</code>。用户消息把表单里的每个字段逐条列出来，最后给出硬性要求：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">要求：标题有吸引力，摘要不超过 120 字；正文使用 Markdown 二级标题分段，
逻辑完整、信息具体、有开头和结语，不编造数据或来源。</code></pre></figure>
<p>「不编造数据或来源」这一句很重要。写作类应用最大的风险不是文笔差，而是一本正经地编参考文献。</p>
<p>返回之后，<code>_parse_json</code> 先用正则剥掉可能出现的 <code>```json</code> 代码块，再 <code>json.loads</code>。缺字段、类型不对、JSON 坏掉，全部收敛成 <code>GenerationError</code>，路由层再翻译成 502。</p>
<h2 id="字数怎么数">字数怎么数<a class="h-anchor" href="#字数怎么数" aria-label="链接到本节">#</a></h2>
<p>中文按字、英文按词：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">PYTHON</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-python"><span class="t-kw">def</span> <span class="t-fn">count_words</span>(text: str) -&gt; int:
    <span class="t-str">"""中文按字、英文按词统计，提供接近阅读习惯的字数。"""</span>
    <span class="t-kw">return</span> <span class="t-fn">len</span>(re.<span class="t-fn">findall</span>(<span class="t-str">r"[㐀-鿿]|[A-Za-z0-9]+"</span>, text))</code></pre></figure>
<h2 id="下一步">下一步<a class="h-anchor" href="#下一步" aria-label="链接到本节">#</a></h2>
<ul>
<li>仓库名叫 <code>langchain_demo</code>，但目前的实现没有引入 LangChain，而是用 <code>httpx</code> 直接调接口。先用最少的依赖跑通，后面再把 PromptTemplate、输出解析器之类换成 LangChain 的组件，是个稳妥的顺序；</li>
<li>历史记录存在内存里（<code>ArticleRepository</code>），服务一重启就清空。换成 SQLite 或 MySQL 时只需要替换仓库实现，路由层不用动，这也是当初把仓库单独抽出来的好处；</li>
<li>长文生成可以改成流式返回（SSE），体验会好很多。</li>
</ul>]]></content:encoded>
</item>
<item>
<title>最尊重模块化：Spring Boot 里的 DTO / DO / VO 分层实践</title>
<link>https://richzds.pages.dev/posts/layered-architecture/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/layered-architecture/</guid>
<pubDate>Tue, 29 Sep 2026 05:00:00 GMT</pubDate>
<category>Java</category><category>Spring Boot</category><category>架构</category><category>MapStruct</category><category>React</category>
<description><![CDATA[以运动场馆管理系统为例，拆解 controller → service → dao 的分层，以及 DTO 进、DO 落库、VO 出的对象边界，用 MapStruct 把转换代码交给编译器。]]></description>
<content:encoded><![CDATA[<p>GitHub 签名写的是「最尊重模块化的选手」。这篇拿 <a href="https://github.com/RichZDS/SportsBackend" target="_blank" rel="noopener">SportsBackend</a> 和 <a href="https://github.com/RichZDS/SportsFrontend" target="_blank" rel="noopener">SportsFrontend</a> 讲讲这句话落到代码里是什么样子。</p>
<p>这个项目一开始叫「消费管理系统」：管理员管客户和消费记录、看统计报表，客户查自己的消费。后来后端又长出了课程、预约、教练、器材、场地、员工这些模块，成了一个运动场馆管理系统。模块多了以后，分层是否清楚会直接决定项目能不能继续写下去。</p>
<h2 id="分层">分层<a class="h-anchor" href="#分层" aria-label="链接到本节">#</a></h2>
<p>前端 README 里画过一张规划：<code>controller → logic → service → dao → do → dto → vo</code>。落到后端代码里是这样的：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">com.zds.sports
├─ controller/      # HTTP 入口：参数、状态码、接口文档
├─ service/         # 业务接口
│  └─ impl/         # 业务实现（继承 MyBatis-Plus 的 ServiceImpl）
├─ dao/             # Mapper，只管读写数据库
├─ domain/entity/   # DO：和表一一对应，比如 ConsumeRecordDO
├─ model/dto/       # DTO：入参，比如 CreateRecordDTO / UpdateRecordDTO
├─ model/vo/        # VO：出参，比如 RecordVO / PageResultVO
├─ convert/         # MapStruct 转换器
└─ config/          # 跨域、Jackson、分页、全局异常、自动填充</code></pre></figure>
<p>规划里的 <code>logic</code> 层（跨多个 service 的编排）暂时没有单独拆出来。业务还简单的时候，编排直接写在 service 里就够了，等出现一个接口要协调好几个领域的情况再拆也来得及。</p>
<h2 id="三种对象三条边界">三种对象，三条边界<a class="h-anchor" href="#三种对象三条边界" aria-label="链接到本节">#</a></h2>
<div class="table-wrap"><table>
<thead><tr><th>对象</th><th>方向</th><th>例子</th><th>负责什么</th></tr></thead>
<tbody>
<tr><td>DTO</td><td>请求 → 后端</td><td><code>CreateRecordDTO</code></td><td>接收并校验入参</td></tr>
<tr><td>DO</td><td>后端 ↔ 数据库</td><td><code>ConsumeRecordDO</code></td><td>和表结构对齐</td></tr>
<tr><td>VO</td><td>后端 → 响应</td><td><code>RecordVO</code></td><td>前端真正需要的字段</td></tr>
</tbody>
</table></div>
<p>为什么不一个实体类用到底？因为三者变化的原因不一样：</p>
<ul>
<li>表加了一列审计字段，不应该让前端看到，所以 <strong>VO 和 DO 要分开</strong>；</li>
<li>创建和更新需要的字段、校验规则不一样，所以 <strong>Create / Update 各有一个 DTO</strong>；</li>
<li>前端想要「客户名」而不是「客户 ID」，这种展示需求只改 VO 和转换器，不碰表结构。</li>
</ul>
<p>入参 DTO 用 Jakarta Validation 把关：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><span class="code-title">CreateRecordDTO.java</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-meta">@Data</span>
<span class="t-kw">public</span> <span class="t-kw">class</span> <span class="t-type">CreateRecordDTO</span> {
    <span class="t-meta">@NotNull</span>
    <span class="t-kw">private</span> <span class="t-type">Long</span> customerId;
    <span class="t-meta">@NotNull</span>
    <span class="t-kw">private</span> <span class="t-type">BigDecimal</span> amount;
    <span class="t-meta">@NotNull</span>
    <span class="t-kw">private</span> <span class="t-type">String</span> category;
    <span class="t-kw">private</span> <span class="t-type">String</span> remark;
    <span class="t-meta">@NotNull</span>
    <span class="t-kw">private</span> <span class="t-type">String</span> paidAt;
}</code></pre></figure>
<p>金额用的是 <code>BigDecimal</code> 而不是 <code>double</code>，写和钱有关的代码时这一点不能省。</p>
<h2 id="转换交给-mapstruct">转换交给 MapStruct<a class="h-anchor" href="#转换交给-mapstruct" aria-label="链接到本节">#</a></h2>
<p>对象分开以后，最烦的是互相拷字段。MapStruct 在<strong>编译期</strong>生成转换代码，不用反射，出错时编译就报：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><span class="code-title">RecordMapStruct.java</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-meta">@Mapper</span>(componentModel = <span class="t-str">"spring"</span>,
        nullValuePropertyMappingStrategy = <span class="t-type">NullValuePropertyMappingStrategy</span>.<span class="t-type">IGNORE</span>)
<span class="t-kw">public</span> <span class="t-kw">interface</span> <span class="t-type">RecordMapStruct</span> {
    <span class="t-meta">@Mapping</span>(target = <span class="t-str">"customerName"</span>,
             expression = <span class="t-str">"java(recordDO.getCustomer() != null ? recordDO.getCustomer().getName() : null)"</span>)
    <span class="t-type">RecordVO</span> <span class="t-fn">toVO</span>(<span class="t-type">ConsumeRecordDO</span> recordDO);

    <span class="t-meta">@Mapping</span>(target = <span class="t-str">"paidAt"</span>, expression = <span class="t-str">"java(parseDateTimeFlexible(createRecordDTO.getPaidAt()))"</span>)
    <span class="t-type">ConsumeRecordDO</span> <span class="t-fn">toDO</span>(<span class="t-type">CreateRecordDTO</span> createRecordDTO);

    <span class="t-meta">@Mapping</span>(target = <span class="t-str">"paidAt"</span>, expression = <span class="t-str">"java(parseDateTimeFlexible(updateRecordDTO.getPaidAt()))"</span>)
    <span class="t-kw">void</span> <span class="t-fn">updateDO</span>(<span class="t-type">UpdateRecordDTO</span> updateRecordDTO, <span class="t-meta">@MappingTarget</span> <span class="t-type">ConsumeRecordDO</span> recordDO);
}</code></pre></figure>
<p><code>NullValuePropertyMappingStrategy.IGNORE</code> 很关键：更新时 DTO 里没传的字段是 <code>null</code>，这个策略保证它不会把数据库里原来的值覆盖掉，局部更新就这样实现了。</p>
<p>有了转换器，service 里的代码只剩业务本身：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><span class="code-title">ConsumeRecordServiceImpl.java</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-kw">public</span> <span class="t-type">RecordVO</span> <span class="t-fn">createRecord</span>(<span class="t-type">CreateRecordDTO</span> createRecordDTO) {
    <span class="t-type">ConsumeRecordDO</span> recordDO = recordMapStruct.<span class="t-fn">toDO</span>(createRecordDTO);
    <span class="t-kw">this</span>.<span class="t-fn">save</span>(recordDO);
    <span class="t-kw">return</span> recordMapStruct.<span class="t-fn">toVO</span>(recordDO);
}</code></pre></figure>
<p>DTO 进，DO 落库，VO 出，一眼能看完。</p>
<h2 id="controller-保持薄">controller 保持薄<a class="h-anchor" href="#controller-保持薄" aria-label="链接到本节">#</a></h2>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JAVA</span><span class="code-title">ConsumeRecordController.java</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-java"><span class="t-meta">@PostMapping</span>
<span class="t-meta">@ResponseStatus</span>(<span class="t-type">HttpStatus</span>.<span class="t-type">CREATED</span>)
<span class="t-meta">@Operation</span>(summary = <span class="t-str">"Create a new consumption record"</span>)
<span class="t-kw">public</span> <span class="t-type">RecordVO</span> <span class="t-fn">createRecord</span>(<span class="t-meta">@Valid</span> <span class="t-meta">@RequestBody</span> <span class="t-type">CreateRecordDTO</span> createRecordDTO) {
    <span class="t-kw">return</span> consumeRecordService.<span class="t-fn">createRecord</span>(createRecordDTO);
}</code></pre></figure>
<p>controller 只做三件事：声明路由和状态码（创建返回 201，删除返回 204）、触发校验（<code>@Valid</code>）、把活交给 service。校验失败由 <code>GlobalExceptionHandler</code> 统一处理，把字段错误拼成一条可读的消息返回 400；其他未捕获的异常也会记日志，统一返回 500。</p>
<h2 id="前端也分层">前端也分层<a class="h-anchor" href="#前端也分层" aria-label="链接到本节">#</a></h2>
<p>前端是 React + Ant Design + ECharts，同样按职责拆开：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">src/
├─ pages/        # Admin/Customers、Admin/Records、Admin/Reports、Customer/Records
├─ components/   # Table、Form、Chart（统一封装 ECharts）
├─ services/     # Axios 封装，baseURL 来自 VITE_API_BASE
└─ routes/       # 路由配置</code></pre></figure>
<p>图表组件只收一个 <code>options</code>：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">JSX</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-jsx">&lt;<span class="t-type">Chart</span> options={options} style={{ height: <span class="t-num">360</span> }} /&gt;</code></pre></figure>
<p>页面只管准备数据，怎么初始化 ECharts、窗口变化时怎么 resize，都关在组件里面。</p>
<h2 id="小结">小结<a class="h-anchor" href="#小结" aria-label="链接到本节">#</a></h2>
<p>模块化不是为了目录好看，而是为了<strong>改一处的时候，只需要改一处</strong>：</p>
<ul>
<li>改表结构，只动 DO 和转换器；</li>
<li>改返回格式，只动 VO 和转换器；</li>
<li>改校验规则，只动 DTO；</li>
<li>换图表库，只动 <code>Chart</code> 组件。</li>
</ul>]]></content:encoded>
</item>
<item>
<title>SDUT 计科中外学习小 tip：前人走过的弯路，后人不必再走</title>
<link>https://richzds.pages.dev/posts/sdut-study-tips/</link>
<guid isPermaLink="true">https://richzds.pages.dev/posts/sdut-study-tips/</guid>
<pubDate>Tue, 29 Sep 2026 04:00:00 GMT</pubDate>
<category>学习笔记</category><category>SDUT</category><category>408</category><category>开源</category>
<description><![CDATA[一个给山东理工大学计科中外同学的课程经验仓库：为什么做、怎么组织，以及从各门课里整理出来的学习建议。]]></description>
<content:encoded><![CDATA[<p>刚进大学时，大家都要四处收集资料：作业答案、PTA 代码和思路、历年试卷、学长学姐做的 A4 纸，还有上完一门课才恍然大悟的技巧。有的资料一找就有，有的要费很大力气，很多时候能不能拿到全凭运气。</p>
<p><a href="https://github.com/RichZDS/SDUT" target="_blank" rel="noopener">SDUT</a> 这个仓库想解决的就是这件事。README 里写道：</p>
<blockquote><p>我为这一年一年，这么多人孤军奋战的重复劳动感到不平。</p></blockquote>
<blockquote><p>我希望只要是前人走过的弯路，后人就不必再走。</p></blockquote>
<p>这段前言借鉴了浙大学习指南 <a href="https://qsctech.github.io/zju-icicles/" target="_blank" rel="noopener">zju-icicles</a>，README 里也注明了出处。</p>
<h2 id="仓库怎么组织">仓库怎么组织<a class="h-anchor" href="#仓库怎么组织" aria-label="链接到本节">#</a></h2>
<p>按学期分目录，每门课一个 README，资料和经验放在一起：</p>
<figure class="code"><figcaption class="code-bar"><span class="code-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="code-lang">TEXT</span><button class="code-copy" type="button">COPY</button></figcaption><pre><code class="lang-text">SDUT/
├─ 大一下/
│  └─ README.md          # 本学期科目总览
└─ 大二上/
   ├─ 离散数学/          # 经验 + 思维导图
   ├─ 线性代数（B）/
   ├─ 面向对象/          # 经验 + 往年试卷
   ├─ 马克思主义原理/
   └─ 操作系统.rar</code></pre></figure>
<p>仓库在 GitHub 上收到了 3 个 star，数字不大，但对一个面向单个专业的资料库来说，说明真的有人在用。</p>
<h2 id="各门课的经验">各门课的经验<a class="h-anchor" href="#各门课的经验" aria-label="链接到本节">#</a></h2>
<p>下面是从各门课 README 里整理出来的建议。</p>
<h3 id="大一下">大一下<a class="h-anchor" href="#大一下" aria-label="链接到本节">#</a></h3>
<ul>
<li><strong>计算机组成原理</strong>：408 考研科目之一。学的范围比普通计科窄，难点在英语。实验课前期不难，后面几次会变难，要把实验和课本对照着学。</li>
<li><strong>程序设计基础</strong>：上学期的进阶，从结构体一路讲到贪心和动态规划，难度直线上升。题目基本和 PTA 相关，<strong>多刷 PTA</strong> 是最直接的办法。</li>
<li><strong>高级程序设计</strong>：全英文授课，学 Java。期末要做一个小游戏（比如纸牌游戏）并答辩。</li>
<li><strong>计算机科学基础</strong>：全英文授课，是下学期离散数学的基础。</li>
</ul>
<h3 id="大二上">大二上<a class="h-anchor" href="#大二上" aria-label="链接到本节">#</a></h3>
<ul>
<li><strong>离散数学</strong>：这学期难度最高。以定义和性质定理为主，是后面数据结构的基础。刚开始听不懂很正常，回头再看会突然开窍。老师上课强调的往年题一定要标出来，期末前讲解的那套旧卷要吃透。另外它和四六级、操作系统的考试挨得很近，要提前安排复习。</li>
<li><strong>面向对象</strong>：整体偏难，挂科率不低。建议提前在网上找 Java 基础课补一补，再突击背一下英文术语。</li>
<li><strong>线性代数（B）</strong>：老师会把笔记整理在语雀上，照着学就行。</li>
</ul>
<h2 id="为什么这种仓库值得做">为什么这种仓库值得做<a class="h-anchor" href="#为什么这种仓库值得做" aria-label="链接到本节">#</a></h2>
<ol>
<li><strong>信息差是能消除的</strong>。一门课的重点、考试形式、容易踩的坑，写下来只要十几分钟，却能省下后来人好几周的摸索。</li>
<li><strong>仓库比群聊可靠</strong>。群文件会过期，聊天记录会被刷走，Git 仓库有目录、有历史，还能接受别人的 PR。</li>
<li><strong>写下来也是复盘</strong>。给别人写经验的时候，会重新想一遍这门课到底在讲什么。</li>
</ol>
<p>README 里也写了：如果有文件侵犯了谁的权益，可以联系作者删除。分享资料的同时尊重版权，这一条不能少。</p>
<hr>
<p>如果你也是 SDUT 的同学，欢迎去仓库补充你那一届的经验，点个 star 也好。</p>]]></content:encoded>
</item>
</channel>
</rss>
