diff --git a/template/nestJs/libs/redis/redis.service.ts b/template/nestJs/libs/redis/redis.service.ts index 507a4c40..1e13683d 100644 --- a/template/nestJs/libs/redis/redis.service.ts +++ b/template/nestJs/libs/redis/redis.service.ts @@ -7,6 +7,7 @@ export class RedisService { constructor() { this.redisClient = new Redis({ host: process.env.REDIS_HOST, + password: process.env.REDIS_PASSWORD, port: parseInt(process.env.REDIS_PORT), }); } diff --git a/template/springboot/pom.xml b/template/springboot/pom.xml index 0eee262e..15e7ded0 100644 --- a/template/springboot/pom.xml +++ b/template/springboot/pom.xml @@ -49,10 +49,12 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-validation - + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + mysql diff --git a/template/springboot/src/main/java/com/TinyPro/DataInitializer.java b/template/springboot/src/main/java/com/TinyPro/DataInitializer.java index ab22b6cc..11c4cffb 100644 --- a/template/springboot/src/main/java/com/TinyPro/DataInitializer.java +++ b/template/springboot/src/main/java/com/TinyPro/DataInitializer.java @@ -51,6 +51,8 @@ public class DataInitializer implements CommandLineRunner { private LangRepository langRepository; @Autowired private I18Repository i18Repository; + @Autowired + private ApplicationRepository applicationRepository; @Override public void run(String... args) throws Exception { @@ -68,6 +70,9 @@ public void run(String... args) throws Exception { // 初始化国际化信息 initI18n(); + // 初始化应用 + initApplications(); + // 初始化权限 initPermissions(); @@ -90,9 +95,17 @@ private void initI18n() throws IOException { try (InputStream is = pathResource.getInputStream()) { String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); Map> i18nData = JSON.parseObject(json, Map.class); + Set existingLangNames = langRepository.findAll() + .stream() + .map(Lang::getName) + .collect(Collectors.toSet()); // 遍历外层 Map for (Map.Entry> outerEntry : i18nData.entrySet()) { String langName = outerEntry.getKey(); // 外层键作为 Lang 的 name + if (existingLangNames.contains(langName)) { + logger.info("语言 {} 已存在,跳过初始化", langName); + continue; + } Lang lang = new Lang(); lang.setName(langName); List i18List = new ArrayList<>(); @@ -123,6 +136,104 @@ private void initI18n() throws IOException { } } + private void initApplications() { + List applicationData = List.of( + new Application( + "Tiny Design 设计体系", + "华为云产品和服务的规范体系,包括交互视觉设计、业务流程、国际化、术语词条。", + "[{ \"type\": \"\", \"value\": \"机会点定义\" }, { \"type\": \"danger\", \"value\": \"交互设计\" }]", + "card-list-application-default.png", + "design" + ), + new Application( + "Tiny DesignLink 设计流水线工具", + "设计+协同+资源管理,一个工具就够了,在线原型设计、设计过程融入DevOps流程。", + "[{ \"type\": \"error\", \"value\": \"交互设计\" }, { \"type\": \"warning\", \"value\": \"视觉设计\" }]", + "card-list-application-default.png", + "design" + ), + new Application( + "TinyUI3.0 开发工具 ", + "Cloud Design System 提供了丰富的规范文档及开发组件。", + "[{ \"type\": \"success\", \"value\": \"开发\" }]", + "card-list-application-default.png", + "dev" + ), + new Application( + "TinyPlus3.0 开发工具", + "TinyPlus3.0 是基于Angular + Typescript的Web前端云业务组件库。", + "[{ \"type\": \"success\", \"value\": \"开发\" }]", + "card-list-tiny-plus.png", + "dev" + ), + new Application( + "Tiny Stage 工程工具 ", + "一个跨平台的前端工程化cli工具,为开发提供一系列开发套件和工程插件", + "[{ \"type\": \"success\", \"value\": \"开发\" }]", + "card-list-console-framework.png", + "dev" + ), + new Application( + "Tiny Flow 接口编排工具 ", + "端到端的API编排解决方案,通过可视化编程的方式快速生成、发布、调试的API编排。", + "[{ \"type\": \"success\", \"value\": \"开发\" }]", + "card-list-console-framework.png", + "dev" + ), + new Application( + "Tiny Gate 门禁系统", + "门禁系统,通过卡点方式集成到伏羲流水线,在服务发布时生成预览页面。", + "[{ \"type\": \"info\", \"value\": \"测试验证\" }]", + "card-list-console-framework.png", + "dev" + ), + new Application( + "Console Framework 控制台框架", + "华为云各服务快速构建管理控制台的平台。", + "[{ \"type\": \"success\", \"value\": \"开发\" },{ \"type\": \"info\", \"value\": \"测试验证\" },{ \"type\": \"warning\", \"value\": \"上线\" }]", + "card-list-console-framework.png", + "dev" + ), + new Application( + "Nodejs Framework Nodejs应用", + "基于egg的定制化web服务框架,让你快速上手Nodejs做BFF意见微服务。", + "[{ \"type\": \"success\", \"value\": \"开发\" },{ \"type\": \"info\", \"value\": \"测试验证\" }]", + "card-list-console-framework.png", + "dev" + ), + new Application( + "Furion 前端体验监控", + "提供端到端前端用户体验度量,让产品用户体验可度量、可监控、可优化。", + "[{ \"type\": \"\", \"value\": \"机会点定义\" }]", + "card-list-furion.png", + "dev" + ), + new Application( + "Tiny Mock API 管理", + "功能强大的API管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。", + "[{ \"type\": \"success\", \"value\": \"开发\" },{ \"type\": \"warning\", \"value\": \"视觉设计\" }]", + "card-list-application-default.png", + "dev" + ) + ); + + Set existingAppNames = applicationRepository.findAll() + .stream() + .map(Application::getName) + .collect(Collectors.toSet()); + List newApplications = applicationData.stream() + .filter(app -> !existingAppNames.contains(app.getName())) + .collect(Collectors.toList()); + + if (newApplications.isEmpty()) { + logger.info("没有新应用需要导入,数据库已存在所有应用"); + return; + } + + applicationRepository.saveAll(newApplications); + logger.info("成功导入 {} 个新应用", newApplications.size()); + } + private void initPermissions() { Map permissions = new HashMap<>(); permissions.put("user", new String[]{"add", "remove", "update", "query", "password::force-update","batch-remove"}); @@ -132,35 +243,34 @@ private void initPermissions() { permissions.put("i18n", new String[]{"add", "remove", "update", "query","batch-remove"}); permissions.put("lang", new String[]{"add", "remove", "update", "query"}); - Permission superPermission = new Permission(); - superPermission.setName("*"); - superPermission.setDesc("super permission"); - try { - permissionRepository.save(superPermission); - } catch (Exception e) { - logger.error(e.getMessage()); - logger.error("Please clear the database and try again"); - System.exit(-1); - } + createPermissionIfAbsent("*", "super permission"); for (Map.Entry entry : permissions.entrySet()) { String module = entry.getKey(); String[] actions = entry.getValue(); for (String action : actions) { - Permission permission = new Permission(); - permission.setName(module + "::" + action); - permission.setDesc(""); - try { - permissionRepository.save(permission); - } catch (Exception e) { - logger.error(e.getMessage()); - logger.error("Please clear the database and try again"); - System.exit(-1); - } + createPermissionIfAbsent(module + "::" + action, ""); } } } + private void createPermissionIfAbsent(String name, String desc) { + if (permissionRepository.existsByName(name)) { + return; + } + + Permission permission = new Permission(); + permission.setName(name); + permission.setDesc(desc); + try { + permissionRepository.save(permission); + } catch (Exception e) { + logger.error(e.getMessage()); + logger.error("Please clear the database and try again"); + System.exit(-1); + } + } + private void initMenus() throws IOException { List menuData = getMenuData(); try { @@ -222,14 +332,21 @@ private List getMenuData() throws IOException { } private Role initRole() { - Role role = new Role(); - role.setName("admin"); + Role role = roleRepository.findAllByName(Contants.ADMIN) + .stream() + .findFirst() + .orElseGet(() -> { + Role newRole = new Role(); + newRole.setName(Contants.ADMIN); + return newRole; + }); - Set permissions = permissionRepository.findByDesc("super permission") + Permission superPermission = permissionRepository.findAllByName("*") .stream() - .collect(Collectors.toSet()); // Java 17+ 不可变列表 + .findFirst() + .orElseThrow(() -> new IllegalStateException("super permission not initialized")); - role.setPermission(permissions); + role.setPermission(new HashSet<>(List.of(superPermission))); Set all = menuRepository.findAll().stream().collect(Collectors.toSet()); role.setMenus(all); @@ -238,6 +355,11 @@ private Role initRole() { } private void initUser(Role role) { + if (!userRepository.findAllByEmail("admin@no-reply.com").isEmpty()) { + logger.info("[APP]: admin user exists, skip create"); + return; + } + User user = new User(); user.setEmail("admin@no-reply.com"); String password; @@ -250,14 +372,11 @@ private void initUser(Role role) { user.setName(Contants.ADMIN); user.setSalt(Contants.PUBLICK_SALT); user.setStatus(Contants.USER_STATUS_YES); - Optional optionalRole = roleRepository.findByName(Contants.ADMIN); - Role adminRole = optionalRole.get(); - List roleList = List.of(adminRole); + List roleList = List.of(role); user.setRole(roleList); user = userRepository.save(user); logger.info("[APP]: create admin user success"); - logger.info("[APP]: email: {}", user.getEmail()); - logger.info("[APP]: password: 'admin'"); + logger.info("[APP]: default admin credentials created; password omitted from logs"); logger.info("Enjoy!"); } @@ -282,4 +401,4 @@ private void testRedisConnection() { logger.error("[❌ ERROR] Redis 连接失败!", e); } } -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/TinyProApplication.java b/template/springboot/src/main/java/com/TinyPro/TinyProApplication.java index 0627bec4..823fa367 100644 --- a/template/springboot/src/main/java/com/TinyPro/TinyProApplication.java +++ b/template/springboot/src/main/java/com/TinyPro/TinyProApplication.java @@ -4,10 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication(scanBasePackages = {"com.TinyPro"}) -@MapperScan(basePackages = {"com.TinyPro.mappers"}) +@ConfigurationPropertiesScan(basePackages = "com.TinyPro") @EntityScan(basePackages = "com.TinyPro.entity.po") @EnableJpaAuditing public class TinyProApplication { diff --git a/template/springboot/src/main/java/com/TinyPro/config/TinyProProperties.java b/template/springboot/src/main/java/com/TinyPro/config/TinyProProperties.java new file mode 100644 index 00000000..b26c65ee --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/config/TinyProProperties.java @@ -0,0 +1,154 @@ +package com.TinyPro.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; +import org.springframework.validation.annotation.Validated; + +/** + * Application-owned settings that need validation before the application starts. + */ +@Validated +@ConfigurationProperties(prefix = "tinypro") +public class TinyProProperties { + + @Valid + @NotNull + private Jwt jwt = new Jwt(); + + @Valid + @NotNull + private Reject reject = new Reject(); + + @Valid + @NotNull + private Logging logging = new Logging(); + + public Jwt getJwt() { + return jwt; + } + + public void setJwt(Jwt jwt) { + this.jwt = jwt; + } + + public Reject getReject() { + return reject; + } + + public void setReject(Reject reject) { + this.reject = reject; + } + + public Logging getLogging() { + return logging; + } + + public void setLogging(Logging logging) { + this.logging = logging; + } + + public static class Jwt { + + @NotBlank(message = "tinypro.jwt.secret must not be blank") + @Size(min = 8, message = "tinypro.jwt.secret must contain at least 8 characters") + private String secret; + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + } + + public static class Reject { + + @NotNull(message = "tinypro.reject.start must be specified") + private Boolean start = false; + + public Boolean getStart() { + return start; + } + + public void setStart(Boolean start) { + this.start = start; + } + } + + public static class Logging { + + @NotBlank(message = "tinypro.logging.file must not be blank") + private String file = "logs/tiny-pro.log"; + + @NotBlank(message = "tinypro.logging.max-file-size must not be blank") + private String maxFileSize = "50MB"; + + @Min(value = 1, message = "tinypro.logging.max-history must be at least 1") + @Max(value = 365, message = "tinypro.logging.max-history must not exceed 365") + private int maxHistory = 30; + + @NotBlank(message = "tinypro.logging.total-size-cap must not be blank") + private String totalSizeCap = "2GB"; + + @AssertTrue(message = "tinypro.logging.max-file-size must be a valid data size") + public boolean isMaxFileSizeValid() { + return isDataSize(maxFileSize); + } + + @AssertTrue(message = "tinypro.logging.total-size-cap must be a valid data size") + public boolean isTotalSizeCapValid() { + return isDataSize(totalSizeCap); + } + + private boolean isDataSize(String value) { + if (value == null || value.isBlank()) { + return false; + } + try { + return DataSize.parse(value).toBytes() > 0; + } catch (IllegalArgumentException ex) { + return false; + } + } + + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } + + public String getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(String maxFileSize) { + this.maxFileSize = maxFileSize; + } + + public int getMaxHistory() { + return maxHistory; + } + + public void setMaxHistory(int maxHistory) { + this.maxHistory = maxHistory; + } + + public String getTotalSizeCap() { + return totalSizeCap; + } + + public void setTotalSizeCap(String totalSizeCap) { + this.totalSizeCap = totalSizeCap; + } + } +} diff --git a/template/springboot/src/main/java/com/TinyPro/controller/ApplicationController.java b/template/springboot/src/main/java/com/TinyPro/controller/ApplicationController.java new file mode 100644 index 00000000..d0bff032 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/controller/ApplicationController.java @@ -0,0 +1,49 @@ +package com.TinyPro.controller; + +import com.TinyPro.entity.dto.CreateApplicationDto; +import com.TinyPro.entity.dto.PaginationQueryDto; +import com.TinyPro.entity.po.Application; +import com.TinyPro.entity.vo.ApplicationVo; +import com.TinyPro.entity.vo.I18Vo; +import com.TinyPro.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/application") +@Tag(name = "应用管理", description = "应用相关接口") +public class ApplicationController { + @Autowired + private ApplicationService applicationService; + + @Operation(summary = "获取应用列表", description = "分页查询应用,支持关键词搜索和分类过滤") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "成功", + content = @Content(schema = @Schema(implementation = ApplicationVo.class))) + }) + @GetMapping + public ResponseEntity getAllApplication( + @Valid @ModelAttribute PaginationQueryDto searchInfo) { + return new ResponseEntity<>(applicationService.findAllApplication(searchInfo), HttpStatus.OK); + } + + @Operation(summary = "创建应用", description = "创建新应用,支持初始化模式(已存在则返回)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "创建成功"), + @ApiResponse(responseCode = "400", description = "应用已存在") + }) + @PostMapping + public Application createApplication(@Valid @RequestBody CreateApplicationDto dto, + @RequestParam(defaultValue = "false") boolean isInit) { + return applicationService.createApplication(dto, isInit); + } +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/dto/CreateApplicationDto.java b/template/springboot/src/main/java/com/TinyPro/entity/dto/CreateApplicationDto.java new file mode 100644 index 00000000..db79986d --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/entity/dto/CreateApplicationDto.java @@ -0,0 +1,26 @@ +package com.TinyPro.entity.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "创建应用请求") +public class CreateApplicationDto { + @NotBlank + @Schema(description = "应用名称", required = true) + private String name; + + @Schema(description = "描述") + private String description; + + @Schema(description = "标签数组或 JSON 字符串") + private JsonNode tag; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "分类") + private String classify; +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/dto/PaginationQueryDto.java b/template/springboot/src/main/java/com/TinyPro/entity/dto/PaginationQueryDto.java index f3db5217..e3cb8179 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/dto/PaginationQueryDto.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/dto/PaginationQueryDto.java @@ -1,18 +1,52 @@ package com.TinyPro.entity.dto; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.core.convert.converter.Converter; -import org.springframework.stereotype.Component; +import io.swagger.v3.oas.annotations.Parameter; + + +import lombok.Getter; +import org.springdoc.core.annotations.ParameterObject; + @Data +@ParameterObject // 让 Springdoc 展开参数到 Swagger 文档中 public class PaginationQueryDto { - @Min(1) - private Integer page = 1; // 默认值1,可通过配置覆盖 + @Getter + @Parameter(description = "页数,必须是一个大于零的正整数", example = "1") + @Min(1) + private Integer page=1; // 默认值 1 + + @Getter + @Parameter(description = "页大小,必须是一个正整数,最大 100", example = "10") + @Min(1) + @Max(100) + private Integer limit=1; // 默认值 1(与 NestJS 原代码一致,也可根据需要改为 10) + + @Parameter(description = "类型") + private String classify; // 可选,不加校验 + + @Parameter(description = "关键字") + private String keywords; + + /** + * Supports clients that use pageIndex/pageSize instead of page/limit. + */ + public void setPageIndex(Integer pageIndex) { + if (pageIndex != null) { + this.page = pageIndex; + } + } + + public void setPageSize(Integer pageSize) { + if (pageSize != null) { + this.limit = pageSize; + } + } + + - @Min(1) - private Integer limit = 10; // 默认值10,可通过配置覆盖 -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/Application.java b/template/springboot/src/main/java/com/TinyPro/entity/po/Application.java new file mode 100644 index 00000000..96c9c813 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/Application.java @@ -0,0 +1,61 @@ +package com.TinyPro.entity.po; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "application") +@Data +public class Application { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + private String description; + @Lob + @Column(columnDefinition = "TEXT") + private String tag; + + private String icon; + + private String classify; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + public Application(String name, String description, String tag, String icon, String classify) { + this.name = name; + this.description = description; + this.tag = tag; + this.icon = icon; + this.classify = classify; + } + + public Application() { + + } + + + // 安全解析 tag(非持久化方法) + public Object parseTagSafely() { + if (tag == null || tag.isBlank()) return new Object[0]; + try { + return new ObjectMapper().readValue(tag, Object.class); + } catch (Exception e) { + return new Object[0]; + } + } + + +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/Lang.java b/template/springboot/src/main/java/com/TinyPro/entity/po/Lang.java index 1fea46b9..5599722c 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/po/Lang.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/Lang.java @@ -24,10 +24,10 @@ public class Lang implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - + @Column(nullable = false, unique = true) private String name; @OneToMany(mappedBy = "lang", fetch = FetchType.LAZY) @JsonBackReference private List i18ns; -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/Menu.java b/template/springboot/src/main/java/com/TinyPro/entity/po/Menu.java index cf65ad87..f6fc2328 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/po/Menu.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/Menu.java @@ -21,8 +21,9 @@ public class Menu implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - + @Column(nullable = false, unique = true) private String name; + @Column(name = "`order`") @TableField("'order'") private Integer order; private Integer parentId; @@ -31,4 +32,4 @@ public class Menu implements Serializable { private String component; private String path; private String locale; -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/Permission.java b/template/springboot/src/main/java/com/TinyPro/entity/po/Permission.java index 201a5a44..706c150a 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/po/Permission.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/Permission.java @@ -19,7 +19,8 @@ public class Permission implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - + @Column(nullable = false, unique = true) private String name; + @Column(name = "`desc`") private String desc; -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/Role.java b/template/springboot/src/main/java/com/TinyPro/entity/po/Role.java index 9a5121cc..9562fb4b 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/po/Role.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/Role.java @@ -23,7 +23,7 @@ public class Role implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - + @Column(nullable = false, unique = true) private String name; @ManyToMany(fetch = FetchType.LAZY) @@ -38,4 +38,4 @@ public class Role implements Serializable { inverseJoinColumns = @JoinColumn(name = "menu_id")) private Set menus; -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/po/User.java b/template/springboot/src/main/java/com/TinyPro/entity/po/User.java index f08c8510..f2b7aa06 100644 --- a/template/springboot/src/main/java/com/TinyPro/entity/po/User.java +++ b/template/springboot/src/main/java/com/TinyPro/entity/po/User.java @@ -25,8 +25,8 @@ public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; - private String name; + @Column(nullable = false, unique = true) private String email; private String password; @@ -72,4 +72,4 @@ public class User { public void onCreate() { // 这里调用加密工具给 password 加盐 } -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/entity/vo/ApplicationVo.java b/template/springboot/src/main/java/com/TinyPro/entity/vo/ApplicationVo.java new file mode 100644 index 00000000..c9ec8080 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/entity/vo/ApplicationVo.java @@ -0,0 +1,32 @@ +package com.TinyPro.entity.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "应用分页响应") +public class ApplicationVo { + @Schema(description = "应用列表") + private List data; + + @Schema(description = "总记录数") + private long total; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "应用条目") + public static class ApplicationItem { + private Long id; + private String name; + private String description; + private Object tag; + private String icon; + private String classify; + } +} diff --git a/template/springboot/src/main/java/com/TinyPro/exception/GlobalExceptionHandler.java b/template/springboot/src/main/java/com/TinyPro/exception/GlobalExceptionHandler.java index 713c8632..1b67468f 100644 --- a/template/springboot/src/main/java/com/TinyPro/exception/GlobalExceptionHandler.java +++ b/template/springboot/src/main/java/com/TinyPro/exception/GlobalExceptionHandler.java @@ -2,65 +2,115 @@ import com.TinyPro.entity.contants.Contants; import com.TinyPro.utils.LocaleUntil; -import org.hibernate.exception.ConstraintViolationException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import java.text.MessageFormat; -import java.util.List; import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { - @Autowired - private MessageSource messageSource; - public GlobalExceptionHandler() { + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); - } + @Autowired + private MessageSource messageSource; @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception ex) { - if (ex instanceof BusinessException){ - BusinessException exception= (BusinessException) ex; - // 根据错误码和参数获取国际化错误消息 - String message = messageSource.getMessage( - exception.getErrorCode(), - new Object[]{exception.getArgs()}, - LocaleUntil.getLocale() - ); - - // 创建自定义的错误响应对象,可以包含错误信息和状态码数值 - ErrorResponse errorResponse = new ErrorResponse(message, exception.getHttpStatus().value()); + public ResponseEntity handleException(Exception ex, HttpServletRequest request) { + if (ex instanceof BusinessException businessException) { + return handleBusinessException(businessException); + } + if (ex instanceof HttpMessageNotReadableException readableException) { + Throwable cause = readableException.getMostSpecificCause(); + logger.warn("Malformed request body for {} {}: {}", + request.getMethod(), request.getRequestURI(), cause.getMessage()); return ResponseEntity - .status(exception.getHttpStatus()) - .body(errorResponse); - } else if (ex instanceof MethodArgumentNotValidException || ex instanceof ConstraintViolationException) { - MethodArgumentNotValidException exception= (MethodArgumentNotValidException) ex; - String errorMsg = exception.getBindingResult() + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.getReasonPhrase(), + HttpStatus.BAD_REQUEST.value())); + } + + if (ex instanceof ResponseStatusException statusException) { + logger.warn("Request rejected: {} {} -> {} ({})", + request.getMethod(), request.getRequestURI(), + statusException.getStatusCode().value(), statusException.getReason()); + int status = statusException.getStatusCode().value(); + HttpStatus knownStatus = HttpStatus.resolve(status); + String message = statusException.getReason() != null + ? statusException.getReason() + : knownStatus == null ? "Request failed" : knownStatus.getReasonPhrase(); + return ResponseEntity.status(status).body(new ErrorResponse(message, status)); + } + + if (ex instanceof MethodArgumentNotValidException validationException) { + String errorMessage = validationException.getBindingResult() .getFieldErrors() .stream() - .map(error -> MessageFormat.format(error.getDefaultMessage(),error.getField())) + .map(error -> MessageFormat.format(error.getDefaultMessage(), error.getField())) .collect(Collectors.joining(", ")); - // 创建自定义的错误响应对象,可以包含错误信息和状态码数值 - BusinessException e = new BusinessException(errorMsg, HttpStatus.BAD_REQUEST, null); - NoExistErrorResponse errorResponse = new NoExistErrorResponse(new String[]{errorMsg}, e.getHttpStatus().value(), Contants.NO_EXIST_ERROR_RESPONSE); + logger.warn("Request validation failed: {} {} -> {}", + request.getMethod(), request.getRequestURI(), errorMessage); + return validationError(errorMessage); + } - return ResponseEntity - .status(e.getHttpStatus()) - .body(errorResponse); + if (ex instanceof ConstraintViolationException validationException) { + String errorMessage = validationException.getConstraintViolations() + .stream() + .map(error -> error.getPropertyPath() + ": " + error.getMessage()) + .collect(Collectors.joining(", ")); + logger.warn("Request validation failed: {} {} -> {}", + request.getMethod(), request.getRequestURI(), errorMessage); + return validationError(errorMessage); + } + + if (ex instanceof DataIntegrityViolationException + || ex instanceof org.hibernate.exception.ConstraintViolationException) { + logger.error("Database constraint violation for {} {}", + request.getMethod(), request.getRequestURI(), ex); } else { - ErrorResponse errorResponse = new ErrorResponse(Contants.PUBLIC_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.value()); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(errorResponse); + logger.error("Unhandled exception for {} {}", + request.getMethod(), request.getRequestURI(), ex); } + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(Contants.PUBLIC_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.value())); + } + + private ResponseEntity handleBusinessException(BusinessException exception) { + HttpStatus status = exception.getHttpStatus() == null + ? HttpStatus.INTERNAL_SERVER_ERROR + : exception.getHttpStatus(); + String message = messageSource.getMessage( + exception.getErrorCode(), + new Object[]{exception.getArgs()}, + LocaleUntil.getLocale() + ); + return ResponseEntity + .status(status) + .body(new ErrorResponse(message, status.value())); + } + + private ResponseEntity validationError(String errorMessage) { + NoExistErrorResponse errorResponse = new NoExistErrorResponse( + new String[]{errorMessage}, + HttpStatus.BAD_REQUEST.value(), + Contants.NO_EXIST_ERROR_RESPONSE + ); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/filter/RejectInterceptor.java b/template/springboot/src/main/java/com/TinyPro/filter/RejectInterceptor.java index 325461a3..38152390 100644 --- a/template/springboot/src/main/java/com/TinyPro/filter/RejectInterceptor.java +++ b/template/springboot/src/main/java/com/TinyPro/filter/RejectInterceptor.java @@ -1,13 +1,11 @@ package com.TinyPro.filter; import com.TinyPro.annotation.Reject; +import com.TinyPro.config.TinyProProperties; import com.TinyPro.exception.BusinessException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.MessageSource; -import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; @@ -16,8 +14,16 @@ @Component public class RejectInterceptor implements HandlerInterceptor { - @Value("${reject.start}") - private Boolean rejectStart; + private final boolean rejectStart; + + public RejectInterceptor() { + this.rejectStart = false; + } + + @Autowired + public RejectInterceptor(TinyProProperties properties) { + this.rejectStart = properties.getReject().getStart(); + } @Override public boolean preHandle(HttpServletRequest request, @@ -40,4 +46,4 @@ public boolean preHandle(HttpServletRequest request, } return true; } -} \ No newline at end of file +} diff --git a/template/springboot/src/main/java/com/TinyPro/jpa/ApplicationRepository.java b/template/springboot/src/main/java/com/TinyPro/jpa/ApplicationRepository.java new file mode 100644 index 00000000..e9c9c118 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/jpa/ApplicationRepository.java @@ -0,0 +1,12 @@ +package com.TinyPro.jpa; + +import com.TinyPro.entity.po.Application; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApplicationRepository extends JpaRepository, JpaSpecificationExecutor { + Application findByName(String name); + boolean existsByName(String name); +} diff --git a/template/springboot/src/main/java/com/TinyPro/jpa/IPermissionRepository.java b/template/springboot/src/main/java/com/TinyPro/jpa/IPermissionRepository.java index 23511a68..4dbdb145 100644 --- a/template/springboot/src/main/java/com/TinyPro/jpa/IPermissionRepository.java +++ b/template/springboot/src/main/java/com/TinyPro/jpa/IPermissionRepository.java @@ -17,6 +17,8 @@ public interface IPermissionRepository extends JpaRepository { Optional findByDesc(String desc); Optional findByName(String name); + List findAllByName(String name); + boolean existsByName(String name); Page findByNameContainingIgnoreCase(String name, Pageable pageable); @Query("SELECT p FROM Role r JOIN r.permission p WHERE r.id IN :roleIds") List findByRoleIdIn(@Param("roleIds") List roleIds); diff --git a/template/springboot/src/main/java/com/TinyPro/jpa/IRoleRepository.java b/template/springboot/src/main/java/com/TinyPro/jpa/IRoleRepository.java index b9a6363c..e246b151 100644 --- a/template/springboot/src/main/java/com/TinyPro/jpa/IRoleRepository.java +++ b/template/springboot/src/main/java/com/TinyPro/jpa/IRoleRepository.java @@ -19,6 +19,7 @@ public interface IRoleRepository extends JpaRepository { Optional findByName(String name); + List findAllByName(String name); List findAllById(Iterable ids); @Modifying diff --git a/template/springboot/src/main/java/com/TinyPro/jpa/IUserRepository.java b/template/springboot/src/main/java/com/TinyPro/jpa/IUserRepository.java index 35aa06e2..e473e00d 100644 --- a/template/springboot/src/main/java/com/TinyPro/jpa/IUserRepository.java +++ b/template/springboot/src/main/java/com/TinyPro/jpa/IUserRepository.java @@ -11,6 +11,7 @@ public interface IUserRepository extends JpaRepository , JpaSpecificationExecutor { Optional findByEmail(String email); + List findAllByEmail(String email); boolean existsByRoleId(Long roleId); @Modifying void deleteByRoleId(Long roleId); diff --git a/template/springboot/src/main/java/com/TinyPro/logging/MaskingPatternLayout.java b/template/springboot/src/main/java/com/TinyPro/logging/MaskingPatternLayout.java new file mode 100644 index 00000000..c2bbc4cd --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/logging/MaskingPatternLayout.java @@ -0,0 +1,61 @@ +package com.TinyPro.logging; + +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.ILoggingEvent; + +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Masks credentials in the fully rendered log line, including exception text. + */ +public class MaskingPatternLayout extends PatternLayout { + + private static final String MASK = "******"; + private static final Pattern BEARER_PATTERN = Pattern.compile( + "(?i)(\\bBearer\\s+)([^\\s,;]+)"); + private static final Pattern AUTHORIZATION_PATTERN = Pattern.compile( + "(?i)(\\bauthorization\\b\\s*[:=]\\s*)(Bearer\\s+)?([^\\s,;]+)"); + private static final Pattern SENSITIVE_ASSIGNMENT_PATTERN = Pattern.compile( + "(?i)((? matcher.group(1) + MASK); + masked = replace(masked, AUTHORIZATION_PATTERN, + matcher -> matcher.group(1) + + (matcher.group(2) == null ? "" : matcher.group(2)) + + MASK); + return replace(masked, SENSITIVE_ASSIGNMENT_PATTERN, + matcher -> matcher.group(1) + maskValue(matcher.group(2))); + } + + private static String maskValue(String value) { + if (value.length() >= 2 + && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")))) { + return value.charAt(0) + MASK + value.charAt(value.length() - 1); + } + return MASK; + } + + private static String replace(String value, Pattern pattern, + Function replacement) { + Matcher matcher = pattern.matcher(value); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(result, + Matcher.quoteReplacement(replacement.apply(matcher))); + } + matcher.appendTail(result); + return result.toString(); + } +} diff --git a/template/springboot/src/main/java/com/TinyPro/service/ApplicationService.java b/template/springboot/src/main/java/com/TinyPro/service/ApplicationService.java new file mode 100644 index 00000000..af9c4ca4 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/service/ApplicationService.java @@ -0,0 +1,21 @@ +package com.TinyPro.service; + +import com.TinyPro.entity.dto.CreateApplicationDto; +import com.TinyPro.entity.dto.PaginationQueryDto; +import com.TinyPro.entity.po.Application; +import com.TinyPro.entity.vo.ApplicationVo; + +public interface ApplicationService { + /** + * 分页查询应用列表 + */ + ApplicationVo findAllApplication(PaginationQueryDto searchInfo); + + /** + * 创建应用 + * @param dto 应用信息 + * @param isInit 是否初始化模式(true: 已存在则直接返回,不抛异常;false: 已存在则抛异常) + * @return 已存在或新建的应用实体 + */ + Application createApplication(CreateApplicationDto dto, boolean isInit); +} diff --git a/template/springboot/src/main/java/com/TinyPro/service/imp/ApplicationServiceImpl.java b/template/springboot/src/main/java/com/TinyPro/service/imp/ApplicationServiceImpl.java new file mode 100644 index 00000000..1f2d2ab6 --- /dev/null +++ b/template/springboot/src/main/java/com/TinyPro/service/imp/ApplicationServiceImpl.java @@ -0,0 +1,124 @@ +package com.TinyPro.service.imp; + +import com.TinyPro.entity.dto.CreateApplicationDto; +import com.TinyPro.entity.dto.PaginationQueryDto; +import com.TinyPro.entity.po.Application; +import com.TinyPro.entity.vo.ApplicationVo; +import com.TinyPro.jpa.ApplicationRepository; +import com.TinyPro.service.ApplicationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.criteria.Predicate; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +public class ApplicationServiceImpl implements ApplicationService { + + private final ApplicationRepository applicationRepository; + private final MessageSource messageSource; + private final ObjectMapper objectMapper; + + // 分页查询 + public ApplicationVo findAllApplication(PaginationQueryDto searchInfo) { + int page = searchInfo.getPage(); + int limit = searchInfo.getLimit(); + String keywords = searchInfo.getKeywords(); + String classify = searchInfo.getClassify(); + Pageable pageable = PageRequest.of(page - 1, limit); + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (keywords != null && !keywords.isEmpty()) { + String pattern = "%" + keywords + "%"; + Predicate namePred = cb.like(root.get("name"), pattern); + Predicate descPred = cb.like(root.get("description"), pattern); + Predicate tagPred = cb.like(root.get("tag"), pattern); + predicates.add(cb.or(namePred, descPred, tagPred)); + } + + if (classify != null && !classify.isBlank() && !"all".equalsIgnoreCase(classify)) { + predicates.add(cb.equal(root.get("classify"), classify)); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + Page pageResult = applicationRepository.findAll(spec, pageable); + + List items = pageResult.getContent().stream() + .map(app -> new ApplicationVo.ApplicationItem( + app.getId(), + app.getName(), + app.getDescription(), + app.parseTagSafely(), + app.getIcon(), + app.getClassify() + )) + .toList(); + + return new ApplicationVo(items, pageResult.getTotalElements()); + } + + // 创建应用(支持 isInit) + @Transactional + public Application createApplication(@Valid CreateApplicationDto dto, boolean isInit) { + String name = dto.getName(); + Application existing = applicationRepository.findByName(name); + Locale locale = LocaleContextHolder.getLocale(); + + if (isInit && existing != null) { + return existing; + } + + if (!isInit && existing != null) { + String msg = messageSource.getMessage( + "exception.applicationInfo.exists", + new Object[]{name}, + "应用已存在", + locale + ); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, msg); + } + + Application newApp = new Application( + dto.getName(), + dto.getDescription(), + serializeTag(dto.getTag()), + dto.getIcon(), + dto.getClassify() + ); + return applicationRepository.save(newApp); + } + + private String serializeTag(JsonNode tag) { + if (tag == null || tag.isNull()) { + return null; + } + if (tag.isTextual()) { + return tag.textValue(); + } + try { + return objectMapper.writeValueAsString(tag); + } catch (JsonProcessingException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid tag format", ex); + } + } +} diff --git a/template/springboot/src/main/java/com/TinyPro/utils/JwtUtil.java b/template/springboot/src/main/java/com/TinyPro/utils/JwtUtil.java index bfd5ce25..6d0557e5 100644 --- a/template/springboot/src/main/java/com/TinyPro/utils/JwtUtil.java +++ b/template/springboot/src/main/java/com/TinyPro/utils/JwtUtil.java @@ -1,9 +1,9 @@ package com.TinyPro.utils; +import com.TinyPro.config.TinyProProperties; import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.crypto.spec.SecretKeySpec; @@ -18,10 +18,15 @@ public class JwtUtil { private final Key secretKey; - @Value("${jwt.secret}") - private String secretString; + @Autowired + public JwtUtil(TinyProProperties properties) { + this(properties.getJwt().getSecret()); + } - public JwtUtil(@Value("${jwt.secret}") String secretString) { + /** + * Kept for callers that construct this utility directly in tests or integrations. + */ + public JwtUtil(String secretString) { try { // 使用 SHA-256 哈希算法将字符串转换为字节数组 MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -98,4 +103,4 @@ public String extendExpiration(String oldJwt) { .signWith(secretKey) // 用同一 key 重新签名 .compact(); } -} \ No newline at end of file +} diff --git a/template/springboot/src/main/resources/application.properties b/template/springboot/src/main/resources/application.properties index 235c5bef..6ccd0108 100644 --- a/template/springboot/src/main/resources/application.properties +++ b/template/springboot/src/main/resources/application.properties @@ -1,7 +1,7 @@ -server.port=3000 -spring.datasource.url=jdbc:mysql://localhost:3306/login?allowMultiQueries=true&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false +server.port=3001 +spring.datasource.url=jdbc:mysql://localhost:3306/tiny_pro?allowMultiQueries=true&serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false spring.datasource.username=root -spring.datasource.password=111111 +spring.datasource.password=${DATABASE_PASSWORD:111111} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.hikari.pool-name=HikariCPDatasource spring.datasource.hikari.minimum-idle=5 @@ -18,10 +18,19 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL8Dialect spring.jpa.properties.hibernate.dialect.storage_engine=innodb -spring.jpa.properties.hibernate.globally_quoted_identifiers=true +spring.jpa.properties.hibernate.globally_quoted_identifiers=false spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl -jwt.secret=0Zi4SA== -# Redis 配置 +jwt.secret=${JWT_SECRET:0Zi4SA==} +# Redis \u914D\u7F6E spring.data.redis.host=localhost spring.data.redis.port=6379 -reject.start=false \ No newline at end of file +spring.data.redis.password=${REDIS_PASSWORD:000000} +reject.start=${REJECT_START:false} + +# Bind application-owned settings as one validated configuration object. +tinypro.jwt.secret=${jwt.secret} +tinypro.reject.start=${reject.start} +tinypro.logging.file=${LOG_FILE:logs/tiny-pro.log} +tinypro.logging.max-file-size=${LOG_MAX_FILE_SIZE:50MB} +tinypro.logging.max-history=${LOG_MAX_HISTORY:30} +tinypro.logging.total-size-cap=${LOG_TOTAL_SIZE_CAP:2GB} diff --git a/template/springboot/src/main/resources/i18n/messages_zh_CN.properties b/template/springboot/src/main/resources/i18n/messages_zh_CN.properties index 499c6778..b6bdcfc6 100644 --- a/template/springboot/src/main/resources/i18n/messages_zh_CN.properties +++ b/template/springboot/src/main/resources/i18n/messages_zh_CN.properties @@ -1,134 +1,139 @@ # General -hello=你好 {0} +hello=\u4F60\u597D {0} code=zh-CN -yes=是 -no=否 +yes=\u662F +no=\u5426 # Menu -menu.board=看板 -menu.home=监控页 -menu.work=工作台 -menu.list=列表页 -menu.result=结果页 -menu.exception=异常页 -menu.form=表单页 -menu.profile=详情页 -menu.profile.detail=基础详情页 -menu.visualization=数据可视化 -menu.menuPage=菜单页 -menu.menuPage.second=二级菜单 -menu.menuPage.third=菜单demo页 -menu.user=个人中心 -menu.systemManager=系统管理 -menu.userManager=用户管理 -menu.userManager.info=查看用户 -menu.userManager.setting=修改信息 -menu.userManager.useradd=添加用户 -menu.permission=权限管理 -menu.permission.info=查看权限 -menu.permission.setting=修改权限 -menu.permission.permissionAdd=添加权限 -menu.role=角色管理 -menu.role.info=查看角色 -menu.menu=菜单管理 -menu.menu.info=查看菜单 -navbar.docs=文档中心 -navbar.action.locale=切换为中文 +menu.board=\u770B\u677F +menu.home=\u76D1\u63A7\u9875 +menu.work=\u5DE5\u4F5C\u53F0 +menu.list=\u5217\u8868\u9875 +menu.result=\u7ED3\u679C\u9875 +menu.exception=\u5F02\u5E38\u9875 +menu.form=\u8868\u5355\u9875 +menu.profile=\u8BE6\u60C5\u9875 +menu.profile.detail=\u57FA\u7840\u8BE6\u60C5\u9875 +menu.visualization=\u6570\u636E\u53EF\u89C6\u5316 +menu.menuPage=\u83DC\u5355\u9875 +menu.menuPage.second=\u4E8C\u7EA7\u83DC\u5355 +menu.menuPage.third=\u83DC\u5355demo\u9875 +menu.user=\u4E2A\u4EBA\u4E2D\u5FC3 +menu.systemManager=\u7CFB\u7EDF\u7BA1\u7406 +menu.userManager=\u7528\u6237\u7BA1\u7406 +menu.userManager.info=\u67E5\u770B\u7528\u6237 +menu.userManager.setting=\u4FEE\u6539\u4FE1\u606F +menu.userManager.useradd=\u6DFB\u52A0\u7528\u6237 +menu.permission=\u6743\u9650\u7BA1\u7406 +menu.permission.info=\u67E5\u770B\u6743\u9650 +menu.permission.setting=\u4FEE\u6539\u6743\u9650 +menu.permission.permissionAdd=\u6DFB\u52A0\u6743\u9650 +menu.role=\u89D2\u8272\u7BA1\u7406 +menu.role.info=\u67E5\u770B\u89D2\u8272 +menu.menu=\u83DC\u5355\u7BA1\u7406 +menu.menu.info=\u67E5\u770B\u83DC\u5355 +navbar.docs=\u6587\u6863\u4E2D\u5FC3 +navbar.action.locale=\u5207\u6362\u4E3A\u4E2D\u6587 # Messages -messageBox.switchRoles=切换角色 -messageBox.userCenter=用户中心 -messageBox.userSettings=用户设置 -messageBox.logout=退出登录 -messageBox.updatePwd=修改密码 -message.delete.success=删除成功 +messageBox.switchRoles=\u5207\u6362\u89D2\u8272 +messageBox.userCenter=\u7528\u6237\u4E2D\u5FC3 +messageBox.userSettings=\u7528\u6237\u8BBE\u7F6E +messageBox.logout=\u9000\u51FA\u767B\u5F55 +messageBox.updatePwd=\u4FEE\u6539\u5BC6\u7801 +message.delete.success=\u5220\u9664\u6210\u529F # Cloud -menu.cloud=云服务能力展示 -menu.btn.confirm=确认 -menu.i18n=国际化管理 +menu.cloud=\u4E91\u670D\u52A1\u80FD\u529B\u5C55\u793A +menu.btn.confirm=\u786E\u8BA4 +menu.i18n=\u56FD\u9645\u5316\u7BA1\u7406 # Theme -theme.title.main=个性化配置 -theme.title.first=主题 -theme.title.default=默认主题 -theme.title.honey=蜜糖主题 -theme.title.violet=紫罗兰主题 -theme.title.deepness=深邃夜空主题 -theme.title.deep=深色主题 -theme.title.light=浅色主题 -theme.title.customization=自定义主题 -theme-title-recommend=推荐主题 -theme-text-default=科技、探索、钻研、精尖、包容 -theme-text-honey=明快、感性、温暖、积极、活力 -theme-text-violet=优雅、浪漫、温柔、神秘、高贵 -theme-text-deepness=平稳、中性、空间、力量、坚硬 -theme-text-dark=深沉、果断、勇敢、坚韧、向往 +theme.title.main=\u4E2A\u6027\u5316\u914D\u7F6E +theme.title.first=\u4E3B\u9898 +theme.title.default=\u9ED8\u8BA4\u4E3B\u9898 +theme.title.honey=\u871C\u7CD6\u4E3B\u9898 +theme.title.violet=\u7D2B\u7F57\u5170\u4E3B\u9898 +theme.title.deepness=\u6DF1\u9083\u591C\u7A7A\u4E3B\u9898 +theme.title.deep=\u6DF1\u8272\u4E3B\u9898 +theme.title.light=\u6D45\u8272\u4E3B\u9898 +theme.title.customization=\u81EA\u5B9A\u4E49\u4E3B\u9898 +theme-title-recommend=\u63A8\u8350\u4E3B\u9898 +theme-text-default=\u79D1\u6280\u3001\u63A2\u7D22\u3001\u94BB\u7814\u3001\u7CBE\u5C16\u3001\u5305\u5BB9 +theme-text-honey=\u660E\u5FEB\u3001\u611F\u6027\u3001\u6E29\u6696\u3001\u79EF\u6781\u3001\u6D3B\u529B +theme-text-violet=\u4F18\u96C5\u3001\u6D6A\u6F2B\u3001\u6E29\u67D4\u3001\u795E\u79D8\u3001\u9AD8\u8D35 +theme-text-deepness=\u5E73\u7A33\u3001\u4E2D\u6027\u3001\u7A7A\u95F4\u3001\u529B\u91CF\u3001\u575A\u786C +theme-text-dark=\u6DF1\u6C89\u3001\u679C\u65AD\u3001\u52C7\u6562\u3001\u575A\u97E7\u3001\u5411\u5F80 # Settings -settings.title=页面配置 -settings.themeColor=主题色 -settings.content=内容区域 -settings.search=搜索 -settings.language=语言 -settings.navbar=简约模式 -settings.menuWidth=菜单宽度 (px) -settings.navbar.alerts=消息通知 -settings.navbar.help=帮助中心 -settings.menu=经典模式 -settings.tabBar=多页签 -settings.footer=时尚模式 -settings.colorWeek=主题配置 -settings.alertContent=配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。 -settings.copySettings=复制配置 -settings.copySettings.message=复制成功,请粘贴到 src/settings.json 文件中 -settings.close=关闭 -settings.color.tooltip=根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效 +settings.title=\u9875\u9762\u914D\u7F6E +settings.themeColor=\u4E3B\u9898\u8272 +settings.content=\u5185\u5BB9\u533A\u57DF +settings.search=\u641C\u7D22 +settings.language=\u8BED\u8A00 +settings.navbar=\u7B80\u7EA6\u6A21\u5F0F +settings.menuWidth=\u83DC\u5355\u5BBD\u5EA6 (px) +settings.navbar.alerts=\u6D88\u606F\u901A\u77E5 +settings.navbar.help=\u5E2E\u52A9\u4E2D\u5FC3 +settings.menu=\u7ECF\u5178\u6A21\u5F0F +settings.tabBar=\u591A\u9875\u7B7E +settings.footer=\u65F6\u5C1A\u6A21\u5F0F +settings.colorWeek=\u4E3B\u9898\u914D\u7F6E +settings.alertContent=\u914D\u7F6E\u4E4B\u540E\u4EC5\u662F\u4E34\u65F6\u751F\u6548\uFF0C\u8981\u60F3\u771F\u6B63\u4F5C\u7528\u4E8E\u9879\u76EE\uFF0C\u70B9\u51FB\u4E0B\u65B9\u7684 "\u590D\u5236\u914D\u7F6E" \u6309\u94AE\uFF0C\u5C06\u914D\u7F6E\u66FF\u6362\u5230 settings.json \u4E2D\u5373\u53EF\u3002 +settings.copySettings=\u590D\u5236\u914D\u7F6E +settings.copySettings.message=\u590D\u5236\u6210\u529F\uFF0C\u8BF7\u7C98\u8D34\u5230 src/settings.json \u6587\u4EF6\u4E2D +settings.close=\u5173\u95ED +settings.color.tooltip=\u6839\u636E\u4E3B\u9898\u989C\u8272\u751F\u6210\u7684 10 \u4E2A\u68AF\u5EA6\u8272\uFF08\u5C06\u914D\u7F6E\u590D\u5236\u5230\u9879\u76EE\u4E2D\uFF0C\u4E3B\u9898\u8272\u624D\u80FD\u5BF9\u4EAE\u8272 / \u6697\u9ED1\u6A21\u5F0F\u540C\u65F6\u751F\u6548 # Common -common.unauth=未授权 -common.tokenError=异常token -common.tokenExpire=token 过期 -common.forbidden=没有 `{0}` 权限,请联系管理员 +common.unauth=\u672A\u6388\u6743 +common.tokenError=\u5F02\u5E38token +common.tokenExpire=token \u8FC7\u671F +common.forbidden=\u6CA1\u6709 `{0}` \u6743\u9650\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458 # User -user.oldPasswordError=旧密码错误 -user.requiredFieldsMissing=缺少必填字段; -user.userNotFound=用户不存在 -user.userExists=用户已存在 -user.userNumberNull=当前为最后一个用户,禁止删除 +user.oldPasswordError=\u65E7\u5BC6\u7801\u9519\u8BEF +user.requiredFieldsMissing=\u7F3A\u5C11\u5FC5\u586B\u5B57\u6BB5; +user.userNotFound=\u7528\u6237\u4E0D\u5B58\u5728 +user.userExists=\u7528\u6237\u5DF2\u5B58\u5728 +user.userNumberNull=\u5F53\u524D\u4E3A\u6700\u540E\u4E00\u4E2A\u7528\u6237\uFF0C\u7981\u6B62\u5220\u9664 # Role -role.exists=角色已存在 -role.notExists=角色不存在 -role.conflict=角色冲突,请联系管理员 +role.exists=\u89D2\u8272\u5DF2\u5B58\u5728 +role.notExists=\u89D2\u8272\u4E0D\u5B58\u5728 +role.conflict=\u89D2\u8272\u51B2\u7A81\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458 # Permission -permission.exists=权限 {0} 已存在 -permission.notExists=权限不存在 +permission.exists=\u6743\u9650 {0} \u5DF2\u5B58\u5728 +permission.notExists=\u6743\u9650\u4E0D\u5B58\u5728 # Menu -menu.exists=菜单 {0} 已存在 -menu.notExists=菜单不存在 +menu.exists=\u83DC\u5355 {0} \u5DF2\u5B58\u5728 +menu.notExists=\u83DC\u5355\u4E0D\u5B58\u5728 # Lang -lang.notExists={0} 不存在 -lang.exists={0} 已存在 -lang.notExistsCommon=语言不存在 -lang.DELETE_LANG_CONFLICT=请先清空{0}下的所有国际化词条 +lang.notExists={0} \u4E0D\u5B58\u5728 +lang.exists={0} \u5DF2\u5B58\u5728 +lang.notExistsCommon=\u8BED\u8A00\u4E0D\u5B58\u5728 +lang.DELETE_LANG_CONFLICT=\u8BF7\u5148\u6E05\u7A7A{0}\u4E0B\u7684\u6240\u6709\u56FD\u9645\u5316\u8BCD\u6761 # I18 -i18.exists=国际化词条已存在 -i18.notExists=国际化词条不存在 +i18.exists=\u56FD\u9645\u5316\u8BCD\u6761\u5DF2\u5B58\u5728 +i18.notExists=\u56FD\u9645\u5316\u8BCD\u6761\u4E0D\u5B58\u5728 # Auth -auth.userNotExists=用户不存在 -auth.passwordOrEmailError=账号或密码错误 +auth.userNotExists=\u7528\u6237\u4E0D\u5B58\u5728 +auth.passwordOrEmailError=\u8D26\u53F7\u6216\u5BC6\u7801\u9519\u8BEF # Preview -preview.reject-this-request=服务器拒绝了本次请求, 因为演示模式下不允许增加, 删除, 修改数据 +preview.reject-this-request=\u670D\u52A1\u5668\u62D2\u7EDD\u4E86\u672C\u6B21\u8BF7\u6C42, \u56E0\u4E3A\u6F14\u793A\u6A21\u5F0F\u4E0B\u4E0D\u5141\u8BB8\u589E\u52A0, \u5220\u9664, \u4FEE\u6539\u6570\u636E # Validations -NOT_EMPTY={0} 不能为空 -IS_ARRAY={0} 必须是数组 -NOT_EMPTY_HUMAN={0} 不能为空 +NOT_EMPTY={0} \u4E0D\u80FD\u4E3A\u7A7A +IS_ARRAY={0} \u5FC5\u987B\u662F\u6570\u7EC4 +NOT_EMPTY_HUMAN={0} \u4E0D\u80FD\u4E3A\u7A7A + +page=\u9875\u7801 +limit=\u6BCF\u9875\u6761\u6570 +jakarta.validation.constraints.Min.message={page}\u4E0D\u80FD\u5C0F\u4E8E{value} +jakarta.validation.constraints.Max.message={limit}\u4E0D\u80FD\u5927\u4E8E{value} diff --git a/template/springboot/src/main/resources/logback-spring.xml b/template/springboot/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..e5f241bc --- /dev/null +++ b/template/springboot/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n%ex + + + + + + ${logFile} + + ${logFile}.%d{yyyy-MM-dd}.%i.gz + ${logMaxFileSize} + ${logMaxHistory} + ${logTotalSizeCap} + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n%ex + + + + + + + + + diff --git a/template/springboot/src/test/java/com/TinyPro/config/TinyProPropertiesTest.java b/template/springboot/src/test/java/com/TinyPro/config/TinyProPropertiesTest.java new file mode 100644 index 00000000..ec620c6f --- /dev/null +++ b/template/springboot/src/test/java/com/TinyPro/config/TinyProPropertiesTest.java @@ -0,0 +1,30 @@ +package com.TinyPro.config; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TinyProPropertiesTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void acceptsValidConfiguration() { + TinyProProperties properties = new TinyProProperties(); + properties.getJwt().setSecret("a-valid-secret"); + + assertTrue(validator.validate(properties).isEmpty()); + } + + @Test + void rejectsBlankJwtSecretAndInvalidLogSize() { + TinyProProperties properties = new TinyProProperties(); + properties.getJwt().setSecret(" "); + properties.getLogging().setMaxFileSize("not-a-size"); + + assertFalse(validator.validate(properties).isEmpty()); + } +} diff --git a/template/springboot/src/test/java/com/TinyPro/controller/ApplicationControllerTest.java b/template/springboot/src/test/java/com/TinyPro/controller/ApplicationControllerTest.java new file mode 100644 index 00000000..07e01b90 --- /dev/null +++ b/template/springboot/src/test/java/com/TinyPro/controller/ApplicationControllerTest.java @@ -0,0 +1,56 @@ +package com.TinyPro.controller; + +import com.TinyPro.entity.dto.CreateApplicationDto; +import com.TinyPro.entity.dto.PaginationQueryDto; +import com.TinyPro.entity.po.Application; +import com.TinyPro.entity.vo.ApplicationVo; +import com.TinyPro.service.ApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ApplicationControllerTest { + + @Test + void bindsPageAndLimitQueryParameters() throws Exception { + RecordingApplicationService applicationService = new RecordingApplicationService(); + + MockMvc mockMvc = MockMvcBuilders + .standaloneSetup(controller(applicationService)) + .build(); + + mockMvc.perform(get("/application") + .param("page", "2") + .param("limit", "10")) + .andExpect(status().isOk()); + + assertEquals(2, applicationService.lastSearchInfo.getPage()); + assertEquals(10, applicationService.lastSearchInfo.getLimit()); + } + + private ApplicationController controller(ApplicationService applicationService) { + ApplicationController controller = new ApplicationController(); + ReflectionTestUtils.setField(controller, "applicationService", applicationService); + return controller; + } + + private static final class RecordingApplicationService implements ApplicationService { + private PaginationQueryDto lastSearchInfo; + + @Override + public ApplicationVo findAllApplication(PaginationQueryDto searchInfo) { + this.lastSearchInfo = searchInfo; + return new ApplicationVo(); + } + + @Override + public Application createApplication(CreateApplicationDto dto, boolean isInit) { + throw new UnsupportedOperationException("Not used in this test"); + } + } +} diff --git a/template/springboot/src/test/java/com/TinyPro/exception/GlobalExceptionHandlerTest.java b/template/springboot/src/test/java/com/TinyPro/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..2d9b8a57 --- /dev/null +++ b/template/springboot/src/test/java/com/TinyPro/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,37 @@ +package com.TinyPro.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void preservesResponseStatusExceptionStatus() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/application"); + + ResponseEntity response = handler.handleException( + new ResponseStatusException(HttpStatus.BAD_REQUEST, "application exists"), request); + + assertEquals(HttpStatus.BAD_REQUEST.value(), response.getStatusCode().value()); + assertEquals("application exists", ((ErrorResponse) response.getBody()).getMessage()); + } + + @Test + void logsAndHidesUnexpectedExceptionDetails() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/application"); + + ResponseEntity response = handler.handleException( + new IllegalStateException("database password=should-not-be-returned"), request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), response.getStatusCode().value()); + assertEquals(com.TinyPro.entity.contants.Contants.PUBLIC_ERROR, + ((ErrorResponse) response.getBody()).getMessage()); + } +} diff --git a/template/springboot/src/test/java/com/TinyPro/logging/MaskingPatternLayoutTest.java b/template/springboot/src/test/java/com/TinyPro/logging/MaskingPatternLayoutTest.java new file mode 100644 index 00000000..00541a5d --- /dev/null +++ b/template/springboot/src/test/java/com/TinyPro/logging/MaskingPatternLayoutTest.java @@ -0,0 +1,26 @@ +package com.TinyPro.logging; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MaskingPatternLayoutTest { + + @Test + void masksCredentialsInCommonLogFormats() { + String message = "password=admin token: abc123 " + + "\"secret\":\"jwt-secret\" Authorization: Bearer jwt-token"; + + String masked = MaskingPatternLayout.mask(message); + + assertTrue(masked.contains("password=******")); + assertTrue(masked.contains("token: ******")); + assertTrue(masked.contains("\"secret\":\"******\""), masked); + assertTrue(masked.contains("Authorization: Bearer ******")); + assertFalse(masked.contains("admin")); + assertFalse(masked.contains("abc123")); + assertFalse(masked.contains("jwt-secret")); + assertFalse(masked.contains("jwt-token")); + } +} diff --git a/template/springboot/src/test/java/com/TinyPro/service/imp/ApplicationServiceImplTest.java b/template/springboot/src/test/java/com/TinyPro/service/imp/ApplicationServiceImplTest.java new file mode 100644 index 00000000..7b852851 --- /dev/null +++ b/template/springboot/src/test/java/com/TinyPro/service/imp/ApplicationServiceImplTest.java @@ -0,0 +1,59 @@ +package com.TinyPro.service.imp; + +import com.TinyPro.entity.dto.CreateApplicationDto; +import com.TinyPro.entity.po.Application; +import com.TinyPro.jpa.ApplicationRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApplicationServiceImplTest { + + @Test + void serializesTagArrayBeforeSaving() throws Exception { + ApplicationRepository repository = repositoryStub(); + ObjectMapper objectMapper = new ObjectMapper(); + ApplicationServiceImpl service = new ApplicationServiceImpl(repository, null, objectMapper); + + CreateApplicationDto dto = new CreateApplicationDto(); + dto.setName("Tag array application"); + dto.setTag(objectMapper.readTree("[{\"type\":\"success\",\"value\":\"dev\"}]")); + + Application saved = service.createApplication(dto, false); + + assertEquals("[{\"type\":\"success\",\"value\":\"dev\"}]", saved.getTag()); + } + + @Test + void keepsJsonStringTagCompatible() throws Exception { + ApplicationRepository repository = repositoryStub(); + ObjectMapper objectMapper = new ObjectMapper(); + ApplicationServiceImpl service = new ApplicationServiceImpl(repository, null, objectMapper); + + CreateApplicationDto dto = new CreateApplicationDto(); + dto.setName("String tag application"); + dto.setTag(objectMapper.readTree("\"[{\\\"type\\\":\\\"success\\\"}]\"")); + + Application saved = service.createApplication(dto, false); + + assertEquals("[{\"type\":\"success\"}]", saved.getTag()); + } + + private ApplicationRepository repositoryStub() { + return (ApplicationRepository) Proxy.newProxyInstance( + ApplicationRepository.class.getClassLoader(), + new Class[]{ApplicationRepository.class}, + (proxy, method, args) -> { + if ("findByName".equals(method.getName())) { + return null; + } + if ("save".equals(method.getName())) { + return args[0]; + } + return null; + }); + } +}