前言这是一份关于如何将已部署的 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: ghcr.io/umami-software/umami:postgresql-3.0.0 ports: - "3000:3000" environment: DATABASE_URL: postgresql://umami:你的密码@db:5432/umami DATABASE_TYPE: postgresql APP_SECRET: 你的随机字符串 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这个命令会从 Docker Hub 拉取最新的 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）才能访问。
在飞牛 NAS 底层安装宝塔面板并部署网站
为什么在底层安装宝塔1、飞牛 NAS 虽自带 1Panel 管理面板，但无法直接部署 Python 网站，且缺少完善的 Python 环境管理工具。2、使用飞牛自带的宝塔 Docker 应用常见问题：网站无法访问、容器重启后数据丢失、在宝塔内安装 MySQL/PHP/FTP/Docker 等组件失败。3、在底层安装宝塔通常可避免上述问题，但可能与飞牛 OS 的部分配置冲突，存在系统升级失败等风险，请自行权衡。一、安装方式在飞牛 NAS 上安装宝塔面板有三种方式，本文推荐“底层安装”并以此示范：1、Docker 安装：使用系统应用商店的宝塔镜像。2、底层安装：通过 SSH 在宿主机（与飞牛 OS 同级）直接安装。（本文采用）3、在虚拟机中安装：通过应用商店创建 Ubuntu 等虚拟机，在其中安装宝塔。二、准备工作1、下载下载远程连接工具：软件名称：FinalShell地址：https://www.hostbuf.com/2、设置连接打开 SSH打开飞牛系统设置 -> SSH -> 按钮打开添加连接点击左上角文件夹样式按钮，并选择“SSH连接(Linux)”填写配置名称：自定义主机：飞牛 OS 的 IP 地址端口：22（保持默认）用户名：飞牛 OS 管理员账号密码：管理员密码三、安装宝塔1、连接设备2、切换为 root 权限使用命令：sudo -i再输入管理员密码（输入时不显示） 看到 root 提示符即切换成功。3、执行安装脚本使用宝塔通用安装脚本：if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh ed8484bec按提示先输入 y 进行确认，再输入 yes 进行强制安装。这里的已有web服务是飞牛OS的网页端4、查看面板信息安装完成后，终端会输出面板登录地址和账号密码：建议使用内网地址登录。内网访问默认端口已放行。如需公网或 FRP 访问，请按需配置端口放行。5、安装运行环境推荐“一键安装 LNMP”。安装成功示例：四、常见问题与设置优化1、面板 Nginx 无法启动或飞牛网页端打不开取消“端口重定向”的勾选，将 80 和 443 端口留给宝塔使用：2、宝塔中的 MySQL 等服务安装后无法启动将安装方式切换为编译安装（耗时较长），更好地适配系统环境：五、网站搭建与访问1、创建网站2、访问测试3、公网访问如需公网访问，可使用 FRP 或自行做端口映射/放行策略。可参考文章：[post cid="1086" /]
解决宝塔面板SSL报错 - AttributeError: module 'acme_v2' has no attribute 'acme_v2'
问题背景在使用宝塔面板设置网站SSL证书时，有时会遇到报错情况，常见的原因包括Python环境依赖缺失、面板组件损坏或环境冲突等。本文将介绍两种解决方案，帮助您快速恢复宝塔面板的SSL功能。常见错误示例以下是一个典型的错误示例：出错了，面板运行时发生错误！ AttributeError: module 'acme_v2' has no attribute 'acme_v2' REQUEST_DATE: 2023-07-17 10:29:45 VERSION: Debian GNU/Linux 12 (bookworm) x86_64(Py3.7.16) - 9.6.0 REMOTE_ADDR: 178.239.124.112 REQUEST_URI: POST /site?action=GetSSL REQUEST_FORM: {'siteName': 'blog.ybyq.wang'} Traceback (most recent call last): File "/www/server/panel/BTPanel/__init__.py", line 2525, in publicObject res = run_obj.run(toObject, defs, get) File "/www/server/panel/BTPanel/__init__.py", line 2472, in run result = getattr(toObject, get.action)(get) File "/www/server/panel/class/panelSite.py", line 2735, in GetSSL acme = acme_v2.acme_v2() AttributeError: module 'acme_v2' has no attribute 'acme_v2'错误分析从上述错误日志可以看出：错误类型：AttributeError - Python属性错误具体原因：module 'acme_v2' has no attribute 'acme_v2' - 找不到acme_v2模块中的acme_v2方法或类错误位置：在处理SSL证书申请时（/site?action=GetSSL）出现错误错误堆栈：最终在panelSite.py文件的2735行调用acme_v2.acme_v2()时失败这个错误通常是由于Python模块安装不完整、版本不兼容或模块损坏导致的。特别是在Debian系统上运行Python 3.7.16版本时，可能会出现此类依赖问题。解决方案一：重新安装依赖包在执行此操作前，强烈建议先做好磁盘快照或系统备份，以防万一出现问题可以快速回滚。步骤：首先执行以下命令：btpip install -r /www/server/panel/requirements.txt && bt restart代码解释：btpip install -r /www/server/panel/requirements.txt：使用宝塔面板的pip工具安装requirements.txt文件中列出的所有Python依赖包。requirements.txt包含宝塔面板正常运行所需的所有Python库。&&：这是命令连接符，表示左边命令执行成功后才会执行右边的命令。bt restart：重启宝塔面板服务，使新安装的依赖生效。此命令会重新安装所有宝塔面板依赖，包括处理SSL证书所需的acme_v2模块，有很大机会解决上述AttributeError问题。解决方案二：备份并重新安装面板如果解决方案一无效，可以尝试以下更彻底的方法。同样，务必先做好系统快照。步骤：备份现有Python环境：mv /www/server/panel/pyenv/ /www/backup/pyenv_backup重新安装宝塔面板：if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh ed8484bec代码解释：第一条命令：mv /www/server/panel/pyenv/ /www/backup/pyenv_backup：将宝塔面板的Python虚拟环境（pyenv目录）移动到备份位置。这个操作是将可能有问题的Python环境隔离，以便重新安装一个干净的环境。第二条命令：if [ -f /usr/bin/curl ]：检查系统是否安装了curl工具then curl -sSO https://download.bt.cn/install/install_panel.sh：如果有curl，使用curl下载面板安装脚本-s 静默模式，不显示进度-S 显示错误信息-O 将下载内容保存为原始文件名else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh：如果没有curl，则使用wget下载安装脚本-O install_panel.sh 指定保存的文件名fi：if条件语句结束bash install_panel.sh ed8484bec：执行下载的安装脚本，ed8484bec是安装参数或授权码重新安装面板将完全解决Python环境问题，包括acme_v2模块缺失或损坏的情况，从而修复SSL设置功能。注意事项在执行任何操作前，务必先做好数据备份或系统快照，这是系统管理的基本原则。如果您的站点有重要业务，建议在非高峰期进行操作。重装面板不会影响您的网站数据和配置，但为安全起见，还是建议提前备份重要数据。重装完成后，可能需要重新设置SSL证书。如果您的系统是Debian 12，可能会出现更多兼容性问题，建议考虑使用官方更推荐的系统版本。总结宝塔面板SSL设置报错通常与Python环境或面板组件有关，特别是acme_v2模块相关的错误。通过重新安装依赖包或重装面板可以解决大部分问题。在处理此类问题时，做好备份是首要原则，然后根据具体情况选择适合的解决方案。希望以上方法能帮助你解决宝塔面板SSL设置的问题。
前端开发中嵌入B站视频播放器：解决high_quality=1 失效的问题
问题背景在前端开发中，我们可能需要嵌入B站视频。使用B站官方播放器时，遇到了以下问题：默认画质被限制在360p高清画质需要登录B站账号部分视频需要大会员才能观看高清版本问题分析默认的B站原生播放器 存在的问题：画质限制：即使设置了high_quality=1和quality=720，实际播放时仍被限制在360p，这两个方法在22年就失效了登录要求：高清画质需要用户登录B站账号会员限制：部分视频的高清版本需要大会员权限解决方案改进后的高清解析播放器[hide] [/hide]优势：画质提升：可以直接获取视频源默认画质，1080p同样生效无需登录：用户无需登录B站账号突破限制：可以观看部分需要大会员的视频总结通过使用视频解析接口，成功解决了B站视频播放器的画质限制问题。希望这个解决方案对你有所帮助，有问题请在评论区交流。
IntelliJ IDEA和PyCharm全家桶最新版永久破解教程：无需重新安装
前言话不多说，先看破解效果，我破解的是PyCharm 2025.1.1.1专业版和IDEA 2025.1.1.1专业版工具下载对于2024.2.4版本以后的，用下面的链接下载工具[hide]https://pan.quark.cn/s/b0dfc91f0193[/hide]对于2020到2024的老版本，使用下面的链接下载[hide]https://pan.quark.cn/s/5b86def38453[/hide]使用说明
本文将详细演示如何在IDEA中使用 Gradle 构建工具将 SpringBoot 项目打包为可执行的 JAR 文件。操作步骤步骤 1：配置项目构建工件打开项目结构，在工件（Artifacts）中添加新的 JAR 配置。步骤 2：选择模块配置系统默认选择所有模块，确认配置无误后点击确定。步骤 3：执行 Gradle 构建打开右侧的 Gradle 工具窗口展开 build 任务组双击执行 bootjar 任务步骤 4：获取生成的 JAR 文件构建完成后，打开项目根目录下的 build/libs/ 文件夹，即可看到生成的 .jar 文件。重要提示⚠️ 注意：在提交作业或部署项目时，请选择第一个 JAR 文件（通常是不带 -plain 后缀的文件），不要选择第二个 plain JAR 文件。可执行 JAR：包含所有依赖项，可以直接运行Plain JAR：仅包含项目代码，缺少依赖项，无法独立运行运行 JAR 文件生成的可执行 JAR 文件可以通过以下命令运行：java -jar your-project-name.jar
1.下载安装1.去官网下载Gradle2.下载后解压在D盘创建.gradle文件夹，作为本地仓库2.配置环境1、新建 GRADLE_HOME 系统变量，路径为D:\gradle-8.10.2-bin\gradle-8.10.22、新建 GRADLE__USER_HOME 系统变量，路径为D:\gradle-8.10.2-bin\gradle-8.10.2\.gradle3、添加完成4、将 %GRADLE_HOME%\bin 添加到 Path 环境变量中，然后点击确定5、验证gradle是否安装成功，打开cmd命令行输入 gradle -v3.设置本地仓库-全局配置第二步中已经创建创建好了.gradle，然后在.gradle中新建文件init.gradle在init.gradle中输入以下代码并保存allprojects { repositories { // 本地仓库 maven { url 'file:///D:/Program Files/apache-maven-3.6.3/.m2/repository' } mavenLocal() // 阿里云镜像 maven { name "Alibaba" url "https://maven.aliyun.com/nexus/content/groups/public/" } // 标准仓库 mavenCentral() google() } buildscript { repositories { maven { name "Alibaba" url "https://maven.aliyun.com/nexus/content/groups/public/" } mavenCentral() google() } } }4.IDEA中的Gradle设置5.注意事项需要根据项目中JDK的版本要求设置JDK，不然会构建失败，比如：这里面要求的是21版本，就需要把版本改为21，在这里直接下载即可
此教程是用的是IDEA2025.1.1.1版本，确保本地安装了git，并且IDEA也安装了git插件（也可以安装一个中文插件），如果没有可以去插件中下载。1、首先在idea上随便打开一个项目，进入设置找到gitidea可以自动检测出本地的git路径，其他设置项不用修改。2、主菜单-> VCS-> 从版本控制中获取3、复制gitee的项目地址，填入URL中4、登录，这里选择使用帮助程序5、完成导入在新导入的文件夹中VCS会变成Git，这里会有git的一些操作选项