<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Sir丶雨轩的博客</title>
        <link>https://blog.oofo.cc</link>
        <description>Sir丶雨轩的个人博客，分享技术、生活和思考。</description>
        <lastBuildDate>Fri, 27 Mar 2026 16:24:55 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>Feed for Node.js</generator>
        <language>zh-CN</language>
        <image>
            <title>Sir丶雨轩的博客</title>
            <url>https://blog.oofo.cc/favicon.ico</url>
            <link>https://blog.oofo.cc</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <title><![CDATA[RoyMapGuide 增强版：把内置地图标记升级成可导航、可扩展、可导入导出的坐标工具]]></title>
            <link>https://blog.oofo.cc/posts/wow-plugin-roymapguide-enhanced</link>
            <guid>https://blog.oofo.cc/posts/wow-plugin-roymapguide-enhanced</guid>
            <pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 背景

`RoyMapGuide` 原始版本主要提供**内置地图标记展示**能力，来源帖：

- https://bbs.nga.cn/read.php?tid=43600010&_fp=2

在日常使用里，我们希望它不仅“能看”，还要“能用”：

- 点击标记后直接设导航点
- 临时坐标能快速录入、保存、管理
- 自定义点能分享（导入/导出）

所以我们做了增强版，本文记录这次升级内容。...]]></description>
            <content:encoded><![CDATA[
## 背景

`RoyMapGuide` 原始版本主要提供**内置地图标记展示**能力，来源帖：

- https://bbs.nga.cn/read.php?tid=43600010&_fp=2

在日常使用里，我们希望它不仅“能看”，还要“能用”：

- 点击标记后直接设导航点
- 临时坐标能快速录入、保存、管理
- 自定义点能分享（导入/导出）

所以我们做了增强版，本文记录这次升级内容。

---

## 增强目标

围绕“地图标记 -> 导航工作流”做了三层增强：

1. **内置标记可直接导航**（左键点击标记设置用户航点）
2. **自定义坐标体系**（标题/备注/颜色、历史列表、移除）
3. **分享与快录入口**（导入导出 + `/yx` 快速弹窗 + 地图左上角输入框）

---

## 主要功能升级

### 1) 内置标记：从“展示”变成“可点击导航”

现在在地图上左键点击标记，会直接设置用户航点，并自动超级追踪，省去手动开地图、手动插旗的步骤。

> 对于常驻主城、副本入口、军需官等点位，效率提升非常明显。

---

### 2) 地图左上角坐标输入：快速设导航

在世界地图左上角新增了快速输入框，支持输入：

- `12.34 56.78`
- `12.34,56.78`

回车后直接设置导航，并在聊天框给出成功/错误提示。

![主城展示](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E4%B8%BB%E5%9F%8E%E5%B1%95%E7%A4%BA.png)

![银月城](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E9%93%B6%E6%9C%88%E5%9F%8E.png)

---

### 3) 坐标扩展页：自定义坐标可视化管理

新增独立“坐标扩展”选项页，支持：

- 自动读取当前位置地图与坐标
- 输入**标题 + 备注**
- 自定义颜色（色板）
- 保存后地图立即生效
- 历史列表分页查看、单条移除

![坐标扩展](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E5%9D%90%E6%A0%87%E6%89%A9%E5%B1%95.png)

![测试点位](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E6%B5%8B%E8%AF%95%E7%82%B9%E4%BD%8D.png)

---

### 4) /yx 快速添加弹窗

新增斜杠命令：`/yx`

打开快速添加窗口后，可以直接填写：

- 标题
- 备注
- 颜色

保存即写入自定义坐标，并自动关闭弹窗，同时输出“自定义 xxx 添加成功”。

![快速添加](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E5%BF%AB%E9%80%9F%E6%B7%BB%E5%8A%A0.png)

---

### 5) 独立导入导出页：分享坐标更方便

新增“坐标导入导出”独立选项页：

- 导出：一键复制整包自定义坐标
- 导入：粘贴后批量恢复
- 与历史列表联动，导入后立即可见

![坐标导入导出](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/%E5%9D%90%E6%A0%87%E5%AF%BC%E5%85%A5%E5%AF%BC%E5%87%BA.png)

---

### 6) Tooltip 与交互细节优化

- 内置点位悬停显示 `info`
- 自定义点位悬停优先显示 `备注`
- 鼠标悬停高亮优化，避免缩放闪烁问题

这些细节让“看图找点”变成“看图即用”。

---

## 与原版的差异总结

原版核心能力：

- 内置地图标记展示

增强版新增能力：

- 标记点击直接导航
- 地图左上角输入坐标设导航
- 自定义坐标（标题/备注/颜色）
- 历史列表管理（分页/移除）
- 独立导入导出页面
- `/yx` 快速添加入口
- Tooltip 与高亮交互优化

---

## 安装与使用

### 安装

下载插件包并解压到：

`World of Warcraft/_retail_/Interface/AddOns/`

插件包：

- [RoyMapGuide.zip](https://oss.yuxuan66.com/images/new-blog/wow-plugin-roymapguide/RoyMapGuide.zip)

### 使用建议

1. 先开启“全地图标记”
2. 在主城尝试点击一个内置标记验证导航
3. 使用地图左上角输入框测试临时导航
4. 通过 `/yx` 添加一个自定义坐标
5. 到“坐标扩展 / 坐标导入导出”页面查看与备份

---

## 结语

这次增强的核心不是“多几个按钮”，而是把 `RoyMapGuide` 从“静态标注”升级成了“可操作的导航工具”。

如果你常在多个角色之间跑主城、做周常、查商人或找入口，这套增强会明显减少重复操作。]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>World of Warcraft</category>
            <category>WoW插件</category>
            <category>RoyMapGuide</category>
            <category>Lua</category>
        </item>
        <item>
            <title><![CDATA[修复Raycast应用名称显示问题]]></title>
            <link>https://blog.oofo.cc/posts/raycast-app-names-showing-incorrectly-fix</link>
            <guid>https://blog.oofo.cc/posts/raycast-app-names-showing-incorrectly-fix</guid>
            <pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 问题描述

如果你发现 Raycast 搜索应用时，显示的应用名称是英文（例如 `WeChat`, `Calendar`, `Activity Monitor`），而你的 macOS 系统界面（如程序坞、启动台）显示的是正确的中文名（如「微信」、「日历」、「活动监视器」），这篇文章将为你解释原因并提供修复步骤。

这不是 Raycast 的 Bug，而是一个与 macOS 系统底层相关的问...]]></description>
            <content:encoded><![CDATA[
## 问题描述

如果你发现 Raycast 搜索应用时，显示的应用名称是英文（例如 `WeChat`, `Calendar`, `Activity Monitor`），而你的 macOS 系统界面（如程序坞、启动台）显示的是正确的中文名（如「微信」、「日历」、「活动监视器」），这篇文章将为你解释原因并提供修复步骤。

这不是 Raycast 的 Bug，而是一个与 macOS 系统底层相关的问题。

## 根本原因：macOS 的双层名称机制

理解这个问题的关键在于了解 macOS 如何存储和显示应用名称：

1.  **底层元数据（Spotlight 索引）**：
    *   这是 `mdls` 命令和 Raycast 读取的数据源。
    *   存储在由 **Spotlight** 维护的索引中。
    *   当这个索引损坏或未更新时，应用名称就可能显示为英文或旧名称。

2.  **表层显示名称（系统界面）**：
    *   这是你在程序坞、访达和启动台中看到的名称。
    *   系统直接读取应用程序包内的本地化资源文件（如 `zh_CN.lproj/InfoPlist.strings`）。
    *   因此，即使 Spotlight 索引出了问题，系统界面通常也能正确显示。

**简单来说**：Raycast 依赖的“数据库”（Spotlight 索引）出错了，而系统界面有自己获取名称的“捷径”，所以出现了显示不一致的情况。

## 修复步骤

请按照以下步骤操作，从最简单的方法开始尝试。

### 方法一：强制重建 Spotlight 索引（最有效）

这是解决此问题最根本的方法。它会强制 macOS 重新扫描所有文件和应用，重建其内部搜索数据库。

1.  打开 **“系统设置”** > **“聚焦”**。
2.  在 **“隐私”** 标签页中，将你的整个硬盘（通常是 `Macintosh HD`）拖入列表，或点击 `+` 号添加它。这会暂时禁用该磁盘的索引。
3.  等待几分钟，然后从隐私列表中移除该磁盘。系统会立即开始重建索引。

**或者，使用终端命令（更直接）：**

```bash
# 1. 首先，确保 Spotlight 索引是开启状态
sudo mdutil -i on /

# 2. 强制删除并重建索引（这需要一些时间，请耐心等待）
sudo mdutil -E /
```

执行后，你可能会看到如下提示：
```
/:
    Indexing enabled.
```
重建索引的过程可能在后台持续数小时，期间你可能会听到风扇声，这是正常的。

### 方法二：重启系统核心服务

有时，简单地重启相关服务也能刷新数据。

1.  打开 **“活动监视器”**。
2.  在搜索框中输入 `spotlight`。
3.  选中名为 `mds` 或 `mds_stores` 的进程，点击左上角的 **“强制结束”** (X) 按钮。系统会自动重启该进程。

### 方法三：重置 Raycast 的缓存

如果系统索引是正确的，但 Raycast 仍然显示旧数据，可以尝试清除其缓存。

1.  完全退出 Raycast。
2.  打开 **“终端”** 应用，输入以下命令后回车：
    ```bash
    defaults delete com.raycast.macos
    ```
3.  重新启动 Raycast。

## 验证修复是否成功

要检查 Spotlight 索引是否已恢复正常，可以打开 **“终端”** 并使用我们在文章中提到的 `mdls` 命令进行测试。

```bash
# 检查“日历”应用
mdls -name kMDItemDisplayName /System/Applications/Calendar.app

# 检查“活动监视器”
mdls -name kMDItemDisplayName /System/Applications/Utilities/Activity\ Monitor.app
```

如果命令返回了正确的中文名称（例如 `kMDItemDisplayName = "日历"`），说明索引已修复。此时 Raycast 中的应用名称通常也会随之恢复正常。

## 总结

Raycast 应用名称显示异常，本质上是一个 **Spotlight 索引问题**。通过 **重建 Spotlight 索引**，你可以从根本上解决它。虽然这需要一些等待时间，但通常是一劳永逸的。

如果问题依旧，可以尝试在 Raycast 中为特定应用设置 **昵称（Alias）**：找到该应用后，按 `⌘ + Return` 即可为其设置一个你习惯的中文昵称，方便以后搜索。
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>Raycast</category>
            <category>macOS</category>
            <category>Troubleshooting</category>
        </item>
        <item>
            <title><![CDATA[Tailscale-Derp节点安装配置教程]]></title>
            <link>https://blog.oofo.cc/posts/tailscale-derp</link>
            <guid>https://blog.oofo.cc/posts/tailscale-derp</guid>
            <pubDate>Fri, 05 Sep 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 概述
DERP (Designated Encrypted Relay for Packets) 是Tailscale的加密中继服务，用于在NAT穿透失败时提供备用的连接路径。

## 系统要求
- **操作系统**: Linux (推荐Ubuntu 20.04+ 或 CentOS 7+)
- **内存**: 至少512MB RAM
- **存储**: 至少1GB可用空间
- **网络**...]]></description>
            <content:encoded><![CDATA[
## 概述
DERP (Designated Encrypted Relay for Packets) 是Tailscale的加密中继服务，用于在NAT穿透失败时提供备用的连接路径。

## 系统要求
- **操作系统**: Linux (推荐Ubuntu 20.04+ 或 CentOS 7+)
- **内存**: 至少512MB RAM
- **存储**: 至少1GB可用空间
- **网络**: 公网IP，开放端口33445、33446、3478

## 安装步骤

### 1. 下载Derp二进制文件并安装
```bash
# 更新软件包列表 & 升级系统 
sudo apt update && sudo apt upgrade -y

# 安装基本依赖 
sudo apt install -y wget git openssl curl

# 下载Golang，我使用的是x86_64版本，您也可以根据需要选择合适的版本 [All releases - The Go Programming Language](https://go.dev/dl/) 
wget https://go.dev/dl/go1.22.3.linux-amd64.tar.gz

# 删除旧的Golang并解压新的内容 
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.22.3.linux-amd64.tar.gz

# 配置环境变量 
export PATH=$PATH:/usr/local/go/bin

# 检查Golang是否安装成功 
go version

# 配置Golang环境（国外服务器可跳过） 
go env -w GO111MODULE=on 
go env -w GOPROXY=https://goproxy.cn,direct

# 安装Tailscale Derper 
go install tailscale.com/cmd/derper@main

# 创建 /etc/derp 文件夹并赋予权限，以便后续修改文件重新编译到这里
sudo mkdir -p /etc/derp/
sudo chmod 777 /etc/derp/

# 编译文件到指定文件夹(*是通配符，可指定具体版本目录)
cd ~/go/pkg/mod/tailscale.com@*/cmd/derper

# 编译文件到之前创建的文件夹
go build -o /etc/derp/derper

# 创建derp.service

sudo sh -c "cat > /etc/systemd/system/derp.service <<EOF
[Unit]
Description=TS Derper
After=network.target
Wants=network.target

[Service]
User=root
WorkingDirectory=/etc/derp
ExecStart=/etc/derp/derper \
  -verify-clients \
  -hostname derp.domain.com \
  -a :33445 \
  -http-port 33446 \
  -certmode manual \
  -certdir /etc/derp
Restart=always
RestartPreventExitStatus=1

[Install]
WantedBy=multi-user.target

EOF
"
# 自行把证书放到下面位置
# /etc/derp/derp.domain.com.crt
# /etc/derp/derp.domain.com.key


```
### 2. 安装tailscaled
**derper 需要本地运行的 `tailscaled` 来验证客户端状态**

```bash
# 如果你用的是 Linux x86_64，可以用官方脚本安装）
curl -fsSL https://tailscale.com/install.sh | sh

# 启动
systemctl enable --now tailscaled
systemctl status tailscaled

# 登录 tailscaled（让它加入你的 Tailscale 网络）
tailscale up
```

### 3. 启动derper
```bash
# 启动服务
systemctl restart derp
# 查看日志
journalctl -u derp -f
```

配置文件内容：
```json
{
  "version": "1",
  "regions": {
    "999": {
      "regionID": 999,
      "regionCode": "my-derp",
      "regionName": "My DERP Server",
      "nodes": [
        {
          "name": "derp1",
          "regionID": 999,
          "hostName": "your-server-ip",
          "ipv4": "your-server-ip",
          "derpPort": 33445,
          "stunPort": 33446,
          "stunOnly": false
        }
      ]
    }
  }
}
```

### 3. 创建systemd服务文件
```bash
sudo nano /etc/systemd/system/derp.service
```

服务文件内容：
```ini
[Unit]
Description=DERP Server
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/etc/derp
ExecStart=/etc/derp/derp -config=/etc/derp/derp.conf
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

### 4. 启动服务
```bash
# 重新加载systemd配置
sudo systemctl daemon-reload

# 启用服务（开机自启）
sudo systemctl enable derp

# 启动服务
sudo systemctl start derp

# 检查服务状态
sudo systemctl status derp
```

- **注意开放端口 3478、33445、33446**

## 配置Tailscale客户端

### 1. 在Tailscale管理后台配置
1. 登录 [Tailscale管理后台](https://login.tailscale.com/admin/acls/file)
2. 在JSON editor里添加如下代码
```json
"derpMap": {
		"OmitDefaultRegions": true,
		"Regions": {
			"901": {
				"RegionID":   901,
				"RegionCode": "YuxuanMini",
				"RegionName": "YuxuanMini Derper",
				"Nodes": [
					{
						"Name":             "901a",
						"RegionID":         901,
						"HostName":         "derp.domain.com",
						"DERPPort":         33445,
						"IPv4":             "your public ip",
						"InsecureForTests": true
					}
				]
			}
		}
	},
```
![](https://oss.yuxuan66.com/obsidian/20250828133938717-1756359578797-1756359587932-1756359592601.png)


## 验证安装

### 1. 检查服务状态
```bash
# 查看服务状态
sudo systemctl status derp

# 查看日志
sudo journalctl -u derp -f

# 检查端口监听
sudo netstat -tlnp | grep derp
```

### 2. 测试连接
```bash
# 测试DERP端口
telnet your-server-ip 33445

# 测试STUN端口
nc -u your-server-ip 33446
```

## 故障排除

### 常见问题

#### 1. 服务启动失败
```bash
# 查看详细日志
sudo journalctl -u derp -n 50

# 检查配置文件语法
/etc/derp/derp -config=/etc/derp/derp.conf -check
```

#### 2. 端口被占用
```bash
# 查看端口占用
sudo netstat -tlnp | grep :33445
sudo netstat -tlnp | grep :33446

# 杀死占用进程
sudo kill -9 <PID>
```

#### 3. 防火墙问题
```bash
# 检查防火墙状态
sudo ufw status
# 或
sudo firewall-cmd --list-all
```



## 总结
通过以上步骤，您就可以成功搭建一个DERP服务器，为Tailscale网络提供可靠的中继服务。记得定期维护和监控服务状态，确保网络的稳定性。
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>教程</category>
            <category>安装配置</category>
        </item>
        <item>
            <title><![CDATA[基于 MyBatis Plus 的通用 CRUD 实现]]></title>
            <link>https://blog.oofo.cc/posts/mybatis-plus-common</link>
            <guid>https://blog.oofo.cc/posts/mybatis-plus-common</guid>
            <pubDate>Fri, 22 Nov 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 1. 背景说明

Java 的泛型是 JDK 1.5 引入的一个特性，它允许我们定义具有通用性和类型安全性的代码。在实际开发中，很多模块的 CRUD（增删改查）操作非常类似。为了避免重复编码，我们可以结合泛型和 MyBatis Plus 提供的 IService 接口实现一个通用的 CRUD 体系。

通过这种方式，我们可以快速实现分页查询、模糊查询等功能，同时保持代码的可读性和扩展性。
...]]></description>
            <content:encoded><![CDATA[
## 1. 背景说明

Java 的泛型是 JDK 1.5 引入的一个特性，它允许我们定义具有通用性和类型安全性的代码。在实际开发中，很多模块的 CRUD（增删改查）操作非常类似。为了避免重复编码，我们可以结合泛型和 MyBatis Plus 提供的 IService 接口实现一个通用的 CRUD 体系。

通过这种方式，我们可以快速实现分页查询、模糊查询等功能，同时保持代码的可读性和扩展性。

## 2. 设计思路与实现步骤

我们将按以下步骤实现：

1. **封装查询条件**：抽象分页参数和模糊查询参数，统一接口入参
2. **定义通用 Service 接口**：在基础接口中实现 CRUD 的默认方法
3. **实现具体业务模块的 Service 接口和实现类**：复用基础接口的能力
4. **控制器开发**：通过依赖通用接口，快速实现具体业务的 API

以下逐步介绍实现细节和设计意图。

## 3. 具体实现与解读

### 3.1 封装查询条件

为了方便处理分页查询和模糊查询，我们设计了一个 `BaseQuery` 类，统一接收前端的分页参数和查询条件。

**代码实现：**

```java
@Data
public class BaseQuery<T> {
    /**
     * 模糊查询字段
     */
    private String blurry;

    /**
     * 分页 页码，默认值为1
     */
    private long page = 1;

    /**
     * 分页 每页大小，默认值为10
     */
    private long size = 10;

    /**
     * 内置的查询器
     */
    private final LambdaQueryWrapper<T> queryWrapper = Wrappers.lambdaQuery();

    /**
     * 获取分页对象
     * @return 分页对象
     */
    public Page<T> getPage() {
        return new Page<>(page, size);
    }

    /**
     * 获取查询条件
     * @return 查询条件
     */
    public LambdaQueryWrapper<T> getWrapper() {
        if (blurry != null) {
            queryWrapper.like("name", blurry); // 示例模糊查询：匹配 "name" 字段
        }
        return queryWrapper;
    }
}
```

**设计意图：**

- **分页支持**：通过 `Page<T>` 对象封装分页参数（page 和 size），可以直接用于 MyBatis Plus 的分页查询
- **模糊查询支持**：通过 `LambdaQueryWrapper` 封装模糊查询条件，例如按字段 name 模糊匹配
- **灵活扩展**：用户可自行调整模糊查询字段或添加其他条件

**使用场景：**

前端传递如下参数：

```json
{
  "blurry": "张三",
  "page": 1,
  "size": 10
}
```

后台可以直接通过 `BaseQuery` 类快速生成分页对象和查询条件。

### 3.2 通用 Service 接口

`IBaseService` 是一个基础接口，继承自 MyBatis Plus 的 `IService`。通过泛型和接口的默认方法，我们封装了常用的 CRUD 操作。

**代码实现：**

```java
public interface IBaseService<T> extends IService<T> {

    /**
     * 分页查询
     * @param query 查询条件
     * @return 数据分页结果
     */
    default Ps<T> list(BaseQuery<T> query) {
        return Ps.ok(this.page(query.getPage(), query.getWrapper()));
    }

    /**
     * 添加数据
     * @param t 数据实体
     */
    default void add(T t) {
        this.save(t);
    }

    /**
     * 编辑数据
     * @param t 数据实体
     */
    default void edit(T t) {
        this.updateById(t);
    }

    /**
     * 批量删除
     * @param ids 数据 ID 集合
     */
    default void del(Set<Long> ids) {
        this.removeByIds(ids);
    }
}
```

**设计意图：**

- **通用性**：通过泛型 T，可以让接口适配任何实体类型，如 User、Product 等
- **减少重复编码**：将 CRUD 的实现逻辑放在默认方法中，子类无需重复实现
- **扩展性**：如果某个业务模块需要特殊的逻辑，可以在子类中重写这些默认方法

**核心方法解析：**

- `list`：分页 + 条件查询，直接返回封装的分页结果
- `add`：保存单条数据
- `edit`：根据主键更新数据
- `del`：根据 ID 集合批量删除

### 3.3 业务模块的 Service

**接口定义：**

业务模块的接口只需要继承 `IBaseService<T>` 即可。

```java
public interface UserService extends IBaseService<User> {
}
```

**实现类：**

实现类继承 `BaseService` 并添加事务支持。

```java
@Service
@Transactional
public class UserServiceImpl extends BaseService<User, UserMapper> implements UserService {
}
```

### 3.4 控制器开发

控制器通过注入 `UserService`，调用通用接口的方法实现 CRUD 操作。

**代码实现：**

```java
@RestController
@RequestMapping(path = "/user")
public class UserController extends BaseController<UserService> {

    @GetMapping
    public Ps<User> list(BaseQuery<User> query) {
        return baseService.list(query);
    }

    @PostMapping
    public void add(@RequestBody User user) {
        baseService.add(user);
    }

    @PutMapping
    public void edit(@RequestBody User user) {
        baseService.edit(user);
    }

    @DeleteMapping
    public void del(@RequestBody Set<Long> ids) {
        baseService.del(ids);
    }
}
```

## 4. 总结与扩展

### 优势分析

- **高复用性**：抽象通用 CRUD 功能，大幅减少重复代码
- **灵活性**：支持分页、模糊查询等功能，适配不同业务需求
- **扩展性**：可在子类中重写通用接口的方法，满足个性化需求

### 扩展方向

- **多条件查询**：增加动态条件解析功能，如根据多个字段模糊查询
- **权限控制**：在 Service 层引入基于用户角色的权限校验逻辑
- **返回值封装**：结合自定义的响应类（如 `Ps<T>`）进一步规范返回格式

通过这种方式，我们既提升了开发效率，又确保了代码的简洁性和可维护性。希望这篇文章能对你有所帮助！
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>MyBatisPlus</category>
            <category>Java</category>
            <category>数据库</category>
            <category>ORM</category>
        </item>
        <item>
            <title><![CDATA[多线程下 HashMap 的线程安全问题深度解析]]></title>
            <link>https://blog.oofo.cc/posts/hashmap-thread-safety</link>
            <guid>https://blog.oofo.cc/posts/hashmap-thread-safety</guid>
            <pubDate>Thu, 29 Dec 2022 00:00:00 GMT</pubDate>
            <description><![CDATA[

> 阅读本文章，最好有基础的多线程知识，确保你能看懂文章所表达的意思。

## 问题描述

我们创建一个类，在类中维护一个 HashMap（非线程安全，不要扛为什么不用 ConcurrentHashMap，那不在本文的导论范围内）。

然后我们开启多个线程对这个 Map 的 2 个 Key，分别为 A、B，我们从 Map 中取出 value 进行 +1 的操作再放回去。

在线程不安全的情况下...]]></description>
            <content:encoded><![CDATA[

> 阅读本文章，最好有基础的多线程知识，确保你能看懂文章所表达的意思。

## 问题描述

我们创建一个类，在类中维护一个 HashMap（非线程安全，不要扛为什么不用 ConcurrentHashMap，那不在本文的导论范围内）。

然后我们开启多个线程对这个 Map 的 2 个 Key，分别为 A、B，我们从 Map 中取出 value 进行 +1 的操作再放回去。

在线程不安全的情况下，这个 Map 里两个 Key 对应的 Value 大概率是跟我们操作的次数不相同。

## 代码实现

我们先来代码操作：

```java
package com.yuxuan66.demo;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Sir丶雨轩
 * @since 2022/12/29
 */
public class LockTest {

    public static class DoSomething{

        private final Map<String, Integer> countMap = new ConcurrentHashMap<>();
        private final Lock lockA = new ReentrantLock();
        private final Lock lockB = new ReentrantLock();

        public void some(String key) {
            int integer = countMap.getOrDefault(key, 0);
            countMap.put(key, integer + 1);
        }

        public synchronized void print() {
            System.out.println(countMap);
            // TODO 注意 我们在这里判断了理想值是否跟真实的结果一致
            if (countMap.get("A") != 5000 || countMap.get("B") != 5000) {
                System.out.println("error");
                System.exit(0);
            }
        }
    }

    public static class Run implements Runnable{
        private final DoSomething doSomething;
        private final String key;

        public Run(DoSomething doSomething, String key) {
            this.doSomething = doSomething;
            this.key = key;
        }

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                doSomething.some(key);
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 由于要展示的问题不一定稳定复现，所以我们开启多次循环，在重复校验我们的代码
        for (int j = 0; j < 10000; j++) {
            DoSomething doSomething = new DoSomething();
            Thread[] threads = new Thread[100];
            for (int i = 0; i < 100; i += 2) {
                threads[i] = new Thread(new Run(doSomething, "A"));
                threads[i + 1] = new Thread(new Run(doSomething, "B"));
            }
            for (int i = 0; i < 100; i++) {
                threads[i].start();
            }
            for (int i = 0; i < 100; i++) {
                threads[i].join();
            }
            doSomething.print();
        }
    }
}
```

在上面代码中，我们展示了在完全不考虑线程安全的情况下，每一次运行都会直接结束掉。

## 解决方案对比

### 方案一：使用 synchronized

```java
public void some(String key) {
    synchronized (countMap){
        int integer = countMap.getOrDefault(key, 0);
        countMap.put(key, integer + 1);
    }
}
```

这时我们会发现，运行的结果始终如一的跟我们预想的一样。

### 方案二：使用 ReentrantLock（同一把锁）

首先我们尝试在对这个 Map 每次操作时都使用同一把锁：

```java
public void some(String key) {
    Lock lock = lockA;
    lock.lock();
    try{
        int integer = countMap.getOrDefault(key, 0);
        countMap.put(key, integer + 1);
    }finally {
        lock.unlock();
    }
}
```

这是我们发现运行的结果跟 synchronized 是没有区别的，都是正确的。

### 方案三：使用 ReentrantLock（不同锁）- 问题所在

下面到了本文所描述的重点。当我们对 Map 的两个 Key 分别使用不同的锁，这个可能在一定的环境下也是有使用场景的，比如对应的锁可能会被其他对象所控制。

这里就简单模拟一下，对两个 Key 分别使用两把不同的锁：

```java
public void some(String key) {
    Lock lock = "A".equals(key) ? lockA : lockB;
    lock.lock();
    try {
        int integer = countMap.getOrDefault(key, 0);
        countMap.put(key, integer + 1);
    } finally {
        lock.unlock();
    }
}
```

这个时候我们在运行就会发现结果错误了。

## 问题分析

这个时候大家可以想一下为什么会出现这个情况呢？对 Map 的操作明明都在 lock 的代码内。我们对于每一个 key 的操作都是独立的锁，理论来说是不会冲突的。

其实这个问题还真就跟锁没什么关系，让我们来打开 HashMap 的源码（1.8 环境下）的第 397 行：

```java
/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;
```

我们可以发现注释写的很清楚，这个存放着 Map 数据的 table 数组会在第一次使用的时候初始化，并非在 new HashMap 的时候完成。

我们也可以打开源码的第 630 行，这里在 table == null 的时候去调用了一个方法 resize：

```java
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldCap = oldTab.length) == 0 ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // ... 后续代码
}
```

我们可以看到，table 在这里进行了初始化，也就是第一次赋值的时候完成。

## 验证问题

那么接下来我们使用代码来验证一下：

```java
public void some(String key) {
    Lock lock = "A".equals(key) ? lockA : lockB;
    lock.lock();
    try {
        Field field = HashMap.class.getDeclaredField("table");
        field.setAccessible(true);
        System.out.println("table = " + field.get(countMap));
        // 直接结束掉程序，避免日志过多无法看到输出
        System.exit(0);
        int integer = countMap.getOrDefault(key, 0);
        countMap.put(key, integer + 1);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}
```

## 根本原因

到此我们就弄明白了这次的问题，由于两个 Key 的两把锁同时进入了 resize 方法：

1. **thread-0** 初始化 → putVal
2. **thread-1** 初始化（这个时候覆盖掉了 thread-0 put 的 val）→ putVal

所以就会导致我们看到的结果总是会少 1。

## 总结

### 问题核心

这个问题的根本原因在于：

1. **HashMap 的懒加载机制**：table 数组在第一次使用时才初始化
2. **resize 操作的线程不安全**：多个线程同时触发 resize 时会发生数据覆盖
3. **锁粒度问题**：即使为不同 key 使用不同锁，也无法解决 HashMap 内部的线程安全问题

### 解决方案

1. **使用 ConcurrentHashMap**：专门为并发场景设计
2. **对整个 Map 操作加锁**：确保整个操作过程的同步性
3. **使用 Collections.synchronizedMap()**：包装 HashMap 使其线程安全

### 经验教训

- 多线程环境下，即使使用了锁，也要考虑被保护对象的内部实现
- HashMap 不是线程安全的，在高并发场景下应使用 ConcurrentHashMap
- 锁的粒度要合理，不能只保护部分操作而忽略对象内部的线程安全问题

才疏学浅，如果错误还望指教。
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>Java</category>
            <category>多线程</category>
        </item>
        <item>
            <title><![CDATA[使用注解处理器自动生成 Dao 代码]]></title>
            <link>https://blog.oofo.cc/posts/annotation-processor-dao-generator</link>
            <guid>https://blog.oofo.cc/posts/annotation-processor-dao-generator</guid>
            <pubDate>Thu, 24 Jun 2021 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 背景介绍

在我们一般的 SpringBoot 项目中，由于使用了 MybatisPlus 或者 JPA，很多增删改查都是定一个空的 Mapper 或者 Dao 就可以实现的。这里比较懒，参考 Lombok 的原理实现自动生成 Dao 代码。

## 核心思路

通过注解处理器（Annotation Processor）在编译时自动生成 Dao 接口，避免手动创建重复的代码。

## 项目...]]></description>
            <content:encoded><![CDATA[
## 背景介绍

在我们一般的 SpringBoot 项目中，由于使用了 MybatisPlus 或者 JPA，很多增删改查都是定一个空的 Mapper 或者 Dao 就可以实现的。这里比较懒，参考 Lombok 的原理实现自动生成 Dao 代码。

## 核心思路

通过注解处理器（Annotation Processor）在编译时自动生成 Dao 接口，避免手动创建重复的代码。

## 项目结构

注解处理器的项目必须是单独的一个项目，我们需要创建一个 Maven 项目。

## 实现步骤

### 1. 创建 Maven 项目

首先创建一个独立的 Maven 项目用于注解处理器。

### 2. 添加依赖

在 `pom.xml` 中添加 Google 的 auto-service 类库：

```xml
<dependency>
    <groupId>com.google.auto.service</groupId>
    <artifactId>auto-service</artifactId>
    <version>1.0.1</version>
</dependency>
```

### 3. 定义注解

```java
package com.yuxuan66.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author Sir丶雨轩
 * @since 2021/6/24
 */
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.CLASS)
public @interface Dao {

}
```

### 4. 实现注解处理器

```java
package com.yuxuan66.processor;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.IoUtil;
import com.google.auto.service.AutoService;
import com.google.common.primitives.Chars;
import com.yuxuan66.annotation.Dao;
import org.beetl.core.Configuration;
import org.beetl.core.GroupTemplate;
import org.beetl.core.Template;
import org.beetl.core.resource.ClasspathResourceLoader;
import org.beetl.core.resource.StringTemplateResourceLoader;
import sun.nio.ch.IOUtil;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.InputStream;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;

/**
 * @author Sir丶雨轩
 * @since 2021/6/24
 */
@AutoService(Processor.class)
public class DaoProcessor extends AbstractProcessor {

    private Types mTypeUtils;
    private Elements mElementUtils;
    private Filer mFiler;
    private Messager messager;

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> annotations = new LinkedHashSet<>();
        //把我们自己定义的注解添加进去
        annotations.add(Dao.class.getCanonicalName());
        return annotations;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);

        mTypeUtils = processingEnv.getTypeUtils();
        mElementUtils = processingEnv.getElementUtils();
        mFiler = processingEnv.getFiler();
        messager = processingEnv.getMessager();
    }
    
    private void error(Element e, String msg, Object... args) {
        messager.printMessage(Diagnostic.Kind.ERROR, String.format(msg, args), e);
    }
    
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (Element annotatedElement : roundEnv.getElementsAnnotatedWith(Dao.class)) {
            if (annotatedElement.getKind() != ElementKind.CLASS) {
                error(annotatedElement, "Only classes can be annotated with @%s", Dao.class.getSimpleName());
                return true;
            }
            // //解析，并生成代码
            try {
                analysisAnnotated(annotatedElement);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void analysisAnnotated(Element classElement) throws IOException {
        Dao annotation = classElement.getAnnotation(Dao.class);

        InputStream inputStream = this.getClass().getResourceAsStream("/template/Dao.btl");

        String daoContent = IoUtil.read(Objects.requireNonNull(inputStream), StandardCharsets.UTF_8);
        // 获取注解所在的包，把Dao生成到原始包中，或者按照需求生成在对应的包中
        String packageName = mElementUtils.getPackageOf(classElement).getQualifiedName().toString();

        daoContent = daoContent.replaceAll("#date", DateUtil.format(new Date(),"yyyy/MM/dd"));
        daoContent = daoContent.replaceAll("#package",packageName);
        daoContent = daoContent.replaceAll("#className",classElement.getSimpleName().toString());

        try {
            JavaFileObject source = mFiler.createSourceFile(packageName + "." + classElement.getSimpleName() + "Dao");
            Writer writer = source.openWriter();
            writer.write(daoContent);
            writer.flush();
            writer.close();
        } catch (IOException ignored) {
        }
    }
}
```

### 5. 创建模板文件

在 `src/main/resources/template/` 目录下创建 `Dao.btl` 模板文件：

```java
package #package;

import #package.#className;
import com.yuxuan66.support.basic.jpa.BasicDao;

/**
 * @author  Sir丶雨轩
 * @since #date
 */
public interface #classNameDao extends BasicDao<#className> {

}
```

## 核心原理

### AutoService 注解

```java
@AutoService(Processor.class)
final class MyProcessor implements Processor {
  // …
}
```

`@AutoService` 会自动在 `build/classes` 输入目录下生成文件 `META-INF/services/javax.annotation.processing.Processor`，文件的内容如下：

```
foo.bar.MyProcessor
```

在 `javax.annotation.processing.Processor` 的情况下，如果一个 jar 中包含 metadata 文件，并且在 javac 的 classpath 下，javac 会自动加载这个 jar，同时包含它的普通注解编译环境。

### 注解处理器工作流程

1. **编译时扫描**：javac 在编译时扫描带有 `@Dao` 注解的类
2. **代码生成**：处理器读取模板文件，替换占位符
3. **文件输出**：生成对应的 Dao 接口文件
4. **自动编译**：生成的代码会被自动编译

## 使用方式

### 1. 在实体类上添加注解

```java
package com.example.entity;

import com.yuxuan66.annotation.Dao;

/**
 * 用户实体类
 */
@Dao
public class User {
    private Long id;
    private String name;
    private String email;
    
    // getter/setter 方法...
}
```

### 2. 编译项目

```bash
mvn clean compile
```

### 3. 查看生成的代码

编译后会在 `target/generated-sources/annotations` 目录下生成对应的 Dao 接口：

```java
package com.example.entity;

import com.example.entity.User;
import com.yuxuan66.support.basic.jpa.BasicDao;

/**
 * @author  Sir丶雨轩
 * @since 2024/11/22
 */
public interface UserDao extends BasicDao<User> {

}
```

## 高级用法

### 1. 自定义模板

可以根据不同的需求创建不同的模板：

```java
// 模板文件：Dao.btl
package #package;

import #package.#className;
import com.yuxuan66.support.basic.jpa.BasicDao;
import org.springframework.stereotype.Repository;

/**
 * @author  Sir丶雨轩
 * @since #date
 */
@Repository
public interface #classNameDao extends BasicDao<#className> {

}
```

### 2. 支持复杂模板

对于复杂的需求，可以使用模板引擎（如 Beetl）：

```java
// 使用 Beetl 模板引擎
StringTemplateResourceLoader resourceLoader = new StringTemplateResourceLoader();
Configuration cfg = Configuration.defaultConfiguration();
GroupTemplate gt = new GroupTemplate(resourceLoader, cfg);

Template template = gt.getTemplate(daoContent);
template.binding("packageName", packageName);
template.binding("className", className);
template.binding("date", DateUtil.format(new Date(),"yyyy/MM/dd"));

String result = template.render();
```

### 3. 多模板支持

```java
private void analysisAnnotated(Element classElement) throws IOException {
    Dao annotation = classElement.getAnnotation(Dao.class);
    
    // 根据注解参数选择不同模板
    String templateName = annotation.template().isEmpty() ? "Dao.btl" : annotation.template();
    InputStream inputStream = this.getClass().getResourceAsStream("/template/" + templateName);
    
    // 处理模板...
}
```

## 项目集成

### 1. 发布到 Maven 仓库

```xml
<distributionManagement>
    <repository>
        <id>releases</id>
        <url>http://your-nexus/repository/maven-releases/</url>
    </repository>
</distributionManagement>
```

### 2. 在其他项目中使用

```xml
<dependency>
    <groupId>com.yuxuan66</groupId>
    <artifactId>dao-generator</artifactId>
    <version>1.0.0</version>
    <scope>provided</scope>
</dependency>
```

### 3. 配置编译插件

```xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.1</version>
    <configuration>
        <source>8</source>
        <target>8</target>
        <annotationProcessorPaths>
            <path>
                <groupId>com.yuxuan66</groupId>
                <artifactId>dao-generator</artifactId>
                <version>1.0.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
```

## 优势特点

### 1. 减少重复代码

- **自动化生成**：无需手动创建重复的 Dao 接口
- **一致性保证**：生成的代码格式统一，减少人为错误

### 2. 提升开发效率

- **快速开发**：添加注解即可自动生成代码
- **维护简单**：模板修改后重新编译即可

### 3. 灵活可扩展

- **模板化**：支持自定义模板
- **可配置**：支持多种生成策略

## 注意事项

### 1. 编译顺序

- 注解处理器项目需要先编译
- 使用项目需要正确配置依赖

### 2. 模板路径

- 确保模板文件在正确的 classpath 路径下
- 模板文件编码使用 UTF-8

### 3. 错误处理

- 添加适当的错误处理和日志记录
- 处理模板文件不存在的情况

## 总结

通过注解处理器自动生成 Dao 代码，我们成功实现了：

1. **减少重复编码**：避免手动创建重复的 Dao 接口
2. **提升开发效率**：添加注解即可自动生成代码
3. **保持代码一致性**：生成的代码格式统一
4. **易于维护**：模板化设计，便于修改和扩展

这种方案参考了 Lombok 的设计思路，在编译时自动生成代码，既保证了开发效率，又避免了运行时的性能开销。生成的 Dao 代码就像自己创建的一样，也可以被 Spring 管理，完美集成到现有的开发流程中。
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>Java</category>
            <category>注解处理器</category>
            <category>MyBatisPlus</category>
        </item>
        <item>
            <title><![CDATA[SpringBoot 自定义参数解析器实现全局参数RSA加密]]></title>
            <link>https://blog.oofo.cc/posts/springboot-rsa-param-resolver</link>
            <guid>https://blog.oofo.cc/posts/springboot-rsa-param-resolver</guid>
            <pubDate>Wed, 23 Jun 2021 00:00:00 GMT</pubDate>
            <description><![CDATA[
## 前端加密实现

首先前端这边使用 Vue 全局封装 axios 使所有请求的参数进行 RSA 加密：

```javascript
const instance = axios.create({
  baseURL,
  timeout: requestTimeout,
  headers: {
    'Content-Type': contentType,
  },
})

insta...]]></description>
            <content:encoded><![CDATA[
## 前端加密实现

首先前端这边使用 Vue 全局封装 axios 使所有请求的参数进行 RSA 加密：

```javascript
const instance = axios.create({
  baseURL,
  timeout: requestTimeout,
  headers: {
    'Content-Type': contentType,
  },
})

instance.interceptors.request.use(
  (config) => {
    if (
      config.data &&
      config.headers['Content-Type'] ===
        'application/x-www-form-urlencoded;charset=UTF-8'
    ) {
      config.data = qs.stringify(config.data)
    } else {
      encryptedStr(JSON.stringify(config.data)).then((res) => {
        config.data = res
      })
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)
```

## 核心问题分析

由于请求的数据是通过 body 的形式传递的，所以我们需要在参数解析器中获取到加密后的数据。但是我们知道 Request IO 流是只能读取一次的，为了避免我们在读取后导致别的拦截器或框架无法读取，所以我们要先解决这个问题。

### 为什么 HttpServletRequest 的流只能读取一次？

调用 `httpServletRequest.getInputStream()` 可以看到获取的流类型为 `ServletInputStream`，继承 `InputStream`。

下面复习下 `InputStream`，`InputStream` 的 `read` 方法内部有一个 `position`，标志当前流读取到的位置，每读取一次，位置就会移动一次，如果读到最后，`read()` 会返回 -1，标志已经读取完了。如果想要重新读取则需要重写 `reset()` 方法，当然能否 reset 是有条件的，它取决于 `markSupported()` 是否返回 true。

在 `InputStream` 源码中默认不实现 `reset()`，并且 `markSupported()` 默认返回 false：

```java
public synchronized void reset() throws IOException {
    // 调用重新读取则抛出异常
    throw new IOException("mark/reset not supported");
}

public boolean markSupported() {
    // 不支持重新读取
    return false;
}
```

而查看 `ServletInputStream` 源码可以发现，该类没有重写 `mark()`、`reset()` 以及 `markSupported()`，因此 Request IO 流无法重复读取：

```java
public abstract class ServletInputStream extends InputStream {
    protected ServletInputStream() {
    }

    public int readLine(byte[] b, int off, int len) throws IOException {
        if (len <= 0) {
            return 0;
        } else {
            int count = 0;
            int c;
            while((c = this.read()) != -1) {
                b[off++] = (byte)c;
                ++count;
                if (c == 10 || count == len) {
                    break;
                }
            }
            return count > 0 ? count : -1;
        }
    }

    public abstract boolean isFinished();
    public abstract boolean isReady();
    public abstract void setReadListener(ReadListener var1);
}
```

## 解决方案

### 使用 HttpServletRequestWrapper

既然 `ServletInputStream` 不支持重新读写，那么为什么不把流读出来后用容器存储起来，后面就可以多次利用了。

所幸 Java 提供了一个请求包装器：`HttpServletRequestWrapper` 基于装饰者模式实现了 `HttpServletRequest` 接口，只需要继承该类并实现你想要重新定义的方法即可。

### 定义包装器

```java
package com.yuxuan66.support.request;

import org.apache.tomcat.util.http.fileupload.IOUtils;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;

/**
 * @author Sir丶雨轩
 * @since 2021/6/23
 */
public class RequestWrapper extends HttpServletRequestWrapper {

    /**
     * 参数字节数组
     */
    private byte[] requestBody;

    /**
     * Http请求对象
     */
    private final HttpServletRequest request;

    public RequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.request = request;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        /**
         * 每次调用此方法时将数据流中的数据读取出来，然后再回填到InputStream之中
         * 解决通过@RequestBody和@RequestParam（POST方式）读取一次后控制器拿不到参数问题
         */
        if (null == this.requestBody) {
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            IOUtils.copy(request.getInputStream(), byteArrayOutputStream);
            this.requestBody = byteArrayOutputStream.toByteArray();
        }

        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody);
        return new ServletInputStream() {

            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {

            }

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}
```

### 定义过滤器

```java
package com.yuxuan66.support.request;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * @author Sir丶雨轩
 * @since 2021/6/23
 */
@WebFilter(filterName = "channelFilter", urlPatterns = "/*")
public class ChannelFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        try {
            ServletRequest requestWrapper = null;
            if (servletRequest instanceof HttpServletRequest) {
                requestWrapper = new RequestWrapper((HttpServletRequest) servletRequest);
            }
            if (requestWrapper == null) {
                filterChain.doFilter(servletRequest, servletResponse);
            } else {
                filterChain.doFilter(requestWrapper, servletResponse);
            }
        } catch (IOException | ServletException e) {
            e.printStackTrace();
        }
    }
}
```

## 注解和基础实体类

### 定义注解

```java
package com.yuxuan66.support.argument.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author Sir丶雨轩
 * @since 2021/6/23
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RsaParam {
}
```

### 定义基础实体类

```java
package com.yuxuan66.support.basic.model;

/**
 * @author Sir丶雨轩
 * @since 2021/6/23
 */
public class BasicParam {
}
```

## 核心参数解析器

```java
package com.yuxuan66.support.argument;

import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.RSA;
import cn.hutool.json.JSONUtil;
import com.yuxuan66.config.RsaProperties;
import com.yuxuan66.support.argument.annotation.RsaParam;
import com.yuxuan66.support.basic.model.BasicParam;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.util.Objects;

/**
 * @author Sir丶雨轩
 * @since 2021/6/23
 */
public class ParamArgumentResolvers implements HandlerMethodArgumentResolver {

    // 是否满足条件，这里我们判断，参数包含注解或者继承与我们基础实体的
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        return methodParameter.getParameter().isAnnotationPresent(RsaParam.class) || BasicParam.class.isAssignableFrom(methodParameter.getParameterType());
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        HttpServletRequest request = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
        StringBuilder data = new StringBuilder();
        String line;
        BufferedReader reader = Objects.requireNonNull(request).getReader();
        while (null != (line = reader.readLine())) {
            data.append(line);
        }
        // 读取数据解密转为Bean
        RSA rsa = new RSA(RsaProperties.privateKey, null);
        return JSONUtil.toBean(rsa.decryptStr(data.toString(), KeyType.PrivateKey), methodParameter.getParameterType());
    }
}
```

## 配置参数解析器

```java
package com.yuxuan66.config;

import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import com.yuxuan66.support.argument.ParamArgumentResolvers;
import org.springframework.context.annotation.Configuration;

/**
 * 系统核心配置类
 * @author Sir丶雨轩
 * @since 2021/06/17
 */
@Configuration
public class AdminConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new ParamArgumentResolvers());
    }
}
```

## 使用方式

使用的时候我们只需要加上注解或者继承 `BasicParam` 即可自动解密：

```java
@PostMapping(path = "/login")
public RespEntity login(@RsaParam LoginParam loginParam) {
    return authService.login(loginParam);
}
```

## 实现原理总结

1. **前端加密**：使用 Vue 的 axios 拦截器对所有请求参数进行 RSA 加密
2. **流重复读取**：通过 `HttpServletRequestWrapper` 解决 Request IO 流只能读取一次的问题
3. **参数解析**：自定义 `HandlerMethodArgumentResolver` 实现参数的自动解密
4. **注解标识**：使用 `@RsaParam` 注解标识需要解密的参数
5. **自动转换**：将解密后的 JSON 字符串自动转换为对应的 Java 对象

通过这种方式，我们实现了全局请求参数的 RSA 加密解密功能，既保证了数据的安全性，又保持了代码的简洁性。
]]></content:encoded>
            <author>dev@oofo.cc (Sir丶雨轩)</author>
            <category>SpringBoot</category>
            <category>参数解析器</category>
        </item>
    </channel>
</rss>