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:
2026-04-06 21:04:56 +08:00
parent 76ff98db87
commit 3f577b34d5
57 changed files with 3170 additions and 0 deletions
@@ -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);
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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 认证;迭代扩展 M1M11")
.version("0.1.0-SNAPSHOT"))
.components(
new Components()
.addSecuritySchemes(
bearer,
new SecurityScheme()
.name(bearer)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -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;
/**
* I1JWTBearer)保护业务 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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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() {}
}
@@ -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;
}
}
@@ -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> {}
@@ -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;
}
}
@@ -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> {}
@@ -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;
}
}
@@ -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> {}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,55 @@
package cn.craftlabs.platform.api.web.dto;
import java.util.List;
/**
* 与 Spring Data 风格对齐的分页 JSON0-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;
}
}
@@ -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;
}
}
@@ -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
@@ -0,0 +1,30 @@
-- M1:交付平台核心表(PostgreSQL 15H2 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)
);
@@ -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);
@@ -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());
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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("已交付"));
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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 @@
# 单测不依赖本机 PostgreSQLH2 模拟 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