Initial commit: PAR Server with Spring Boot 3.2, MyBatis-Plus, Flyway
This commit is contained in:
14
par-api/src/main/java/com/par/api/ParApplication.java
Normal file
14
par-api/src/main/java/com/par/api/ParApplication.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.par.api;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = {"com.par"})
|
||||
@MapperScan("com.par.core.mapper")
|
||||
public class ParApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ParApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import com.par.core.dto.ApiResponse;
|
||||
import com.par.core.entity.SiteConfig;
|
||||
import com.par.core.enums.ConfigStatus;
|
||||
import com.par.core.enums.TrustLevel;
|
||||
import com.par.core.mapper.AccountMapper;
|
||||
import com.par.core.mapper.ConfigReviewMapper;
|
||||
import com.par.core.mapper.SiteConfigMapper;
|
||||
import com.par.core.mapper.SiteMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 管理员控制器
|
||||
* 所有接口需 HMAC 鉴权 + 管理员权限
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final SiteConfigMapper siteConfigMapper;
|
||||
private final ConfigReviewMapper configReviewMapper;
|
||||
private final AccountMapper accountMapper;
|
||||
private final SiteMapper siteMapper;
|
||||
|
||||
/**
|
||||
* 审核配置
|
||||
*/
|
||||
@PostMapping("/reviews/{reviewId}/approve")
|
||||
public ApiResponse<Void> approveConfig(
|
||||
@PathVariable Long reviewId,
|
||||
@RequestParam(required = false) String comment,
|
||||
HttpServletRequest request) {
|
||||
|
||||
checkAdmin(request);
|
||||
|
||||
// Phase 1: 简化实现,直接更新配置状态
|
||||
SiteConfig config = siteConfigMapper.selectById(reviewId);
|
||||
if (config == null) {
|
||||
return ApiResponse.error(404, "Config not found");
|
||||
}
|
||||
|
||||
Long reviewerId = (Long) request.getAttribute("accountId");
|
||||
|
||||
config.setStatus(ConfigStatus.APPROVED);
|
||||
config.setReviewerId(reviewerId);
|
||||
config.setReviewedAt(LocalDateTime.now());
|
||||
config.setReviewComment(comment);
|
||||
|
||||
// 更新 is_latest 标记
|
||||
siteConfigMapper.clearLatestFlag(config.getSiteId());
|
||||
config.setIsLatest(true);
|
||||
|
||||
siteConfigMapper.updateById(config);
|
||||
|
||||
// 更新 sites 表的 current_config_id
|
||||
var site = siteMapper.selectBySiteId(config.getSiteId());
|
||||
if (site != null) {
|
||||
site.setCurrentConfigId(config.getId());
|
||||
site.setSchemaVersion(config.getSchemaVersion());
|
||||
siteMapper.updateById(site);
|
||||
}
|
||||
|
||||
log.info("Config approved: id={}, reviewer={}", reviewId, reviewerId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝配置
|
||||
*/
|
||||
@PostMapping("/reviews/{reviewId}/reject")
|
||||
public ApiResponse<Void> rejectConfig(
|
||||
@PathVariable Long reviewId,
|
||||
@RequestParam String comment,
|
||||
HttpServletRequest request) {
|
||||
|
||||
checkAdmin(request);
|
||||
|
||||
SiteConfig config = siteConfigMapper.selectById(reviewId);
|
||||
if (config == null) {
|
||||
return ApiResponse.error(404, "Config not found");
|
||||
}
|
||||
|
||||
Long reviewerId = (Long) request.getAttribute("accountId");
|
||||
|
||||
config.setStatus(ConfigStatus.REJECTED);
|
||||
config.setReviewerId(reviewerId);
|
||||
config.setReviewedAt(LocalDateTime.now());
|
||||
config.setReviewComment(comment);
|
||||
|
||||
siteConfigMapper.updateById(config);
|
||||
|
||||
log.info("Config rejected: id={}, reviewer={}", reviewId, reviewerId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户信任等级
|
||||
*/
|
||||
@PostMapping("/accounts/{accountId}/trust-level")
|
||||
public ApiResponse<Void> setTrustLevel(
|
||||
@PathVariable Long accountId,
|
||||
@RequestParam String level,
|
||||
HttpServletRequest request) {
|
||||
|
||||
checkAdmin(request);
|
||||
|
||||
var account = accountMapper.selectById(accountId);
|
||||
if (account == null) {
|
||||
return ApiResponse.error(404, "Account not found");
|
||||
}
|
||||
|
||||
account.setTrustLevel(TrustLevel.fromValue(level));
|
||||
accountMapper.updateById(account);
|
||||
|
||||
log.info("Trust level updated: accountId={}, level={}", accountId, level);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
private void checkAdmin(HttpServletRequest request) {
|
||||
TrustLevel level = (TrustLevel) request.getAttribute("trustLevel");
|
||||
if (level == null || level != TrustLevel.ADMIN) {
|
||||
throw new IllegalArgumentException("Admin permission required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import com.par.core.dto.AccountDTO;
|
||||
import com.par.core.dto.ApiResponse;
|
||||
import com.par.core.dto.LoginRequest;
|
||||
import com.par.core.dto.RegisterRequest;
|
||||
import com.par.core.entity.Account;
|
||||
import com.par.core.service.AccountService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 认证控制器
|
||||
* 注册、登录(读取公开,无需鉴权)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AccountService accountService;
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<AccountDTO> register(@Valid @RequestBody RegisterRequest request) {
|
||||
Account account = accountService.register(request);
|
||||
return ApiResponse.success(accountService.toDTO(account));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录(仅验证密码,返回账户信息)
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AccountDTO> login(@Valid @RequestBody LoginRequest request) {
|
||||
Account account = accountService.login(request);
|
||||
return ApiResponse.success(accountService.toDTO(account));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import com.par.core.dto.ApiResponse;
|
||||
import com.par.core.dto.SiteConfigDTO;
|
||||
import com.par.core.entity.SiteConfig;
|
||||
import com.par.core.service.ConfigService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 配置控制器
|
||||
* 读取公开,提交需 HMAC 鉴权
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/configs")
|
||||
@RequiredArgsConstructor
|
||||
public class ConfigController {
|
||||
|
||||
private final ConfigService configService;
|
||||
|
||||
@Value("${par.static.path:./static}")
|
||||
private String staticPath;
|
||||
|
||||
/**
|
||||
* 获取站点最新配置版本列表
|
||||
*/
|
||||
@GetMapping("/{siteId}")
|
||||
public ApiResponse<List<SiteConfigDTO>> listConfigs(@PathVariable String siteId) {
|
||||
return ApiResponse.success(configService.listApprovedConfigs(siteId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载指定版本配置(支持 ETag 条件请求)
|
||||
*/
|
||||
@GetMapping("/{siteId}/{version}")
|
||||
public ResponseEntity<String> downloadConfig(
|
||||
@PathVariable String siteId,
|
||||
@PathVariable String version,
|
||||
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
|
||||
|
||||
SiteConfig config = configService.getConfig(siteId, version);
|
||||
if (config == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ETag 匹配则返回 304
|
||||
if (ifNoneMatch != null && ifNoneMatch.equals(config.getConfigHash())) {
|
||||
return ResponseEntity.status(304)
|
||||
.eTag(config.getConfigHash())
|
||||
.build();
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
Path filePath = Paths.get(staticPath, config.getStoragePath());
|
||||
String content;
|
||||
try {
|
||||
content = Files.readString(filePath, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read config file: {}", filePath, e);
|
||||
return ResponseEntity.status(500).body("Failed to read config");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.eTag(config.getConfigHash())
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
||||
.body(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交新配置(需 HMAC 鉴权)
|
||||
*/
|
||||
@PostMapping("/{siteId}/submit")
|
||||
public ApiResponse<SiteConfigDTO> submitConfig(
|
||||
@PathVariable String siteId,
|
||||
@RequestBody Map<String, Object> payload,
|
||||
HttpServletRequest request) {
|
||||
|
||||
String version = (String) payload.get("version");
|
||||
String configJson = (String) payload.get("config");
|
||||
String schemaVersion = (String) payload.get("schemaVersion");
|
||||
|
||||
if (version == null || configJson == null) {
|
||||
return ApiResponse.error(400, "version and config are required");
|
||||
}
|
||||
|
||||
Long submitterId = (Long) request.getAttribute("accountId");
|
||||
|
||||
SiteConfig config = configService.submitConfig(siteId, version, configJson, schemaVersion, submitterId);
|
||||
return ApiResponse.success(configService.toDTO(config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 健康检查控制器
|
||||
*/
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public Map<String, Object> health() {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("status", "UP");
|
||||
status.put("timestamp", Instant.now().toString());
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import com.par.core.dto.ApiResponse;
|
||||
import com.par.core.entity.Site;
|
||||
import com.par.core.entity.SiteConfig;
|
||||
import com.par.core.mapper.SiteConfigMapper;
|
||||
import com.par.core.mapper.SiteMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Manifest 控制器
|
||||
* 返回所有站点最新配置的索引
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/manifest")
|
||||
@RequiredArgsConstructor
|
||||
public class ManifestController {
|
||||
|
||||
private final SiteMapper siteMapper;
|
||||
private final SiteConfigMapper siteConfigMapper;
|
||||
|
||||
/**
|
||||
* 获取全局 Manifest
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<Map<String, Object>> getManifest() {
|
||||
List<Site> sites = siteMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Site>()
|
||||
.eq(Site::getDeleted, 0)
|
||||
);
|
||||
|
||||
List<Map<String, Object>> entries = sites.stream().map(site -> {
|
||||
Map<String, Object> entry = new HashMap<>();
|
||||
entry.put("siteId", site.getSiteId());
|
||||
entry.put("name", site.getName());
|
||||
entry.put("siteType", site.getSiteType() != null ? site.getSiteType().getValue() : null);
|
||||
entry.put("schemaVersion", site.getSchemaVersion());
|
||||
entry.put("status", site.getStatus() != null ? site.getStatus().getValue() : null);
|
||||
|
||||
// 获取最新已批准配置
|
||||
SiteConfig latest = siteConfigMapper.selectLatestBySiteId(site.getSiteId());
|
||||
if (latest != null) {
|
||||
entry.put("latestVersion", latest.getVersion());
|
||||
entry.put("configHash", latest.getConfigHash());
|
||||
entry.put("updatedAt", latest.getCreatedAt());
|
||||
}
|
||||
|
||||
return entry;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> manifest = new HashMap<>();
|
||||
manifest.put("version", "1.0");
|
||||
manifest.put("sites", entries);
|
||||
|
||||
return ApiResponse.success(manifest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.par.api.controller;
|
||||
|
||||
import com.par.core.dto.ApiResponse;
|
||||
import com.par.core.dto.SiteDTO;
|
||||
import com.par.core.service.SiteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 站点控制器(读取公开)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/sites")
|
||||
@RequiredArgsConstructor
|
||||
public class SiteController {
|
||||
|
||||
private final SiteService siteService;
|
||||
|
||||
/**
|
||||
* 列出所有活跃站点
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<List<SiteDTO>> listSites() {
|
||||
return ApiResponse.success(siteService.listActiveSites());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个站点信息
|
||||
*/
|
||||
@GetMapping("/{siteId}")
|
||||
public ApiResponse<SiteDTO> getSite(@PathVariable String siteId) {
|
||||
return ApiResponse.success(siteService.toDTO(siteService.findBySiteId(siteId)));
|
||||
}
|
||||
}
|
||||
63
par-api/src/main/resources/application.yml
Normal file
63
par-api/src/main/resources/application.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: par-server
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/par_registry?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
|
||||
username: ${MYSQL_USER:par}
|
||||
password: ${MYSQL_PASSWORD:par123}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
minimum-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
default-property-inclusion: non_null
|
||||
|
||||
web:
|
||||
resources:
|
||||
static-locations: file:${par.static.path:./static/}
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 0
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
par:
|
||||
static:
|
||||
path: ${PAR_STATIC_PATH:./static/configs/}
|
||||
admin:
|
||||
path: ${PAR_ADMIN_PATH:./static/admin/}
|
||||
auth:
|
||||
api-key-prefix: mbt_
|
||||
hmac-window-seconds: 60
|
||||
password-bcrypt-strength: 10
|
||||
security:
|
||||
verification-code-expire-minutes: 10
|
||||
login-max-failures: 5
|
||||
login-lock-minutes: 30
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.par: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
Reference in New Issue
Block a user