Windows11 本地安装并使用 OpenClaw（原 clawdbot）教程
简介OpenClaw 是一款基于 AI 的本地化助手工具，前身为 clawdbot。它可以帮助用户在本地环境中快速部署和使用大语言模型，支持多种国内外主流 AI 模型接入。安装步骤1. 安装 Node.js 22.x首先需要安装 Node.js 运行环境。访问 Node.js 官网下载 22.x 版本的安装包，按照向导完成安装。2. 安装 OpenClaw打开命令行工具，执行以下命令进行全局安装：npm install -g openclaw@latest3. 配置 OpenClaw安装完成后需要进行初始化配置。注意：以下操作需要在管理员权限的终端中运行执行配置命令：openclaw onboard --install-daemon3.1 选择启动模块在配置过程中，选择"快速开始"模块，这样可以快速完成基础配置。3.2 选择 AI 模型这里选择通义千问（Qwen）模型，当然也可以根据需要选择其他国内外的大模型。3.3 登录验证选择通义千问的登录验证方式。系统会自动打开浏览器进行登录验证，完成授权后即可继续。3.4 选择默认模型在可用的模型列表中选择一个作为默认使用的模型。3.5 跳过高级配置对于初次使用，可以暂时跳过 channel 的配置。同样，skills 和 hooks 也可以暂时不配置，后续有需要再添加。3.6 安装守护进程选择 reinstall 来安装或重新安装守护进程。4. 启动和使用配置完成后，OpenClaw 会自动启动。系统会自动在浏览器中打开 OpenClaw 的 Web 界面。5. 验证安装在聊天界面发送一条消息，如果收到 AI 的回复，就说明安装配置成功了。总结至此，OpenClaw 已经在 Windows 系统上成功部署。整个过程主要包括安装 Node.js 环境、全局安装 OpenClaw、配置模型和认证信息、安装守护进程等步骤。安装完成后就可以在本地使用各种 AI 大模型功能了。由于很消耗token，所以我还没有过多使用它的技能。
UnlockMusic 本地构建、服务器部署与浏览器扩展制作教程
本地构建前置环境配置核心依赖Node.js: 运行环境请务必锁定为 v16.x 版本。该项目暂不支持 v17 及更高版本，强行使用会导致编译失败。npm: 推荐直接使用 Node.js v16 自带的包管理工具（npm v8.x），以规避潜在的版本冲突。警告: 本项目基于 Vue CLI 4 与 Webpack 4 开发，与 Node.js v17+ 存在底层不兼容。若版本不匹配，编译流程将无法正常完成。WASM 组件（可选）操作系统：Linux环境支持：Python 3在线预览地址：unlock-music详细操作步骤1. 获取源代码首先把代码拉取到本地：git clone https://git.unlock-music.dev/um/web.git或者在项目仓库下载。2. 确认 Node 版本在终端输入命令检查当前版本：node --version如果环境不符合 v16.x 要求，请按以下方式切换：通过 nvm 管理（推荐）：# 安装 Node.js v16 nvm install 16 # 切换到 v16 环境 nvm use 16（以上命令在 Windows/Linux/macOS 上均适用，nvm 的具体安装请参照对应平台的指南。）手动安装：若未安装 nvm，请前往 Node.js 历史版本库 获取安装包。3. 清理旧依赖（建议执行）如果你之前用其他 Node 版本运行过该项目，为稳妥起见，建议先清理旧的依赖文件：# Windows PowerShell 环境 Remove-Item -Recurse -Force node_modules Remove-Item -Force package-lock.json # Linux/macOS 环境 rm -rf node_modules package-lock.json4. 安装项目依赖执行安装命令：npm install注：请使用 npm install 而非 npm ci，因为在首次构建或切换 Node 版本后，通常需要重新生成 lock 文件。5. 开始构建5.1 开发模式（用于调试）如果你想边修改代码边预览效果，运行：npm run serve启动后，在浏览器中访问 http://localhost:8080 即可实时查看。5.2 生产环境构建（用于部署）如果你需要将应用部署到服务器，运行：npm run build构建完成后，所有生成的文件将位于 dist 目录中，将其上传至服务器即可运行。6. 制作浏览器插件（可选）如果你想将其打包为浏览器扩展程序，运行：npm run make-extension构建成功后会在 dist 目录生成三个扩展文件：popup.html、popup.js 和 manifest.json常见报错及解决方法报错 1: error:0308010C:digital envelope routines::unsupported原因：典型的 OpenSSL 兼容性问题，通常由 Node.js 版本过高引起。解决：将 Node.js 降级至 v16.x 即可。报错 2: parser.state.module.addPresentationalDependency is not a function原因：全局安装了 Webpack 5，与项目内的 Webpack 4 产生冲突。解决：# 1. 移除全局冲突的 webpack npm uninstall -g webpack # 2. 彻底清理并重装项目依赖 rm -rf node_modules package-lock.json npm install报错 3: npm 环境警告如果看到类似 npm warn cli npm v11.x.x does not support Node.js v16.x.x 的提示，通常可以忽略，这仅是版本跨度提示，一般不影响最终构建结果。目录结构说明构建完成后，dist 目录结构大致如下：dist/ ├── index.html # 应用主入口页面 ├── loader.js # 资源预加载脚本 ├── css/ # 样式文件目录 ├── js/ # 核心逻辑及 Web Worker 脚本 ├── img/ # 图片等静态资源 └── service-worker.js # 离线应用支持脚本部署方案选择方案一：Nginx 静态托管将 dist 目录下的所有文件上传至你的 Nginx、Apache 服务器或任意静态托管服务/CDN。基础 Nginx 配置参考：server { listen 80; server_name your-domain.com; root /path/to/your/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }方案二：Docker 容器化部署使用项目提供的 Dockerfile 快速构建镜像并运行：docker build -t unlock-music-web . docker run -d -p 80:80 unlock-music-web方案三：安装浏览器扩展按照以下步骤，将构建好的扩展加载到浏览器中，即可在任意网页上便捷使用。Chrome / Edge 浏览器安装步骤：打开浏览器扩展管理页面开启开发者模式在页面右上角找到并打开开发者模式开关加载扩展点击“加载已解压的扩展程序”按钮在弹出的文件选择窗口中，定位并选择项目的 dist 目录点击“选择文件夹”完成加载确认安装成功扩展列表中会出现“音乐解锁”扩展卡片确保扩展处于启用状态Firefox 浏览器安装步骤：打开调试页面地址栏输入 about:debugging#/runtime/this-firefox临时加载附加组件点击“临时载入附加组件”按钮选择 dist 目录下的 manifest.json 文件点击“打开”完成加载使用扩展：打开后和网页版的一样注意：通过开发者模式加载的扩展在浏览器重启后可能需要重新加载。如需长期使用，建议通过官方扩展商店安装正式版本。方案四：本地离线运行直接下载并解压已构建好的产物包，在浏览器中打开 index.html 文件即可运行（注：此方式通常仅适用于 legacy 版本）。安全与使用须知隐私保护纯本地运算：所有解密操作均在您的浏览器内部完成，程序不会将任何音频文件上传至服务器。网络请求：仅在需要在线匹配专辑封面或同步元数据时，才会发起极少量的网络请求。环境建议：为保障数据传输安全，建议在 HTTPS 协议下访问。同时，HTTPS 环境也能启用 Web Worker 以获得更好的并行处理性能。效能建议处理大量文件：如果您需要处理数以万计的音乐文件，建议使用 CLI 命令行版本，效率更高。浏览器兼容性：推荐使用最新版本的 Chrome、Edge 或 Firefox 浏览器，以获得最佳的性能和兼容性。
彻底清除与飞牛OS共存的宝塔面板教程
为什么必须彻底清理？宝塔面板不仅安装服务，还会：锁定关键文件（.user.ini 加 chattr +i）启用 AppArmor/SELinux 安全策略修改 systemd 服务和内核参数即使“卸载”，仍会残留进程和配置彻底清除步骤第一步：运行宝塔官方卸载脚本wget http://download.bt.cn/install/bt-uninstall.sh sudo bash bt-uninstall.sh→ 选择 【2】卸载宝塔及运行环境第二步：清除顽固进程# 强制终止残留进程 sudo pkill -f "site_total" sudo pkill -f "php-fpm" # 验证是否清除 ps aux | grep -E 'site_total|php-fpm' | grep -v grep"第三步：解除文件锁定并删除 /www[hide]# 移除 immutable 属性（关键！） sudo find /www -name ".user.ini" -exec chattr -i {} \; 2>/dev/null # 删除主目录 sudo rm -rf /www # 验证 ls /www 2>/dev/null && echo "未删干净" || echo "/www 已删除"[/hide]第四步：禁用 AppArmor（防止 Docker 启动失败）# 临时清理策略 sudo aa-teardown # 永久禁用（编辑 GRUB） sudo nano /etc/default/grub将：GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"改为：GRUB_CMDLINE_LINUX_DEFAULT="quiet splash apparmor=0 security=none"保存后执行：sudo update-grub sudo reboot第五步：重置 Docker 环境（可选但推荐）sudo systemctl stop docker sudo rm -rf /vol1/docker sudo mkdir -p /vol1/docker/volumes sudo chown root:docker /vol1/docker sudo chmod 775 /vol1/docker sudo systemctl start docker重要提醒如需建站，请使用飞牛「应用中心」中的官方应用宝塔保持系统纯净，才能确保长期稳定运行
解决飞牛nas更新后提示“数据库读写错误”无法挂载硬盘
问题及原因升级到飞牛OS 1.1.11 后，在 Web 界面挂载硬盘时提示 “数据库读写错误”。飞牛OS 1.1.11 版本 默认启用了 AppArmor 安全模块，但未正确配置对 /vol1 目录（用户数据盘）的访问权限。 这导致：trim_license 服务无法写入 PostgreSQL 数据库Docker 无法创建 volume 元数据（metadata.db）硬盘挂载流程因数据库操作失败而中断解决方案一：禁用 AppArmor 内核安全模块此操作仅在飞牛OS环境下安全，不建议在通用 Linux 服务器上使用。步骤 1：通过 SSH 登录飞牛OSssh admin@你的飞牛IP步骤 2：编辑 GRUB 启动参数使用root权限时需要输入密码（密码输入时不会显示）sudo nano /etc/default/grub找到以下行：GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"修改为：[hide]GRUB_CMDLINE_LINUX_DEFAULT="quiet splash apparmor=0 security=none"[/hide]按 Ctrl+O → 回车保存，Ctrl+X 退出。步骤 3：更新引导配置并重启sudo update-grub sudo reboot步骤 4：验证修复结果系统重启后，登录 Web 界面：尝试重新挂载硬盘应不再出现“数据库读写失败”错误可选验证命令（SSH 中执行）：systemctl is-active trim_license postgresql@15-main补充说明禁用 apparmor=0 不会影响飞牛OS核心功能，反而能解决兼容性问题若未来官方修复此问题，可通过恢复 GRUB 参数重新启用 AppArmor方案二同样SSH远程连接后，使用命令sudo -i切换到管理员权限然后执行官方修复脚本：curl -L https://static2.fnnas.com/aptfix/trim-sec -o trim-sec && chmod +x trim-sec && ./trim-sec最后方案一是我升级1.1.11时此问题的解决办法，解决后再次升级1.1.18并未出现这个问题。方案二是我在飞牛论坛找到的，专门解决1.1.18的。
飞牛NAS更新后无法登录（502/系统内部错误）解决方案
前言在25年11月，我将飞牛os从0.9.35更新到1.x.x后，发现登不进去了。然后我一直怀疑是飞牛frpc服务未重启，就让家里人手动重启了几次，但都不行。最后也是在寒假第一天晚上修复了，不是frp的问题。问题现象浏览器访问 Web 界面时卡在登录页控制台报错：GET http://192.168.1.9:5666/license/v1/device/baseInfo 502 (Bad Gateway)SSH 登录后发现 trim_license.service 反复崩溃解决步骤第一步：确认核心服务状态# 检查飞牛相关服务 systemctl list-units --type=service | grep trim # 检查 Nginx（应为 running） systemctl status nginx # 检查 PostgreSQL 是否安装 systemctl list-unit-files | grep postgresql预期输出：存在 postgresql.service 且为 enabled，但 trim_license 为 inactive 或反复重启。第二步：检查 PostgreSQL 集群状态# 查看 PostgreSQL 集群列表 sudo pg_lsclusters示例输出：Ver Cluster Port Status Owner Data directory Log file 15 main 5432 down postgres /var/lib/postgresql/15/main ...若状态为 down，说明数据库未运行。第三步：排查 PostgreSQL 启动失败原因尝试启动集群：sudo pg_ctlcluster 15 main start若失败，查看详细日志：sudo journalctl -xeu postgresql@15-main.service --no-pager | tail -n 30关键错误示例：could not bind IPv4 address "127.0.0.1": Address already in use FATAL: could not create any TCP/IP sockets表明 5432 端口已被占用。第四步：释放被占用的 5432 端口1. 查找占用端口的进程sudo ss -tulnp | grep :5432示例输出：tcp LISTEN ... 127.0.0.1:5432 ... users:(("postgres",pid=1358,fd=8))2. 终止残留进程sudo kill -9 1358 # 替换为实际 PID3. 清理残留 PID 文件sudo rm -f /var/lib/postgresql/15/main/postmaster.pid sudo rm -f /run/postgresql/*.pid第五步：启动 PostgreSQL 并验证# 启动正规集群 sudo pg_ctlcluster 15 main start # 验证 socket 文件生成 ls /var/run/postgresql/正常输出应包含：.s.PGSQL.5432 15-main.pid第六步：启动 License 服务# 启动授权服务 sudo systemctl start trim_license # 验证状态 systemctl status trim_license预期状态：Active: active (running)第七步：设置开机自启（防止重启后复现）# 启用 PostgreSQL 集群自启 sudo systemctl enable postgresql@15-main # 确保 license 服务自启（通常已启用） sudo systemctl enable trim_license验证与总结输入账号密码，登录成功适用场景：飞牛OS系统更新后，Web 管理界面无法登录，提示 502 Bad Gateway 或“系统内部错误”，且浏览器开发者工具中可见 /license/v1/device/baseInfo 请求失败。 根本原因：trim_license 服务依赖 PostgreSQL 数据库，但更新后 PostgreSQL 未正常运行或端口被残留进程占用，导致授权服务启动失败。附：完整修复命令汇总（可一键复制执行）[hide]# 1. 查看并终止占用 5432 的进程 sudo ss -tulnp | grep :5432 sudo kill -9 1358 # 替换为实际 PID # 2. 清理残留文件 sudo rm -f /var/lib/postgresql/15/main/postmaster.pid sudo rm -f /run/postgresql/*.pid # 3. 启动 PostgreSQL 集群 sudo pg_ctlcluster 15 main start # 4. 验证 socket ls /var/run/postgresql/ # 5. 启动 license 服务 sudo systemctl start trim_license # 6. 设置开机自启 sudo systemctl enable postgresql@15-main sudo systemctl enable trim_license # 7. 检查状态 systemctl status trim_license[/hide]
LoadRunner 在录制脚本时经常会遇到网络连接失败、回放报错等问题，这些问题往往与本地代理配置、防火墙设置或浏览器缓存有关。本文从实际场景出发，提供完整的排查与解决方案。前言期末作业需要用LoadRunner对上学期开发的项目做性能测试。我早早的做完了作业并完成了答辩，后面陆续帮助舍友和同学解决了录制等问题，把遇到的问题和解决方案都给总结了一下，发了三篇简单的教程。在解决了五六位同学的问题后，发现对每个人的问题和方法都不太一样。所以下面这些解决办法，建议可以每个都尝试一下，大概率可以得到解决。一、录制阶段：无法连接网络问题问题表现使用 LoadRunner 12.55 录制时，启动后会提示 "无 Internet 连接"。排查思路LoadRunner 录制通过拦截浏览器的 HTTP(S) 流量实现，因此本地防火墙、代理软件都可能干扰流量捕获。应首先排除系统层面的干扰，再根据是否使用代理进行配置。解决方案一：关闭本地防火墙适用场景： 所有情况下优先检查 防火墙（如 Microsoft Defender、火绒等）可能会阻止 LoadRunner 的网络监听行为。打开 Windows 安全中心 -> 防火墙和网络保护。临时关闭所有网络配置文件的防火墙。解决方案二：配置 HTTP 代理（根据本地环境选择）场景 A：本地未安装任何代理软件操作步骤：进入 Recording Options -> HTTP Properties -> Advanced。在代理设置中选择使用LoadRunner代理服务器。场景 B：本地安装了代理软件（如 Clash、Clash Verge）操作步骤：配置端口映射 进入 Recording Options -> Network -> Port Mapping，添加目标服务器：目标服务器地址：被测网站的域名（如 petad.xuan.cyou）目标端口：2000（或被测网站实际端口，如 80）筛选级别：选择 代码生成配置代理服务器 进入 Runtime Settings -> Internet Protocol -> Proxy -> Use Custom Proxy：代理地址：127.0.0.1代理端口：Clash：7890Clash Verge：7897解决方案三：清除浏览器缓存适用场景： 被测网站在录制浏览器中存在登录态或缓存数据 浏览器缓存可能导致 LoadRunner 无法正确拦截首次请求。在录制浏览器中按 Ctrl + Shift + Delete，清除所有浏览数据。成功标志录制开始后，浏览器访问网站时会提示 "您的连接不是私密连接"（SSL 证书警告），点击 "继续访问" 即可正常录制。二、回放阶段：常见错误及解决方案错误 1：录制后无代码生成问题描述： 录制过程正常，但停止后 Action 文件为空。 解决方案： 这通常是端口映射或捕获级别问题，详细排查方法请参考：[post cid="1402" /]错误 2：SSL Protocol Error完整报错信息： SSL protocol error when attempting to connect with host "127.0.0.1"原因分析： 回放时使用套接字（Socket）模式与 SSL 握手不兼容。解决方法： 进入 Runtime Settings -> Internet Protocol -> Preferences，勾选 "Use WinInet replay instead of Sockets"（使用 WinINet 回放而非套接字）。错误 3：HTTP 415 (Unsupported Media Type)完整报错信息： 对于"https://petad.xuan.cyou/api/product/update"，HTTP 状态代码=415 (Unsupported Media Type)原因分析： 服务端要求请求头包含 Content-Type: application/json，但 LoadRunner 录制时未自动添加。解决方法： 在对应的 web_submit_data 或 web_custom_request 函数之前手动添加请求头：web_add_header("Content-Type", "application/json");三、总结与建议这软件真是一坨💩
LoadRunner 性能测试操作全流程：从脚本录制到结果分析
本教程使用的版本：12.55一、 脚本录制 (VUGen)1. 初始化与新建脚本首先打开 LoadRunner，选择 Virtual User Generator (VUGen)。在首页点击“新建解决方案”或直接创建新脚本。创建新脚本时，通常选择 Web - HTTP/HTML 协议。2. 配置录制环境点击录制按钮，在配置窗口中，应用程序（Application）选择对应的浏览器，并在 URL 地址栏输入被测网站的地址。3. 执行业务录制点击开始录制后，在浏览器中按照预定的业务流程进行操作。4. 插入事务为了精确衡量关键业务（如登录）的性能，需要在操作前后插入事务 (Transaction)。开始事务：以登录功能为例，在点击登录前点击“插入事务”并命名为 login。结束事务：成功登录后，点击“结束事务”并确认 login 事务结束。5. 结束录制与生成代码完成操作后停止录制，系统会自动生成脚本代码和录制报告。后续的性能优化和参数化工作将在生成的代码基础上进行。6. 录制回放校验在正式压测前，点击“回放 (Replay)”确保脚本在单用户环境下能正常跑通。回放成功意味着脚本逻辑基本正确。二、 场景创建 (Controller)1. 创建压测场景在 VUGen 工具栏中点击 工具 -> 创建场景，将脚本导入 Controller。设置场景名称并点击确定，即可完成初始化。三、 场景设计与负载配置本文以并发测试中的一个场景举例根据压测目标（如 TS-2-1.1 用例）配置具体的负载参数。1. 设置启动虚拟用户 (Ramp-up)配置启动的 Vuser 总数以及启动频率（如每隔一定时间增加固定用户），模拟压力平稳上升的过程。2. 设置持续时间 (Duration)设置系统在峰值压力下的持续运行时间，以便观察系统的稳定性和资源消耗情况。3. 设置停止虚拟用户 (Ramp-down)配置测试结束时用户的退出方式，建议采用分批退出的方式。四、 场景运行切换到“运行”选项卡，点击开始执行场景。测试完成后，Controller 将汇总所有实时监控数据。五、 结果分析 (Analysis)1. 启动分析引擎点击 结果 -> 分析结果，系统将启动 Analysis 组件对收集到的原始数据进行整理。2. 查看性能报告在分析报告中，重点关注摘要报告（Summary Report）中的事务响应时间、吞吐量（Throughput）和每秒点击率（Hits per Second）等核心指标。
前言这是一份关于如何将已部署的 Umami2.0 统计系统更新到 3.0 版本的详细教程。如果你是按照我之前的 [post cid="381" /]部署的，那么这篇教程将帮助你平滑升级到最新版本。更新前的准备1. 备份数据库在进行任何更新操作之前，强烈建议先备份数据库数据，以防万一出现问题可以快速回滚。# 进入到 Umami 目录 cd /www/wwwroot/visitors.ybyq.wang/umami # 备份数据库数据 docker-compose exec db pg_dump -U umami umami > umami_backup_$(date +%Y%m%d_%H%M%S).sql备份文件会保存在当前目录下，文件名包含时间戳，方便识别。2. 记录当前版本查看当前运行的 Umami 版本，以便后续确认更新是否成功：docker-compose exec umami cat /app/package.json | grep version更新步骤1. 进入项目目录cd /www/wwwroot/visitors.ybyq.wang/umami2. 停止当前运行的容器docker-compose down这个命令会停止并删除容器，但不会删除数据卷，你的统计数据是安全的。3. 拉取最新代码我已经同步了官方最新版本到 Gitee仓库，现在直接拉取更新：git pull origin master如果提示有本地修改冲突，可以先备份 docker-compose.yml 文件：cp docker-compose.yml docker-compose.yml.backup然后强制拉取：git fetch --all git reset --hard origin/master之后再恢复你的配置文件：cp docker-compose.yml.backup docker-compose.yml4. 检查配置文件Umami3.0 可能会有一些配置变化，检查 docker-compose.yml 文件是否需要更新。主要关注以下配置：[collapse status="false" title="展开查看 docker-compose.yml 配置示例"]version: '3' services: umami: image: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/docker.umami.is/umami-software/umami:postgresql-latest ports: - "3000:3000" environment: DATABASE_URL: postgresql://umami:你的密码@db:5432/umami DATABASE_TYPE: postgresql APP_SECRET: 5201314yyds depends_on: db: condition: service_healthy restart: always healthcheck: test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"] interval: 5s timeout: 5s retries: 5 db: image: postgres:15-alpine environment: POSTGRES_DB: umami POSTGRES_USER: umami POSTGRES_PASSWORD: 你的密码 volumes: - umami-db-data:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 volumes: umami-db-data:[/collapse]确保你的数据库密码等配置信息保持一致。5. 拉取最新镜像docker-compose pull这个命令会从 镜像站拉取最新的 Umami 镜像。6. 重新启动服务docker-compose up -d容器会自动完成数据库迁移和服务启动。7. 查看容器状态docker-compose ps确保所有容器都处于 Up 状态。8. 查看日志如果容器启动失败，可以查看日志排查问题：# 查看 Umami 容器日志 docker-compose logs -f umami # 查看数据库容器日志 docker-compose logs -f db按 Ctrl + C 退出日志查看。验证更新1. 检查版本号访问你的 Umami 网站，登录后台，在设置页面可以看到当前版本号。或者使用命令查看：docker-compose exec umami cat /app/package.json | grep versionUmami 3.0 新特性Umami3.0 带来了许多改进：全新的 UI 设计：更现代化的界面，更好的用户体验性能优化：查询速度更快，页面加载更流畅新增功能：改进的事件追踪更强大的过滤器自定义报告功能API 增强安全性提升：修复了已知的安全漏洞详细更新日志可以查看 Umami 官方发布说明。常见问题1. 更新后无法启动症状：执行 docker-compose up -d 后容器自动停止解决方法：# 查看详细日志 docker-compose logs umami # 如果是数据库连接问题，检查 DATABASE_URL 配置 # 如果是数据库迁移失败，可以尝试手动执行迁移 docker-compose exec umami npm run db-migrate2. 数据丢失症状：更新后统计数据消失解决方法：# 检查数据卷是否存在 docker volume ls | grep umami # 如果数据卷存在，检查数据库连接 docker-compose exec db psql -U umami -d umami -c "SELECT COUNT(*) FROM website;" # 如果需要恢复备份 docker-compose exec -T db psql -U umami -d umami < umami_backup_你的备份文件.sql3. 端口冲突症状：提示 3000 端口已被占用解决方法：修改 docker-compose.yml 中的端口映射：ports: - "3001:3000" # 将宿主机端口改为 3001然后更新宝塔反向代理配置，将目标地址改为 http://127.0.0.1:3001。4. 镜像拉取失败症状：docker-compose pull 时下载很慢或失败解决方法：首先确认镜像标签是否正确。Umami 官方不提供 latest，常用为按数据库区分的标签：Postgres：ghcr.io/umami-software/umami:postgresql-latestMySQL：ghcr.io/umami-software/umami:mysql-latest常见拼写错误：postgresgl-latest（少了 q）会导致 manifest unknown可配置 Docker 镜像加速，或固定到明确版本：image: ghcr.io/umami-software/umami:postgresql-v3.0.0 # 指定具体版本5. Docker 网络错误（iptables/DOCKER 链）症状：failed to create network ... Error response from daemon: Failed to Setup IP tables: Unable to enable SKIP DNAT rule: ... iptables: No chain/target/match by that name.解决方法（依次尝试）：# 1) 重建 Docker 的 iptables 链并清理网络 docker-compose down sudo systemctl restart docker docker network prune -f docker-compose up -d # 2) 若仍失败，切到 iptables-legacy（按发行版选择其中一组命令） # Debian/Ubuntu sudo update-alternatives --set iptables /usr/sbin/iptables-legacy sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy # CentOS/Alibaba Linux # sudo alternatives --set iptables /usr/sbin/iptables-legacy # sudo alternatives --set ip6tables /usr/sbin/ip6tables-legacy sudo systemctl restart docker docker-compose up -d # 3) 仍不行，确保内核模块已加载 sudo modprobe ip_tables iptable_nat sudo systemctl restart docker docker-compose up -d回滚操作如果更新后出现严重问题，可以回滚到之前的版本：1. 停止容器docker-compose down2. 恢复代码# 查看之前的提交记录 git log --oneline # 回退到之前的版本（替换 commit_id） git reset --hard <之前的commit_id>3. 恢复数据库（如果需要）# 启动数据库容器 docker-compose up -d db # 等待数据库启动 sleep 10 # 恢复备份 docker-compose exec -T db psql -U umami -d umami < umami_backup_你的备份文件.sql4. 重新启动服务docker-compose up -d总结Umami3.0 带来了许多改进和新特性，更新过程相对简单。只要做好备份，按照步骤操作，一般不会遇到大问题。如果遇到问题，可以参考常见问题部分，或在评论区留言交流。参考链接Umami GitHub 仓库Umami 官方文档Umami 3.0 发布说明Docker Compose 文档
前言在自定义主题的某天，AI摘要突然不显示了，前前后后改了好几次，通过重启插件无数次、移除全部自定义js和css、关闭pjax、一一恢复改过的页面代码，均未果。这次是通过本文第二个方法改好的，所以至今不知道突然不显示的原因。下面是解决AISummary插件的摘要块不显示的三种方案，前提是摘要内容已正确生成且保存。针对handsome主题，其他主题可借鉴思路。技术环境博客系统: Typecho 1.2.1主题: Handsome 10.1.0插件: AISummary v1.1.0解决方案方案一：修改插件核心逻辑直接修改 Plugin.php 中的 customContent 方法，将摘要插入到内容末尾：// 修改前 $content = $summaryString . $content; // 修改后 $content = $content . $summaryString;方案二：主题模板直接集成（推荐）在主题模板文件中直接添加摘要显示逻辑，绕过插件的 customContent 方法。实施步骤在插件设置中将"正文摘要显示样式"设置为"默认"或者"自定义"，推荐设置为自定义。在主题模板中添加摘要显示代码在 usr/themes/handsome/post.php 第74行的 下方添加：[hide] <?php // 添加AI摘要显示 $options = Typecho_Widget::widget('Widget_Options')->plugin('AISummary'); if ($options->summaryStyle !== '0') { $summaryField = $options->field ?: 'summary'; $db = Typecho_Db::get(); $row = $db->fetchRow($db->select('str_value')->from('table.fields')->where('cid = ?', $this->cid)->where('name = ?', $summaryField)); if ($row && !empty($row['str_value'])) { $pureSummary = str_replace("{{text}}", $row['str_value'], $options->prefix); // 根据插件配置选择样式 if ($options->summaryStyle === '1') { // 使用默认引用样式 $summaryString = ' ' . $pureSummary . ' '; } else if ($options->summaryStyle === '2') { // 使用自定义样式，只添加class，让CSS控制样式 $summaryString = ' ' . $pureSummary . ' '; } else { // 默认样式 $summaryString = ' ' . $pureSummary . ' '; } echo $summaryString; } } ?>[/hide]这种方式不依赖于Typecho的钩子系统，直接在主题模板中添加摘要显示代码。方案三：针对开启了前台Vditor解析方式第一种：1、打开文件：usr/themes/handsome/libs/content/PostContent.php2、找到第382行（大约在第380-385行之间）3、将$content = $obj->text;改为$content = $obj->content;4、保存文件$obj->text是vditor原始的Markdown文本，没有经过任何插件处理$obj->content是经过Typecho插件系统处理后的HTML内容，包括AISummary插件的customContent方法添加的摘要通过使用$obj->content，确保所有插件的钩子都能正常工作第二种：在handsome插件后台切换vidtor为typecho自带的即可下面是自定义模板的文章：[post cid="335" /]
使用 IntelliJ IDEA 创建 Spring Boot Web 项目（仅 Web 依赖）目标：用 IntelliJ IDEA 快速创建只包含 Spring Web 的最小可运行项目，启动后通过浏览器访问 http://localhost:8080/api/hello 返回 JSON。一、创建项目（Spring Boot 向导）步骤说明：打开 IDEA → New Project → 选择 Spring Boot；服务地址保持默认。Project Metadata：Group：如 com.exampleArtifact/Name：如 demoPackaging：jar（默认）Java：17（或本机已安装的 LTS 版本）Build system：Maven（不要选 Gradle）Dependencies：搜索并仅勾选 Spring Web。Finish → 等待 Maven 下载依赖与索引完成。提示：若看不到 Spring Boot(Initializr)，确保安装了 IntelliJ 的 Spring 插件，或使用 File > New > Project... 再选择。二、项目最小代码示例1) 主启动类（IDE 已生成，检查包名与类名）package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }2) Hello 控制器（新增）package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Date; import java.util.Map; @RestController @RequestMapping("/api") public class HelloController { @GetMapping("/hello") public Map<String, Object> hello() { return Map.of( "msg", "hello", "time", new Date() ); } }3) 应用配置（可选）# src/main/resources/application.yml server: # 如需修改端口，取消注释并调整值 # port: 8080三、运行与验证在 DemoApplication 类左侧点击绿色运行箭头，或使用菜单 Run。控制台看到 Started DemoApplication ... 表示启动成功。浏览器/工具访问：http://localhost:8080/api/hello期望响应示例：{"msg":"hello","time":"2025-01-01T00:00:00Z"}常见问题：端口占用：在 application.yml 配置 server.port: 8081。依赖未下载：检查网络代理或尝试 Reload All Maven Projects。JDK 不匹配：Project Structure > Project/Modules 指定与向导一致的 Java 版本。四、标准目录结构与说明 demo/ ← 工程根目录 ├─ pom.xml ← 构建脚本（Maven） ├─ src │ ├─ main │ │ ├─ java │ │ │ └─ com │ │ │ └─ example │ │ │ └─ demo │ │ │ ├─ DemoApplication.java ← 启动类 │ │ │ └─ controller │ │ │ └─ HelloController.java ← Web 控制器 │ │ └─ resources │ │ ├─ application.yml ← 应用配置 │ │ └─ static/ templates/ ← 静态资源/模板(可选) │ └─ test │ └─ java │ └─ com.example.demo │ └─ DemoApplicationTests.java ← 测试类DemoApplication：应用入口，@SpringBootApplication 聚合配置并触发自动装配。controller：放置 @RestController、@Controller 等 Web 层类。resources：application.yml|yaml：端口、数据源、日志级别等配置。static/：静态文件（css/js/img）。templates/：模板引擎（如 Thymeleaf）页面，需相应依赖时使用。test：单元/集成测试。五、Maven 依赖最小示例（仅供参考）Maven pom.xml 关键片段：<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.4</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>六、常用配置（application.yml 示例）spring: application: name: demo jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss server: port: 8080 # servlet: # context-path: /api logging: level: root: INFO com.example.demo: DEBUG # 多环境（profiles），开发/生产差异化配置（任选其一：此处或独立 profile 文件） # spring: # profiles: # active: dev多环境配置（Profiles）示例：# application-dev.yml（开发环境） logging: level: com.example.demo: DEBUG# application-prod.yml（生产环境） logging: level: root: INFO运行时指定环境：在 application.yml 写：spring: profiles: active: dev或启动参数：--spring.profiles.active=prod全局 CORS（跨域）配置（如需前端跨域访问）：package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebCorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true); } }全局异常处理（将错误转换为统一 JSON 响应）：package com.example.demo.common; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public Map<String, Object> handle(Exception ex) { return Map.of( "success", false, "error", ex.getClass().getSimpleName(), "message", ex.getMessage() ); } }七、返回 JSON 与统一异常案例 1：路径参数 + 查询参数 + JSON 返回package com.example.demo.controller; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/users") public class UserController { @GetMapping("/{id}") public Map<String, Object> getUser(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean detail) { return Map.of( "id", id, "name", "Alice", "detail", detail ); } public record CreateUserRequest(String name, Integer age) {} @PostMapping public Map<String, Object> create(@RequestBody CreateUserRequest req) { long generatedId = System.currentTimeMillis(); return Map.of( "id", generatedId, "name", req.name(), "age", req.age() ); } }案例 2：触发异常并由全局异常处理返回统一结构将下述方法加入 UserController 以体验统一异常响应：@GetMapping("/error") public Map<String, Object> error() { throw new IllegalStateException("示例异常"); }测试命令示例：# 获取用户（携带查询参数 detail） curl "http://localhost:8080/api/users/1?detail=true" # 创建用户（POST JSON） curl -X POST "http://localhost:8080/api/users" \ -H "Content-Type: application/json" \ -d "{\"name\":\"Bob\",\"age\":20}" # 触发错误，查看统一异常返回 curl "http://localhost:8080/api/users/error"八、@Value 配置读取示例1) 在 application.yml 定义配置：app: name: demo-app timeout: 5s # 可自动转换为 Duration white-list: - 127.0.0.1 - 10.0.0.1 db: host: 127.0.0.1 port: 33062) 使用 @Value 读取（支持默认值与基础类型转换）：package com.example.demo.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.time.Duration; @Component public class AppValueExample { @Value("${app.name}") private String appName; // 默认值示例：若未配置则为 3s @Value("${app.timeout:3s}") private Duration timeout; // 读取列表元素（带默认值） @Value("${app.white-list[0]:127.0.0.1}") private String firstWhiteIp; // 读取嵌套对象字段（带默认值） @Value("${app.db.host:localhost}") private String dbHost; @Value("${app.db.port:3306}") private Integer dbPort; public String getAppName() { return appName; } public Duration getTimeout() { return timeout; } public String getFirstWhiteIp() { return firstWhiteIp; } public String getDbHost() { return dbHost; } public Integer getDbPort() { return dbPort; } }说明：默认值语法：${key:defaultValue}，当 key 不存在时使用 defaultValue。类型转换：Duration/int/boolean 等常见类型由 Spring 自动转换（如 5s -> Duration.ofSeconds(5)）。列表索引：${list[0]} 访问第一个元素。建议：若配置项较多/需要分组，优先使用 @ConfigurationProperties 进行批量绑定与校验。九、日志引入与配置（SLF4J + Logback）默认：spring-boot-starter-logging 已内置，提供 SLF4J API + Logback 实现。使用：在类中注入并使用 SLF4J Logger。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Date; import java.util.Map; @RestController @RequestMapping("/api") public class HelloControllerWithLog { private static final Logger log = LoggerFactory.getLogger(HelloControllerWithLog.class); @GetMapping("/hello2") public Map<String, Object> hello() { log.info("hello endpoint called at {}", new Date()); log.debug("debug details..."); return Map.of("msg", "hello", "time", new Date()); } }最小 application.yml 配置：logging: level: root: INFO com.example.demo: DEBUG file: name: logs/app.log # 输出到文件（会自动创建文件夹） logback: rollingpolicy: # 基于 Logback 的滚动策略（无需 xml） max-file-size: 10MB max-history: 7 total-size-cap: 1GB常用说明：logging.level.<包名>：设置指定包日志级别。logging.file.name：直接指定日志文件；或使用 logging.file.path 指定目录（文件名默认为 spring.log）。logging.logback.rollingpolicy.*：文件按大小/历史保留自动滚动。自定义输出格式可用：logging.pattern.console / logging.pattern.file。进阶（可选）：使用 logback-spring.xml 进行更细粒度控制，或切换 Log4j2：切换 Log4j2：在 pom.xml 引入 spring-boot-starter-log4j2 并排除默认 logging 依赖。十、@ConfigurationProperties 批量绑定与校验配置（支持嵌套对象、列表、Map、时长等类型）：app: name: demo-app timeout: 5s white-list: - 127.0.0.1 - 10.0.0.1 db: host: 127.0.0.1 port: 3306属性类（开启校验，演示嵌套绑定与默认值）：package com.example.demo.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.convert.DurationUnit; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.util.List; @ConfigurationProperties(prefix = "app") public class AppProperties { private String name; @DurationUnit(ChronoUnit.SECONDS) private Duration timeout = Duration.ofSeconds(3); private List<String> whiteList; private Database db = new Database(); public static class Database { private String host; private int port = 3306; public String getHost() { return host; } public void setHost(String host) { this.host = host; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } } public String getName() { return name; } public void setName(String name) { this.name = name; } public Duration getTimeout() { return timeout; } public void setTimeout(Duration timeout) { this.timeout = timeout; } public List<String> getWhiteList() { return whiteList; } public void setWhiteList(List<String> whiteList) { this.whiteList = whiteList; } public Database getDb() { return db; } public void setDb(Database db) { this.db = db; } }启用方式（三选一）：在启动类上开启扫描（推荐）：import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.SpringApplication; @SpringBootApplication @ConfigurationPropertiesScan // 扫描同包及子包的 @ConfigurationProperties 类 public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }或在配置类上显式启用：import org.springframework.boot.context.properties.EnableConfigurationProperties; @EnableConfigurationProperties(AppProperties.class) public class PropertiesConfig {}或直接把属性类声明为 Bean：import org.springframework.context.annotation.Bean; @org.springframework.context.annotation.Configuration public class PropertiesConfig2 { @Bean public AppProperties appProperties() { return new AppProperties(); } }也可在属性类上加 @Component 直接让其被扫描成 Bean（需保证包路径可被扫描）。校验依赖（若使用 @Validated/约束注解，请确保已引入）：<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>十一、GET 参数处理与示例GET 适用于通过查询参数与路径变量传递轻量数据；GET 不能上传文件（multipart/form-data）。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/get") public class GetParamController { private static final Logger log = LoggerFactory.getLogger(GetParamController.class); // 1) 简单类型 + 默认值 @GetMapping("/simple") public Map<String, Object> simple( @RequestParam String name, @RequestParam(defaultValue = "18") Integer age, @RequestParam(defaultValue = "false") boolean active) { log.info("GET simple: name={}, age={}, active={}", name, age, active); return Map.of("name", name, "age", age, "active", active); } // 2) 列表参数（重复 key：?tags=a&tags=b） @GetMapping("/list") public Map<String, Object> list(@RequestParam(required = false) List<String> tags) { log.info("GET list: tags={}", tags); return Map.of("tags", tags); } // 3) 所有查询参数（平铺） @GetMapping("/all") public Map<String, Object> all(@RequestParam Map<String, String> params) { log.info("GET all params: {}", params); return Map.of("params", params); } // 4) 所有查询参数（允许一个 key 多个值） @GetMapping("/all-multi") public Map<String, Object> allMulti(@RequestParam MultiValueMap<String, String> params) { log.info("GET all multi params: {}", params); return Map.of("params", params); } // 5) DTO 绑定（GET 默认使用 @ModelAttribute 绑定查询参数） public static class QueryDto { private String keyword; private Integer page; private Boolean highlight; public String getKeyword() { return keyword; } public void setKeyword(String keyword) { this.keyword = keyword; } public Integer getPage() { return page; } public void setPage(Integer page) { this.page = page; } public Boolean getHighlight() { return highlight; } public void setHighlight(Boolean highlight) { this.highlight = highlight; } } @GetMapping("/dto") public Map<String, Object> dto(@ModelAttribute QueryDto q) { log.info("GET dto: keyword={}, page={}, highlight={}", q.getKeyword(), q.getPage(), q.getHighlight()); return Map.of("q", Map.of( "keyword", q.getKeyword(), "page", q.getPage(), "highlight", q.getHighlight() )); } // 6) 路径参数 + 查询参数 @GetMapping("/users/{id}") public Map<String, Object> user(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean verbose) { log.info("GET user: id={}, verbose={}", id, verbose); return Map.of("id", id, "verbose", verbose); } // 7) 日期/时间/Duration（注意：Duration 推荐使用 ISO-8601，如 PT30S） @GetMapping("/time") public Map<String, Object> time( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate day, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime at, @RequestParam(required = false) Duration timeout) { log.info("GET time: day={}, at={}, timeout={}", day, at, timeout); return Map.of( "day", day, "at", at, "timeoutSeconds", timeout != null ? timeout.getSeconds() : null ); } }提示：GET 不支持文件上传；文件请使用 POST multipart/form-data。List<String> 通过重复 key 传递最稳妥（如 ?tags=a&tags=b）。Duration 在查询参数中推荐使用 ISO-8601 表达（如 PT30S、PT5M）。十二、POST 参数上传与示例常见 POST 载荷类型：application/json、application/x-www-form-urlencoded、multipart/form-data、text/plain、application/octet-stream。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/post") public class PostParamController { private static final Logger log = LoggerFactory.getLogger(PostParamController.class); // 1) application/json：JSON 体绑定到 DTO public static class CreateUserRequest { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } @PostMapping(value = "/json", consumes = "application/json") public Map<String, Object> json(@RequestBody CreateUserRequest req) { log.info("POST json: name={}, age={}, tags={}", req.getName(), req.getAge(), req.getTags()); return Map.of( "ok", true, "data", Map.of( "name", req.getName(), "age", req.getAge(), "tags", req.getTags() ) ); } // 2) application/x-www-form-urlencoded：表单键值 @PostMapping(value = "/form", consumes = "application/x-www-form-urlencoded") public Map<String, Object> form(@RequestParam String name, @RequestParam Integer age, @RequestParam(required = false) List<String> tags) { log.info("POST form: name={}, age={}, tags={}", name, age, tags); return Map.of("name", name, "age", age, "tags", tags); } // 3) multipart/form-data：单文件 + 其他字段 @PostMapping(value = "/file", consumes = "multipart/form-data") public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) throws IOException { log.info("POST file: name={}, size={}, desc={}", file.getOriginalFilename(), file.getSize(), desc); return Map.of( "filename", file.getOriginalFilename(), "size", file.getSize(), "desc", desc ); } // 4) multipart/form-data：多文件 @PostMapping(value = "/files", consumes = "multipart/form-data") public Map<String, Object> uploadFiles(@RequestParam("files") List<MultipartFile> files) { var names = files.stream().map(MultipartFile::getOriginalFilename).toList(); var sizes = files.stream().map(MultipartFile::getSize).toList(); log.info("POST files: count={}, names={}", files.size(), names); return Map.of( "count", files.size(), "names", names, "sizes", sizes ); } // 5) multipart/form-data：混合 JSON + 文件（JSON 放在 part 中） public static class Meta { private String title; private Integer count; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public Integer getCount() { return count; } public void setCount(Integer count) { this.count = count; } } @PostMapping(value = "/mixed", consumes = "multipart/form-data") public Map<String, Object> mixed(@RequestPart("meta") Meta meta, @RequestPart("file") MultipartFile file) { log.info("POST mixed: meta.title={}, meta.count={}, file={}", meta.getTitle(), meta.getCount(), file.getOriginalFilename()); return Map.of( "meta", Map.of("title", meta.getTitle(), "count", meta.getCount()), "file", Map.of("name", file.getOriginalFilename(), "size", file.getSize()) ); } // 6) text/plain：纯文本 @PostMapping(value = "/text", consumes = "text/plain") public Map<String, Object> text(@RequestBody String body) { log.info("POST text: len={}", body != null ? body.length() : 0); return Map.of("length", body != null ? body.length() : 0, "body", body); } // 7) application/octet-stream：二进制流 @PostMapping(value = "/binary", consumes = "application/octet-stream") public Map<String, Object> binary(@RequestBody byte[] data) { log.info("POST binary: size={}", data != null ? data.length : 0); return Map.of("size", data != null ? data.length : 0); } }示例请求（curl）：# 1) JSON curl -X POST "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20,"tags":["java","spring"]}' # 2) x-www-form-urlencoded（数组用重复 key） curl -X POST "http://localhost:8080/api/post/form" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=Bob&age=22&tags=java&tags=spring" # 3) 单文件上传（multipart） curl -X POST "http://localhost:8080/api/post/file" \ -F "file=@/path/to/a.png" \ -F "desc=头像" # 4) 多文件上传（同名字段） curl -X POST "http://localhost:8080/api/post/files" \ -F "files=@/path/to/a.png" \ -F "files=@/path/to/b.jpg" # 5) 混合 JSON + 文件（给 JSON part 设置类型） curl -X POST "http://localhost:8080/api/post/mixed" \ -F 'meta={"title":"doc","count":2};type=application/json' \ -F "file=@/path/to/a.pdf" # 6) 纯文本 curl -X POST "http://localhost:8080/api/post/text" \ -H "Content-Type: text/plain" \ --data-binary "hello world" # 7) 二进制流 curl -X POST "http://localhost:8080/api/post/binary" \ -H "Content-Type: application/octet-stream" \ --data-binary @/path/to/file.bin提示：表单数组使用重复 key：tags=a&tags=b；或后端声明 String[]/List<String> 显式绑定。混合 JSON + 文件时，JSON part 的 Content-Type 必须是 application/json 才能被 @RequestPart 解析。大文件上传请调整 spring.servlet.multipart.max-file-size 与 max-request-size。十三、过滤器 Filter 与案例过滤器位于最前层（Servlet 容器级），可用于日志、鉴权、跨域预处理等。执行顺序：Filter → Servlet/DispatcherServlet → Interceptor → Controller。package com.example.demo.filter; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.IOException; import java.time.Duration; import java.time.Instant; /** * 基础日志过滤器：记录请求方法、URI、耗时与状态码 */ @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 越小越先执行 public class RequestLoggingFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Instant start = Instant.now(); String method = req.getMethod(); String uri = req.getRequestURI(); try { chain.doFilter(request, response); } finally { long ms = Duration.between(start, Instant.now()).toMillis(); log.info("[REQ] {} {} -> status={}, {}ms", method, uri, resp.getStatus(), ms); } } }测试（与前文 GET/POST 控制器配合）：# 不带 token 访问受保护的 API（期望 401） curl -i "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 携带错误 token（期望 403） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: wrong" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 携带正确 token（期望 200） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: demo-token" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 被排除的健康检查路径（无需 token，期望 200） curl -i "http://localhost:8080/health"提示：全局跨域建议使用 Spring WebMvcConfigurer 的 CORS 配置（见前文）。Filter 中处理 CORS 需注意 OPTIONS 预检放行。引入 Spring Security 时，其过滤器链可能优先于或穿插业务 Filter；鉴权逻辑推荐迁移到 Security 中。十四、拦截器 HandlerInterceptor 与案例拦截器运行在 Spring MVC 层（DispatcherServlet 之后、Controller 之前/之后），适合做登录鉴权、上下文注入、审计日志等。顺序：Filter → Interceptor → Controller → 异常处理（Advice）。package com.example.demo.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.lang.Nullable; import org.springframework.web.servlet.HandlerInterceptor; import java.time.Duration; import java.time.Instant; /** * 日志拦截器：记录方法、URI、耗时 */ public class LoggingInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class); private static final String ATTR_START = "__start_time"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { request.setAttribute(ATTR_START, Instant.now()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) { Object startObj = request.getAttribute(ATTR_START); long ms = 0; if (startObj instanceof Instant start) { ms = Duration.between(start, Instant.now()).toMillis(); } String method = request.getMethod(); String uri = request.getRequestURI(); int status = response.getStatus(); if (ex == null) { log.info("[INTCP] {} {} -> status={}, {}ms", method, uri, status, ms); } else { log.warn("[INTCP] {} {} -> status={}, {}ms, ex={}", method, uri, status, ms, ex.toString()); } } }鉴权拦截器（读取 Header 并写入请求属性）：package com.example.demo.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; /** * 简易 Token 校验：要求 Header: X-Auth-Token=demo-token * 校验通过后写入 request attribute: userId */ public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("X-Auth-Token"); if (token == null || token.isBlank()) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"success\":false,\"message\":\"Missing X-Auth-Token\"}"); return false; } if (!"demo-token".equals(token)) { response.setStatus(403); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"success\":false,\"message\":\"Invalid token\"}"); return false; } // 通过：写入请求属性，供 Controller 使用 request.setAttribute("userId", "demo-user"); return true; } }注册拦截器（路径包含/排除、顺序）：package com.example.demo.config; import com.example.demo.interceptor.AuthInterceptor; import com.example.demo.interceptor.LoggingInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { // 日志拦截器：对所有请求生效，最先执行 registry.addInterceptor(new LoggingInterceptor()) .addPathPatterns("/**") .order(1); // 鉴权拦截器：仅拦截 /api/**，排除无需登录的接口 registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns( "/health", "/actuator/**", "/public/**", "/api/get/**" ) .order(2); } }在控制器中读取拦截器写入的属性：package com.example.demo.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/api/me") public class MeController { @GetMapping public Map<String, Object> me(@RequestAttribute(value = "userId", required = false) String userId) { return Map.of( "authenticated", userId != null, "userId", userId ); } }测试命令（与前文 POST 接口一起验证鉴权）：# 1) 未带 token 访问受保护接口（期望 401） curl -i "http://localhost:8080/api/post/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 2) 错误 token（期望 403） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: wrong" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 3) 正确 token（期望 200） curl -i "http://localhost:8080/api/post/json" \ -H "X-Auth-Token: demo-token" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20}' # 4) 访问 /api/me（展示拦截器写入的 userId） curl -i "http://localhost:8080/api/me" -H "X-Auth-Token: demo-token" # 5) 被排除的路径（无需 token） curl -i "http://localhost:8080/health"提示：Interceptor 可访问 handler（方法/控制器信息），适合做基于注解的鉴权、审计。异常被抛出后将交由 @RestControllerAdvice 处理；如在 preHandle 已写响应并返回 false，后续链路不会继续。若引入 Spring Security，建议把登录鉴权放入 Security 过滤器链与授权机制中，拦截器更多用于业务级横切逻辑。十五、PUT 参数上传与示例PUT 常用于“更新”语义，通常与资源标识（如路径变量 id）配合；相较于 POST，PUT 更强调幂等性（同样的请求重复提交结果一致）。以下示例涵盖 JSON、x-www-form-urlencoded、multipart、text/plain 与二进制流。package com.example.demo.controller; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/put") public class PutParamController { private static final Logger log = LoggerFactory.getLogger(PutParamController.class); // 1) application/json：JSON 体绑定到 DTO public static class UpdateUserRequest { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } @PutMapping(value = "/json", consumes = "application/json") public Map<String, Object> json(@RequestBody UpdateUserRequest req) { log.info("PUT json: name={}, age={}, tags={}", req.getName(), req.getAge(), req.getTags()); return Map.of( "ok", true, "data", Map.of( "name", req.getName(), "age", req.getAge(), "tags", req.getTags() ) ); } // 2) application/x-www-form-urlencoded：表单键值 @PutMapping(value = "/form", consumes = "application/x-www-form-urlencoded") public Map<String, Object> form(@RequestParam String name, @RequestParam Integer age, @RequestParam(required = false) List<String> tags) { log.info("PUT form: name={}, age={}, tags={}", name, age, tags); return Map.of("name", name, "age", age, "tags", tags); } // 3) multipart/form-data：单文件 + 其他字段 @PutMapping(value = "/file", consumes = "multipart/form-data") public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) throws IOException { log.info("PUT file: name={}, size={}, desc={}", file.getOriginalFilename(), file.getSize(), desc); return Map.of( "filename", file.getOriginalFilename(), "size", file.getSize(), "desc", desc ); } // 4) multipart/form-data：混合 JSON + 文件（JSON 放在 part 中） public static class Meta { private String title; private Integer count; public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public Integer getCount() { return count; } public void setCount(Integer count) { this.count = count; } } @PutMapping(value = "/mixed", consumes = "multipart/form-data") public Map<String, Object> mixed(@RequestPart("meta") Meta meta, @RequestPart("file") MultipartFile file) { log.info("PUT mixed: meta.title={}, meta.count={}, file={}", meta.getTitle(), meta.getCount(), file.getOriginalFilename()); return Map.of( "meta", Map.of("title", meta.getTitle(), "count", meta.getCount()), "file", Map.of("name", file.getOriginalFilename(), "size", file.getSize()) ); } // 5) text/plain：纯文本 @PutMapping(value = "/text", consumes = "text/plain") public Map<String, Object> text(@RequestBody String body) { log.info("PUT text: len={}", body != null ? body.length() : 0); return Map.of("length", body != null ? body.length() : 0, "body", body); } // 6) application/octet-stream：二进制流 @PutMapping(value = "/binary", consumes = "application/octet-stream") public Map<String, Object> binary(@RequestBody byte[] data) { log.info("PUT binary: size={}", data != null ? data.length : 0); return Map.of("size", data != null ? data.length : 0); } // 7) 路径变量 + JSON：典型“更新某个资源” @PutMapping(value = "/users/{id}", consumes = "application/json") public Map<String, Object> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest req) { log.info("PUT users/{}: name={}, age={}, tags={}", id, req.getName(), req.getAge(), req.getTags()); return Map.of( "id", id, "updated", true, "data", Map.of("name", req.getName(), "age", req.getAge(), "tags", req.getTags()) ); } }示例请求（curl）：# 1) JSON curl -X PUT "http://localhost:8080/api/put/json" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":21,"tags":["java","spring"]}' # 2) x-www-form-urlencoded（数组用重复 key） curl -X PUT "http://localhost:8080/api/put/form" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "name=Bob&age=22&tags=java&tags=spring" # 3) 单文件上传（multipart） curl -X PUT "http://localhost:8080/api/put/file" \ -F "file=@/path/to/a.png" \ -F "desc=头像" # 4) 混合 JSON + 文件（给 JSON part 设置类型） curl -X PUT "http://localhost:8080/api/put/mixed" \ -F 'meta={"title":"doc","count":2};type=application/json' \ -F "file=@/path/to/a.pdf" # 5) 纯文本 curl -X PUT "http://localhost:8080/api/put/text" \ -H "Content-Type: text/plain" \ --data-binary "hello world" # 6) 二进制流 curl -X PUT "http://localhost:8080/api/put/binary" \ -H "Content-Type: application/octet-stream" \ --data-binary @/path/to/file.bin # 7) 路径变量 + JSON（更新指定 id） curl -X PUT "http://localhost:8080/api/put/users/100" \ -H "Content-Type: application/json" \ -d '{"name":"Carol","age":23,"tags":["k8s"]}'注意：幂等性：PUT 语义倾向幂等，更新相同资源应返回相同结果；可配合 If-Match/ETag 做并发控制。鉴权：若启用了拦截器/过滤器鉴权，PUT 同样需要携带头 X-Auth-Token: demo-token 才能通过。表单/文件：multipart 与 x-www-form-urlencoded 对 PUT 同样适用；部分代理/网关可能对 PUT 有限制，需在网关放行并正确转发请求体。安全：开启 Spring Security 时，PUT 默认受 CSRF 保护；如为纯 API 服务可关闭 CSRF 或在请求中附带 CSRF token。十六、20 个 GET/POST 接口样例覆盖探活、版本、分页、详情、搜索、请求头/IP、速率限制、时间时区、登录、下单/支付、上传、表单、文本、数值计算等多样场景。每个接口提供功能说明、示例请求与“结果样式”。约定：若启用拦截器/安全，请携带 X-Auth-Token: demo-token。1) GET /api/sample/ping — 健康探活功能：返回服务运行状态与时间戳示例请求：curl "http://localhost:8080/api/sample/ping"结果样式：{"ok":true,"ts":1735700000000}2) GET /api/sample/version — 版本信息功能：返回应用名称、版本与构建curl "http://localhost:8080/api/sample/version"{"app":"demo","version":"1.0.0","build":"local"}3) GET /api/sample/users — 用户分页参数：page(默认1), size(默认10), keyword(可选)curl "http://localhost:8080/api/sample/users?page=1&size=2&keyword=ali"{"page":1,"size":2,"keyword":"ali","items":[{"id":1,"name":"Alice","age":20},{"id":2,"name":"Bob","age":22}]}4) GET /api/sample/users/{id} — 用户详情curl "http://localhost:8080/api/sample/users/1001"{"id":1001,"name":"User-1001","age":19}5) GET /api/sample/search — 关键字+标签搜索参数：q、tags(多值)curl "http://localhost:8080/api/sample/search?q=phone&tags=android&tags=5g"{"q":"phone","tags":["android","5g"],"results":["r1","r2"]}6) GET /api/sample/headers — 请求头读取功能：读取 X-Trace-Id 与 User-Agentcurl -H "X-Trace-Id: abc-123" -H "User-Agent: curl/8.0" "http://localhost:8080/api/sample/headers"{"traceId":"abc-123","userAgent":"curl/8.0"}7) GET /api/sample/ip — 客户端 IP/UAcurl -H "User-Agent: demo" "http://localhost:8080/api/sample/ip"{"ip":"127.0.0.1","userAgent":"demo"}8) GET /api/sample/rate-limit — 速率限制信息功能：返回窗口、配额与剩余；同时在响应头设置 X-RateLimit-*curl "http://localhost:8080/api/sample/rate-limit"{"window":"1m","limit":100,"remaining":40}9) GET /api/sample/echo — 回显 messagecurl "http://localhost:8080/api/sample/echo?message=hello"{"message":"hello","ts":1735700000000}10) GET /api/sample/time — 当前时间（可选时区）参数：tz(如 Asia/Shanghai)，非法时区自动回退系统默认curl "http://localhost:8080/api/sample/time?tz=Asia/Shanghai"{"now":"2025-01-01T08:00:00+08:00[Asia/Shanghai]","zone":"Asia/Shanghai","epochMs":1735700000000}11) POST /api/sample/users — 创建用户（JSON）curl -X POST "http://localhost:8080/api/sample/users" \ -H "Content-Type: application/json" \ -d '{"name":"Alice","age":20,"tags":["java","spring"]}'{"id":1700000000000,"name":"Alice","age":20,"tags":["java","spring"]}12) POST /api/sample/users/batch — 批量创建（JSON）curl -X POST "http://localhost:8080/api/sample/users/batch" \ -H "Content-Type: application/json" \ -d '{"users":[{"name":"A","age":18},{"name":"B","age":19}]}'{"created":2}13) POST /api/sample/login — 登录（JSON）curl -X POST "http://localhost:8080/api/sample/login" \ -H "Content-Type: application/json" \ -d '{"username":"u","password":"p"}'{"ok":true,"token":"demo-token"}14) POST /api/sample/orders — 创建订单（JSON）curl -X POST "http://localhost:8080/api/sample/orders" \ -H "Content-Type: application/json" \ -d '{"userId":"U1","items":[{"sku":"S1","quantity":2,"price":10.5},{"sku":"S2","quantity":1,"price":20}]}'{"orderId":"ORD-1700000000000","userId":"U1","total":41.0}15) POST /api/sample/orders/{id}/pay — 支付订单（JSON）curl -X POST "http://localhost:8080/api/sample/orders/ORD-1/pay" \ -H "Content-Type: application/json" \ -d '{"method":"alipay","channel":"app"}'{"orderId":"ORD-1","paid":true,"method":"alipay","channel":"app"}16) POST /api/sample/upload — 单文件上传（multipart）curl -X POST "http://localhost:8080/api/sample/upload" \ -F "file=@/path/to/a.png" -F "desc=头像"{"name":"a.png","size":12345,"desc":"头像"}17) POST /api/sample/uploads — 多文件上传（multipart）curl -X POST "http://localhost:8080/api/sample/uploads" \ -F "files=@/path/to/a.png" \ -F "files=@/path/to/b.jpg"{"count":2,"names":["a.png","b.jpg"],"sizes":[12345,23456]}18) POST /api/sample/feedback — 文本反馈（text/plain）curl -X POST "http://localhost:8080/api/sample/feedback" \ -H "Content-Type: text/plain" --data-binary "很好用！"{"received":12}19) POST /api/sample/submit — 表单提交（x-www-form-urlencoded）curl -X POST "http://localhost:8080/api/sample/submit" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "title=建议&content=内容很多&tags=java&tags=spring"{"title":"建议","contentLen":12,"tags":["java","spring"]}20) POST /api/sample/compute — 数值计算（JSON）功能：avg=true 返回平均值，否则返回总和curl -X POST "http://localhost:8080/api/sample/compute" \ -H "Content-Type: application/json" \ -d '{"numbers":[1,2,3.5],"avg":true}'{"avg":2.1666666667,"count":3}package com.example.demo.controller; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/sample") public class SampleApiController { private static final Logger log = LoggerFactory.getLogger(SampleApiController.class); // ---------- GET ---------- @GetMapping("/ping") public Map<String, Object> ping() { return Map.of("ok", true, "ts", System.currentTimeMillis()); } @GetMapping("/version") public Map<String, Object> version() { return Map.of("app", "demo", "version", "1.0.0", "build", "local"); } @GetMapping("/users") public Map<String, Object> listUsers(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String keyword) { return Map.of( "page", page, "size", size, "keyword", keyword, "items", List.of( Map.of("id", 1, "name", "Alice", "age", 20), Map.of("id", 2, "name", "Bob", "age", 22) ) ); } @GetMapping("/users/{id}") public Map<String, Object> userDetail(@PathVariable long id) { return Map.of("id", id, "name", "User-" + id, "age", 18 + (id % 10)); } @GetMapping("/search") public Map<String, Object> search(@RequestParam(name = "q") String keyword, @RequestParam(required = false) List<String> tags) { return Map.of("q", keyword, "tags", tags, "results", List.of("r1", "r2")); } @GetMapping("/headers") public Map<String, Object> headers(@RequestHeader(value = "X-Trace-Id", required = false) String traceId, @RequestHeader(value = "User-Agent", required = false) String userAgent) { return Map.of("traceId", traceId, "userAgent", userAgent); } @GetMapping("/ip") public Map<String, Object> ip(HttpServletRequest request, @RequestHeader(value = "User-Agent", required = false) String userAgent) { String xff = request.getHeader("X-Forwarded-For"); String ip = xff != null && !xff.isBlank() ? xff.split(",")[0].trim() : request.getRemoteAddr(); return Map.of("ip", ip, "userAgent", userAgent); } @GetMapping("/rate-limit") public Map<String, Object> rateLimit(HttpServletResponse response) { int limit = 100, remaining = 42; // 演示值 response.setHeader("X-RateLimit-Limit", String.valueOf(limit)); response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining)); response.setHeader("X-RateLimit-Window", "1m"); return Map.of("window", "1m", "limit", limit, "remaining", remaining); } @GetMapping("/echo") public Map<String, Object> echo(@RequestParam String message) { return Map.of("message", message, "ts", System.currentTimeMillis()); } @GetMapping("/time") public Map<String, Object> time(@RequestParam(required = false) String tz) { ZoneId zone; try { zone = tz != null && !tz.isBlank() ? ZoneId.of(tz) : ZoneId.systemDefault(); } catch (Exception e) { zone = ZoneId.systemDefault(); } ZonedDateTime now = ZonedDateTime.now(zone); return Map.of("now", now.toString(), "zone", zone.getId(), "epochMs", System.currentTimeMillis()); } // ---------- POST ---------- public static class CreateUserReq { private String name; private Integer age; private List<String> tags; public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public List<String> getTags() { return tags; } public void setTags(List<String> tags) { this.tags = tags; } } public static class BatchCreateUserReq { private List<CreateUserReq> users; public List<CreateUserReq> getUsers() { return users; } public void setUsers(List<CreateUserReq> users) { this.users = users; } } @PostMapping(value = "/users", consumes = "application/json") public Map<String, Object> createUser(@RequestBody CreateUserReq req) { long id = System.currentTimeMillis(); log.info("create user: {} {} {}", req.getName(), req.getAge(), req.getTags()); return Map.of("id", id, "name", req.getName(), "age", req.getAge(), "tags", req.getTags()); } @PostMapping(value = "/users/batch", consumes = "application/json") public Map<String, Object> batchCreate(@RequestBody BatchCreateUserReq req) { int count = req.getUsers() != null ? req.getUsers().size() : 0; return Map.of("created", count); } public static class LoginReq { private String username; private String password; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } @PostMapping(value = "/login", consumes = "application/json") public Map<String, Object> login(@RequestBody LoginReq req) { boolean ok = req.getUsername() != null && !req.getUsername().isBlank(); return Map.of("ok", ok, "token", ok ? "demo-token" : null); } public static class OrderItem { private String sku; private Integer quantity; private Double price; public String getSku() { return sku; } public void setSku(String sku) { this.sku = sku; } public Integer getQuantity() { return quantity; } public void setQuantity(Integer quantity) { this.quantity = quantity; } public Double getPrice() { return price; } public void setPrice(Double price) { this.price = price; } } public static class CreateOrderReq { private String userId; private List<OrderItem> items; public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public List<OrderItem> getItems() { return items; } public void setItems(List<OrderItem> items) { this.items = items; } } @PostMapping(value = "/orders", consumes = "application/json") public Map<String, Object> createOrder(@RequestBody CreateOrderReq req) { double total = 0.0; if (req.getItems() != null) { for (OrderItem it : req.getItems()) { if (it.getPrice() != null && it.getQuantity() != null) { total += it.getPrice() * it.getQuantity(); } } } String orderId = "ORD-" + System.currentTimeMillis(); return Map.of("orderId", orderId, "userId", req.getUserId(), "total", total); } public static class PayReq { private String method; // e.g., wechat, alipay, card private String channel; // e.g., app, web public String getMethod() { return method; } public void setMethod(String method) { this.method = method; } public String getChannel() { return channel; } public void setChannel(String channel) { this.channel = channel; } } @PostMapping(value = "/orders/{id}/pay", consumes = "application/json") public Map<String, Object> pay(@PathVariable String id, @RequestBody PayReq req) { return Map.of("orderId", id, "paid", true, "method", req.getMethod(), "channel", req.getChannel()); } @PostMapping(value = "/upload", consumes = "multipart/form-data") public Map<String, Object> upload(@RequestParam("file") MultipartFile file, @RequestParam(required = false) String desc) { return Map.of("name", file.getOriginalFilename(), "size", file.getSize(), "desc", desc); } @PostMapping(value = "/uploads", consumes = "multipart/form-data") public Map<String, Object> uploads(@RequestParam("files") List<MultipartFile> files) { var names = files.stream().map(MultipartFile::getOriginalFilename).toList(); var sizes = files.stream().map(MultipartFile::getSize).toList(); return Map.of("count", files.size(), "names", names, "sizes", sizes); } @PostMapping(value = "/feedback", consumes = "text/plain") public Map<String, Object> feedback(@RequestBody String body) { return Map.of("received", body != null ? body.length() : 0); } @PostMapping(value = "/submit", consumes = "application/x-www-form-urlencoded") public Map<String, Object> submit(@RequestParam String title, @RequestParam String content, @RequestParam(required = false) List<String> tags) { return Map.of("title", title, "contentLen", content.length(), "tags", tags); } public static class ComputeReq { private List<Double> numbers; private Boolean avg; // true: 返回平均值；false/null：返回总和 public List<Double> getNumbers() { return numbers; } public void setNumbers(List<Double> numbers) { this.numbers = numbers; } public Boolean getAvg() { return avg; } public void setAvg(Boolean avg) { this.avg = avg; } } @PostMapping(value = "/compute", consumes = "application/json") public Map<String, Object> compute(@RequestBody ComputeReq req) { double sum = 0.0; int n = 0; if (req.getNumbers() != null) { for (Double d : req.getNumbers()) { if (d != null) { sum += d; n++; } } } boolean avg = Boolean.TRUE.equals(req.getAvg()); return avg ? Map.of("avg", n > 0 ? sum / n : 0.0, "count", n) : Map.of("sum", sum, "count", n); } }提示：如启用了拦截器或安全配置，上述接口同样需要按规则携带必要的认证信息（如 X-Auth-Token）才能访问。