Initial commit: PAR Server with Spring Boot 3.2, MyBatis-Plus, Flyway

This commit is contained in:
mediabot-pt
2026-06-29 10:43:24 +08:00
commit baa04b5138
60 changed files with 7043 additions and 0 deletions

View 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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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)));
}
}

View 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"