mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat(platform): I1 bootstrap, I2 M1 APIs, OpenAPI SSOT, and CI guards
Deliver dual Spring Boot services (platform API + webhook ingress), JWT auth, Flyway with isolated history tables, customer/project/dictionary endpoints, OpenAPI snapshot under contracts/, RUNBOOK, and CI that runs on services/web/contracts paths plus enforcer + dependency tree ban on craftlabs-auth-bitanswer. Made-with: Cursor
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
package cn.craftlabs.platform.api;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||
|
||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||
@MapperScan("cn.craftlabs.platform.api.persistence")
|
||||
public class PlatformApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PlatformApplication.class, args);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cn.craftlabs.platform.api.auth;
|
||||
|
||||
import cn.craftlabs.platform.api.security.JwtService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* I1:演示账号签发 JWT(I2 起接用户表与密码哈希)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public AuthController(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Map<String, Object> login(@RequestBody Map<String, String> body) {
|
||||
String user = body.getOrDefault("username", "");
|
||||
String pass = body.getOrDefault("password", "");
|
||||
if ("admin".equals(user) && "admin".equals(pass)) {
|
||||
String token =
|
||||
jwtService.createToken(user, "管理员", List.of("SYS_ADMIN"));
|
||||
return Map.of(
|
||||
"token",
|
||||
token,
|
||||
"tokenType",
|
||||
"Bearer",
|
||||
"roles",
|
||||
List.of("SYS_ADMIN"),
|
||||
"displayName",
|
||||
"管理员");
|
||||
}
|
||||
if ("dev".equals(user) && "dev".equals(pass)) {
|
||||
String token = jwtService.createToken(user, "开发账号", List.of("DEVELOPER"));
|
||||
return Map.of(
|
||||
"token",
|
||||
token,
|
||||
"tokenType",
|
||||
"Bearer",
|
||||
"roles",
|
||||
List.of("DEVELOPER"),
|
||||
"displayName",
|
||||
"开发账号");
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package cn.craftlabs.platform.api.config;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
PaginationInnerInterceptor page = new PaginationInnerInterceptor();
|
||||
page.setOverflow(false);
|
||||
interceptor.addInnerInterceptor(page);
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.craftlabs.platform.api.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
final String bearer = "bearer-jwt";
|
||||
return new OpenAPI()
|
||||
.info(
|
||||
new Info()
|
||||
.title("CraftLabs 交付管理平台 API")
|
||||
.description("I1+:JWT 认证;迭代扩展 M1~M11")
|
||||
.version("0.1.0-SNAPSHOT"))
|
||||
.components(
|
||||
new Components()
|
||||
.addSecuritySchemes(
|
||||
bearer,
|
||||
new SecurityScheme()
|
||||
.name(bearer)
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package cn.craftlabs.platform.api.config;
|
||||
|
||||
import cn.craftlabs.platform.api.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* I1:JWT(Bearer)保护业务 API;登录与健康检查、OpenAPI 文档放行。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter)
|
||||
throws Exception {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(
|
||||
sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
auth ->
|
||||
auth.requestMatchers(
|
||||
"/actuator/health",
|
||||
"/actuator/info",
|
||||
"/api/v1/auth/login",
|
||||
"/swagger-ui.html",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs",
|
||||
"/v3/api-docs/**")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.httpBasic(b -> b.disable())
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package cn.craftlabs.platform.api.customer;
|
||||
|
||||
import cn.craftlabs.platform.api.service.CustomerService;
|
||||
import cn.craftlabs.platform.api.web.dto.CustomerRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.CustomerResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 客户 API。{@code DELETE /{id}} 为<strong>软删除</strong>:将 {@code status} 置为 {@code INACTIVE}(可重复调用)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/customers")
|
||||
@Validated
|
||||
public class CustomerController {
|
||||
|
||||
private final CustomerService customerService;
|
||||
|
||||
public CustomerController(CustomerService customerService) {
|
||||
this.customerService = customerService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<CustomerResponse> list(
|
||||
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size,
|
||||
@RequestParam(value = "keyword", required = false) String keyword) {
|
||||
return customerService.page(page, size, keyword);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public CustomerResponse create(@Valid @RequestBody CustomerRequest request) {
|
||||
return customerService.create(request);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public CustomerResponse get(@PathVariable("id") long id) {
|
||||
return customerService.getById(id);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public CustomerResponse update(
|
||||
@PathVariable("id") long id, @Valid @RequestBody CustomerRequest request) {
|
||||
return customerService.update(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable("id") long id) {
|
||||
customerService.delete(id);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package cn.craftlabs.platform.api.dictionary;
|
||||
|
||||
import cn.craftlabs.platform.api.service.DictionaryService;
|
||||
import cn.craftlabs.platform.api.web.dto.DictionaryItemResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dictionaries")
|
||||
public class DictionaryController {
|
||||
|
||||
private final DictionaryService dictionaryService;
|
||||
|
||||
public DictionaryController(DictionaryService dictionaryService) {
|
||||
this.dictionaryService = dictionaryService;
|
||||
}
|
||||
|
||||
@GetMapping("/{type}")
|
||||
public List<DictionaryItemResponse> listByType(@PathVariable("type") String type) {
|
||||
return dictionaryService.listEnabledByType(type);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package cn.craftlabs.platform.api.domain;
|
||||
|
||||
public final class CustomerStatus {
|
||||
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
public static final String INACTIVE = "INACTIVE";
|
||||
|
||||
private CustomerStatus() {}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package cn.craftlabs.platform.api.persistence.customer;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@TableName("platform_customer")
|
||||
public class PlatformCustomer {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
|
||||
@TableField("credit_code")
|
||||
private String creditCode;
|
||||
|
||||
private String status;
|
||||
|
||||
@TableField("created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getCreditCode() {
|
||||
return creditCode;
|
||||
}
|
||||
|
||||
public void setCreditCode(String creditCode) {
|
||||
this.creditCode = creditCode;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.customer;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformCustomerMapper extends BaseMapper<PlatformCustomer> {}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package cn.craftlabs.platform.api.persistence.dictionary;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
@TableName("platform_dictionary")
|
||||
public class PlatformDictionary {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("dict_type")
|
||||
private String dictType;
|
||||
|
||||
@TableField("dict_code")
|
||||
private String dictCode;
|
||||
|
||||
@TableField("dict_label")
|
||||
private String dictLabel;
|
||||
|
||||
@TableField("sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
private Boolean enabled;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getDictType() {
|
||||
return dictType;
|
||||
}
|
||||
|
||||
public void setDictType(String dictType) {
|
||||
this.dictType = dictType;
|
||||
}
|
||||
|
||||
public String getDictCode() {
|
||||
return dictCode;
|
||||
}
|
||||
|
||||
public void setDictCode(String dictCode) {
|
||||
this.dictCode = dictCode;
|
||||
}
|
||||
|
||||
public String getDictLabel() {
|
||||
return dictLabel;
|
||||
}
|
||||
|
||||
public void setDictLabel(String dictLabel) {
|
||||
this.dictLabel = dictLabel;
|
||||
}
|
||||
|
||||
public Integer getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(Integer sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.dictionary;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformDictionaryMapper extends BaseMapper<PlatformDictionary> {}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package cn.craftlabs.platform.api.persistence.project;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@TableName("platform_project")
|
||||
public class PlatformProject {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("customer_id")
|
||||
private Long customerId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String phase;
|
||||
|
||||
@TableField("created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getPhase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
public void setPhase(String phase) {
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.craftlabs.platform.api.persistence.project;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PlatformProjectMapper extends BaseMapper<PlatformProject> {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package cn.craftlabs.platform.api.ping;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class PingController {
|
||||
|
||||
@GetMapping("/ping")
|
||||
public Map<String, String> ping() {
|
||||
return Map.of("service", "delivery-platform-api", "status", "ok");
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package cn.craftlabs.platform.api.project;
|
||||
|
||||
import cn.craftlabs.platform.api.service.ProjectService;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ProjectRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ProjectResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 项目 API。{@code DELETE /{id}} 为<strong>物理删除</strong>。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/projects")
|
||||
@Validated
|
||||
public class ProjectController {
|
||||
|
||||
private final ProjectService projectService;
|
||||
|
||||
public ProjectController(ProjectService projectService) {
|
||||
this.projectService = projectService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<ProjectResponse> list(
|
||||
@RequestParam(value = "page", defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size,
|
||||
@RequestParam(value = "customerId", required = false) Long customerId) {
|
||||
return projectService.page(page, size, customerId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ProjectResponse create(@Valid @RequestBody ProjectRequest request) {
|
||||
return projectService.create(request);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ProjectResponse get(@PathVariable("id") long id) {
|
||||
return projectService.getById(id);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ProjectResponse update(
|
||||
@PathVariable("id") long id, @Valid @RequestBody ProjectRequest request) {
|
||||
return projectService.update(id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable("id") long id) {
|
||||
projectService.delete(id);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package cn.craftlabs.platform.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String raw = header.substring(7).trim();
|
||||
if (!raw.isEmpty()) {
|
||||
try {
|
||||
Claims claims = jwtService.parseAndValidate(raw);
|
||||
String subject = claims.getSubject();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> roles = claims.get("roles", List.class);
|
||||
if (roles == null) {
|
||||
roles = List.of();
|
||||
}
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
for (String r : roles) {
|
||||
String role = r.startsWith("ROLE_") ? r : "ROLE_" + r;
|
||||
authorities.add(new SimpleGrantedAuthority(role));
|
||||
}
|
||||
var auth = new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||||
auth.setDetails(claims);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} catch (Exception ignored) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package cn.craftlabs.platform.api.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirySeconds;
|
||||
|
||||
public JwtService(
|
||||
@Value("${platform.jwt.secret}") String secret,
|
||||
@Value("${platform.jwt.expiry-seconds:43200}") long expirySeconds) {
|
||||
if (secret.length() < 32) {
|
||||
throw new IllegalArgumentException("platform.jwt.secret must be at least 32 characters");
|
||||
}
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirySeconds = expirySeconds;
|
||||
}
|
||||
|
||||
public String createToken(String subject, String displayName, List<String> roles) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plusSeconds(expirySeconds);
|
||||
return Jwts.builder()
|
||||
.subject(subject)
|
||||
.claim("displayName", displayName)
|
||||
.claim("roles", roles)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parseAndValidate(String token) {
|
||||
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.domain.CustomerStatus;
|
||||
import cn.craftlabs.platform.api.persistence.customer.PlatformCustomer;
|
||||
import cn.craftlabs.platform.api.persistence.customer.PlatformCustomerMapper;
|
||||
import cn.craftlabs.platform.api.web.dto.CustomerRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.CustomerResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CustomerService {
|
||||
|
||||
private final PlatformCustomerMapper customerMapper;
|
||||
|
||||
public CustomerService(PlatformCustomerMapper customerMapper) {
|
||||
this.customerMapper = customerMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<CustomerResponse> page(int page, int size, String keyword) {
|
||||
String kw = StringUtils.hasText(keyword) ? keyword.trim() : null;
|
||||
LambdaQueryWrapper<PlatformCustomer> q =
|
||||
Wrappers.lambdaQuery(PlatformCustomer.class)
|
||||
.like(kw != null, PlatformCustomer::getName, kw)
|
||||
.orderByDesc(PlatformCustomer::getId);
|
||||
Page<PlatformCustomer> mpPage = new Page<>(page + 1L, size);
|
||||
customerMapper.selectPage(mpPage, q);
|
||||
List<CustomerResponse> content =
|
||||
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
|
||||
return new PageResponse<>(content, mpPage.getTotal(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomerResponse create(CustomerRequest request) {
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
PlatformCustomer c = new PlatformCustomer();
|
||||
c.setName(request.getName().trim());
|
||||
c.setCreditCode(blankToNull(request.getCreditCode()));
|
||||
c.setStatus(resolveStatusForCreate(request.getStatus()));
|
||||
c.setCreatedAt(now);
|
||||
c.setUpdatedAt(now);
|
||||
customerMapper.insert(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public CustomerResponse getById(long id) {
|
||||
PlatformCustomer c = customerMapper.selectById(id);
|
||||
if (c == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found");
|
||||
}
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomerResponse update(long id, CustomerRequest request) {
|
||||
PlatformCustomer c = customerMapper.selectById(id);
|
||||
if (c == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found");
|
||||
}
|
||||
c.setName(request.getName().trim());
|
||||
if (request.getCreditCode() != null) {
|
||||
c.setCreditCode(blankToNull(request.getCreditCode()));
|
||||
}
|
||||
if (StringUtils.hasText(request.getStatus())) {
|
||||
c.setStatus(request.getStatus().trim());
|
||||
}
|
||||
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
customerMapper.updateById(c);
|
||||
return toResponse(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除:将 {@code status} 置为 {@link CustomerStatus#INACTIVE}(幂等)。
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(long id) {
|
||||
PlatformCustomer c = customerMapper.selectById(id);
|
||||
if (c == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found");
|
||||
}
|
||||
c.setStatus(CustomerStatus.INACTIVE);
|
||||
c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
customerMapper.updateById(c);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void requireExists(long id) {
|
||||
if (customerMapper.selectById(id) == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveStatusForCreate(String status) {
|
||||
if (StringUtils.hasText(status)) {
|
||||
return status.trim();
|
||||
}
|
||||
return CustomerStatus.ACTIVE;
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return StringUtils.hasText(s) ? s.trim() : null;
|
||||
}
|
||||
|
||||
private CustomerResponse toResponse(PlatformCustomer c) {
|
||||
CustomerResponse r = new CustomerResponse();
|
||||
r.setId(c.getId());
|
||||
r.setName(c.getName());
|
||||
r.setCreditCode(c.getCreditCode());
|
||||
r.setStatus(c.getStatus());
|
||||
r.setCreatedAt(c.getCreatedAt());
|
||||
r.setUpdatedAt(c.getUpdatedAt());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.persistence.dictionary.PlatformDictionary;
|
||||
import cn.craftlabs.platform.api.persistence.dictionary.PlatformDictionaryMapper;
|
||||
import cn.craftlabs.platform.api.web.dto.DictionaryItemResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DictionaryService {
|
||||
|
||||
private final PlatformDictionaryMapper dictionaryMapper;
|
||||
|
||||
public DictionaryService(PlatformDictionaryMapper dictionaryMapper) {
|
||||
this.dictionaryMapper = dictionaryMapper;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DictionaryItemResponse> listEnabledByType(String dictType) {
|
||||
LambdaQueryWrapper<PlatformDictionary> q =
|
||||
Wrappers.lambdaQuery(PlatformDictionary.class)
|
||||
.eq(PlatformDictionary::getDictType, dictType)
|
||||
.eq(PlatformDictionary::getEnabled, true)
|
||||
.orderByAsc(PlatformDictionary::getSortOrder)
|
||||
.orderByAsc(PlatformDictionary::getId);
|
||||
return dictionaryMapper.selectList(q).stream()
|
||||
.map(
|
||||
d ->
|
||||
new DictionaryItemResponse(
|
||||
d.getDictCode(),
|
||||
d.getDictLabel(),
|
||||
d.getSortOrder() == null ? 0 : d.getSortOrder()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.persistence.project.PlatformProject;
|
||||
import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper;
|
||||
import cn.craftlabs.platform.api.web.dto.PageResponse;
|
||||
import cn.craftlabs.platform.api.web.dto.ProjectRequest;
|
||||
import cn.craftlabs.platform.api.web.dto.ProjectResponse;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ProjectService {
|
||||
|
||||
private static final String DEFAULT_PHASE = "PLANNING";
|
||||
|
||||
private final PlatformProjectMapper projectMapper;
|
||||
private final CustomerService customerService;
|
||||
|
||||
public ProjectService(PlatformProjectMapper projectMapper, CustomerService customerService) {
|
||||
this.projectMapper = projectMapper;
|
||||
this.customerService = customerService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ProjectResponse> page(int page, int size, Long customerId) {
|
||||
LambdaQueryWrapper<PlatformProject> q =
|
||||
Wrappers.lambdaQuery(PlatformProject.class)
|
||||
.eq(customerId != null, PlatformProject::getCustomerId, customerId)
|
||||
.orderByDesc(PlatformProject::getId);
|
||||
Page<PlatformProject> mpPage = new Page<>(page + 1L, size);
|
||||
projectMapper.selectPage(mpPage, q);
|
||||
List<ProjectResponse> content =
|
||||
mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList());
|
||||
return new PageResponse<>(content, mpPage.getTotal(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProjectResponse create(ProjectRequest request) {
|
||||
customerService.requireExists(request.getCustomerId());
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
PlatformProject p = new PlatformProject();
|
||||
p.setCustomerId(request.getCustomerId());
|
||||
p.setName(request.getName().trim());
|
||||
p.setPhase(resolvePhase(request.getPhase()));
|
||||
p.setCreatedAt(now);
|
||||
p.setUpdatedAt(now);
|
||||
projectMapper.insert(p);
|
||||
return toResponse(p);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ProjectResponse getById(long id) {
|
||||
PlatformProject p = projectMapper.selectById(id);
|
||||
if (p == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found");
|
||||
}
|
||||
return toResponse(p);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProjectResponse update(long id, ProjectRequest request) {
|
||||
PlatformProject p = projectMapper.selectById(id);
|
||||
if (p == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found");
|
||||
}
|
||||
customerService.requireExists(request.getCustomerId());
|
||||
p.setCustomerId(request.getCustomerId());
|
||||
p.setName(request.getName().trim());
|
||||
if (StringUtils.hasText(request.getPhase())) {
|
||||
p.setPhase(request.getPhase().trim());
|
||||
}
|
||||
p.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
projectMapper.updateById(p);
|
||||
return toResponse(p);
|
||||
}
|
||||
|
||||
/** 物理删除项目行。 */
|
||||
@Transactional
|
||||
public void delete(long id) {
|
||||
int rows = projectMapper.deleteById(id);
|
||||
if (rows == 0) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolvePhase(String phase) {
|
||||
return StringUtils.hasText(phase) ? phase.trim() : DEFAULT_PHASE;
|
||||
}
|
||||
|
||||
private ProjectResponse toResponse(PlatformProject p) {
|
||||
ProjectResponse r = new ProjectResponse();
|
||||
r.setId(p.getId());
|
||||
r.setCustomerId(p.getCustomerId());
|
||||
r.setName(p.getName());
|
||||
r.setPhase(p.getPhase());
|
||||
r.setCreatedAt(p.getCreatedAt());
|
||||
r.setUpdatedAt(p.getUpdatedAt());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class CustomerRequest {
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 256)
|
||||
private String name;
|
||||
|
||||
@Size(max = 64)
|
||||
private String creditCode;
|
||||
|
||||
@Size(max = 32)
|
||||
private String status;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getCreditCode() {
|
||||
return creditCode;
|
||||
}
|
||||
|
||||
public void setCreditCode(String creditCode) {
|
||||
this.creditCode = creditCode;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class CustomerResponse {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private String creditCode;
|
||||
private String status;
|
||||
private OffsetDateTime createdAt;
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getCreditCode() {
|
||||
return creditCode;
|
||||
}
|
||||
|
||||
public void setCreditCode(String creditCode) {
|
||||
this.creditCode = creditCode;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
public class DictionaryItemResponse {
|
||||
|
||||
private String dictCode;
|
||||
private String dictLabel;
|
||||
private int sortOrder;
|
||||
|
||||
public DictionaryItemResponse() {}
|
||||
|
||||
public DictionaryItemResponse(String dictCode, String dictLabel, int sortOrder) {
|
||||
this.dictCode = dictCode;
|
||||
this.dictLabel = dictLabel;
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public String getDictCode() {
|
||||
return dictCode;
|
||||
}
|
||||
|
||||
public void setDictCode(String dictCode) {
|
||||
this.dictCode = dictCode;
|
||||
}
|
||||
|
||||
public String getDictLabel() {
|
||||
return dictLabel;
|
||||
}
|
||||
|
||||
public void setDictLabel(String dictLabel) {
|
||||
this.dictLabel = dictLabel;
|
||||
}
|
||||
|
||||
public int getSortOrder() {
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
public void setSortOrder(int sortOrder) {
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 与 Spring Data 风格对齐的分页 JSON(0-based {@code number})。
|
||||
*/
|
||||
public class PageResponse<T> {
|
||||
|
||||
private List<T> content;
|
||||
private long totalElements;
|
||||
private int number;
|
||||
private int size;
|
||||
|
||||
public PageResponse() {}
|
||||
|
||||
public PageResponse(List<T> content, long totalElements, int number, int size) {
|
||||
this.content = content;
|
||||
this.totalElements = totalElements;
|
||||
this.number = number;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public List<T> getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(List<T> content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public long getTotalElements() {
|
||||
return totalElements;
|
||||
}
|
||||
|
||||
public void setTotalElements(long totalElements) {
|
||||
this.totalElements = totalElements;
|
||||
}
|
||||
|
||||
public int getNumber() {
|
||||
return number;
|
||||
}
|
||||
|
||||
public void setNumber(int number) {
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class ProjectRequest {
|
||||
|
||||
@NotNull
|
||||
private Long customerId;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 256)
|
||||
private String name;
|
||||
|
||||
@Size(max = 64)
|
||||
private String phase;
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getPhase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
public void setPhase(String phase) {
|
||||
this.phase = phase;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package cn.craftlabs.platform.api.web.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class ProjectResponse {
|
||||
|
||||
private Long id;
|
||||
private Long customerId;
|
||||
private String name;
|
||||
private String phase;
|
||||
private OffsetDateTime createdAt;
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getPhase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
public void setPhase(String phase) {
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: delivery-platform-api
|
||||
# 架构约定:生产/联调使用 PostgreSQL 15;本地可 docker compose 见 services/docker-compose.yml
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/craftlabs_platform}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:craftlabs}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:craftlabs}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
flyway:
|
||||
enabled: true
|
||||
# 与同库共存的 license-webhook-ingress 默认 flyway_schema_history 隔离
|
||||
table: flyway_platform_api
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
# JWT:生产必须通过环境变量覆盖(至少 32 字符)
|
||||
platform:
|
||||
jwt:
|
||||
secret: ${PLATFORM_JWT_SECRET:dev-only-unsafe-change-in-production-32chars!!}
|
||||
expiry-seconds: ${PLATFORM_JWT_EXPIRY_SECONDS:43200}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
-- M1:交付平台核心表(PostgreSQL 15;H2 MODE=PostgreSQL 单测)
|
||||
CREATE TABLE platform_customer (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
credit_code VARCHAR(64),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE platform_project (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
customer_id BIGINT NOT NULL REFERENCES platform_customer (id),
|
||||
name VARCHAR(256) NOT NULL,
|
||||
phase VARCHAR(64) NOT NULL DEFAULT 'PLANNING',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_platform_project_customer_id ON platform_project (customer_id);
|
||||
|
||||
CREATE TABLE platform_dictionary (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dict_type VARCHAR(64) NOT NULL,
|
||||
dict_code VARCHAR(64) NOT NULL,
|
||||
dict_label VARCHAR(256) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT uq_platform_dictionary_type_code UNIQUE (dict_type, dict_code)
|
||||
);
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- M1:项目阶段字典(中文标签)
|
||||
INSERT INTO platform_dictionary (dict_type, dict_code, dict_label, sort_order, enabled)
|
||||
VALUES ('PROJECT_PHASE', 'PLANNING', '规划中', 10, TRUE),
|
||||
('PROJECT_PHASE', 'IN_PROGRESS', '进行中', 20, TRUE),
|
||||
('PROJECT_PHASE', 'DELIVERED', '已交付', 30, TRUE);
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package cn.craftlabs.platform.api.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void loginSuccess() throws Exception {
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginFail() throws Exception {
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"x\",\"password\":\"y\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package cn.craftlabs.platform.api.contract;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 将 springdoc 产出的 OpenAPI 固化为仓库内 {@code contracts/openapi/delivery-platform-api.json}。
|
||||
*
|
||||
* <p>更新快照:{@code UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest}(在 {@code
|
||||
* services/delivery-platform-api} 模块目录下执行)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class OpenApiContractSnapshotTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void openApiMatchesCommittedContract() throws Exception {
|
||||
MvcResult result =
|
||||
mockMvc.perform(get("/v3/api-docs"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
String raw = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode actual = MAPPER.readTree(raw);
|
||||
String normalized = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(actual);
|
||||
|
||||
Path contractFile = repositoryRoot().resolve("contracts/openapi/delivery-platform-api.json");
|
||||
boolean update = "1".equals(System.getenv("UPDATE_OPENAPI"));
|
||||
|
||||
if (update) {
|
||||
Files.createDirectories(contractFile.getParent());
|
||||
Files.writeString(contractFile, normalized, StandardCharsets.UTF_8);
|
||||
return;
|
||||
}
|
||||
|
||||
assertThat(Files.isRegularFile(contractFile))
|
||||
.as(
|
||||
"缺少契约文件 %s;请执行: cd services/delivery-platform-api && UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest",
|
||||
contractFile)
|
||||
.isTrue();
|
||||
|
||||
JsonNode expected = MAPPER.readTree(Files.readString(contractFile, StandardCharsets.UTF_8));
|
||||
assertThat(actual)
|
||||
.as(
|
||||
"OpenAPI 与快照不一致。若变更为刻意更新契约,请设置 UPDATE_OPENAPI=1 重新导出并提交 %s",
|
||||
contractFile)
|
||||
.isEqualTo(expected);
|
||||
}
|
||||
|
||||
/** Surefire 的 user.dir 为当前模块根(delivery-platform-api)。 */
|
||||
private static Path repositoryRoot() {
|
||||
Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
if (dir.endsWith("delivery-platform-api")) {
|
||||
return dir.getParent().getParent();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package cn.craftlabs.platform.api.customer;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class CustomerControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void customerCrudHappyPath() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String createBody = "{\"name\":\"测试客户\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}";
|
||||
String created =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNumber())
|
||||
.andExpect(jsonPath("$.name").value("测试客户"))
|
||||
.andExpect(jsonPath("$.creditCode").value("91110000MA"))
|
||||
.andExpect(jsonPath("$.status").value("ACTIVE"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
long id = objectMapper.readTree(created).get("id").asLong();
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers").header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray())
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.number").value(0))
|
||||
.andExpect(jsonPath("$.size").value(20));
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers").param("keyword", "测试").header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(1));
|
||||
|
||||
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("测试客户"));
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v1/customers/" + id)
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"已更名\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("已更名"));
|
||||
|
||||
mockMvc.perform(delete("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
JsonNode after =
|
||||
objectMapper.readTree(
|
||||
mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString());
|
||||
assertThat(after.get("status").asText()).isEqualTo("INACTIVE");
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.craftlabs.platform.api.dictionary;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class DictionaryControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void listProjectPhaseDictionary() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
mockMvc.perform(
|
||||
get("/api/v1/dictionaries/PROJECT_PHASE")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(3)))
|
||||
.andExpect(jsonPath("$[0].dictCode").value("PLANNING"))
|
||||
.andExpect(jsonPath("$[0].dictLabel").value("规划中"))
|
||||
.andExpect(jsonPath("$[1].dictCode").value("IN_PROGRESS"))
|
||||
.andExpect(jsonPath("$[1].dictLabel").value("进行中"))
|
||||
.andExpect(jsonPath("$[2].dictCode").value("DELIVERED"))
|
||||
.andExpect(jsonPath("$[2].dictLabel").value("已交付"));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package cn.craftlabs.platform.api.ping;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class PingWithJwtTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void pingRequiresJwt() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/ping")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pingWithBearerFromLogin() throws Exception {
|
||||
MvcResult login =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").isString())
|
||||
.andReturn();
|
||||
String token =
|
||||
objectMapper.readTree(login.getResponse().getContentAsString()).get("token").asText();
|
||||
assertThat(token).isNotBlank();
|
||||
|
||||
mockMvc.perform(get("/api/v1/ping").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("ok"));
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package cn.craftlabs.platform.api.project;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ProjectControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void projectCrudHappyPath() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String cust =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"项目所属客户\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long customerId = objectMapper.readTree(cust).get("id").asLong();
|
||||
|
||||
String projBody =
|
||||
"{\"customerId\":"
|
||||
+ customerId
|
||||
+ ",\"name\":\"交付项目A\",\"phase\":\"PLANNING\"}";
|
||||
String created =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/projects")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(projBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.phase").value("PLANNING"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long projectId = objectMapper.readTree(created).get("id").asLong();
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/projects")
|
||||
.param("customerId", String.valueOf(customerId))
|
||||
.header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].name").value("交付项目A"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.customerId").value(customerId));
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v1/projects/" + projectId)
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"customerId\":"
|
||||
+ customerId
|
||||
+ ",\"name\":\"交付项目A-改\",\"phase\":\"IN_PROGRESS\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("交付项目A-改"))
|
||||
.andExpect(jsonPath("$.phase").value("IN_PROGRESS"));
|
||||
|
||||
mockMvc.perform(delete("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package cn.craftlabs.platform.api.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
public final class JwtTestSupport {
|
||||
|
||||
private JwtTestSupport() {}
|
||||
|
||||
public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception {
|
||||
MvcResult login =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
||||
.andReturn();
|
||||
String body = login.getResponse().getContentAsString();
|
||||
return objectMapper.readTree(body).get("token").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# 单测不依赖本机 PostgreSQL:H2 模拟 PostgreSQL 语法习惯(版本仍以生产 PG15 为准)
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:craftlabs_platform;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
flyway:
|
||||
enabled: true
|
||||
table: flyway_platform_api
|
||||
|
||||
platform:
|
||||
jwt:
|
||||
secret: unit-test-jwt-secret-at-least-32-chars-ok
|
||||
Reference in New Issue
Block a user