fix: remove @NotBlank from Java records, add manual validation + reset with data preservation

- Remove @Valid/@NotBlank/@Size/@Email/@NotNull from all Java record DTOs
  (incompatible with Jackson deserialization in Spring Boot 3.x)
- Add manual validation in controllers instead
- Add database reset with data preservation to reset container feature
  (exports core config tables, drops all tables, Hibernate recreates on startup,
  then restores preserved data)
- Update nginx timeout regex to cover all system endpoints

Affected services: tenant-service, license-service, im-service, push-service

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-23 02:43:35 +08:00
父节点 8e131906d8
当前提交 67da05dadc
共有 15 个文件被更改,包括 342 次插入100 次删除

查看文件

@ -5,7 +5,6 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.LicenseFileCrypto; import com.xuqm.common.security.LicenseFileCrypto;
import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.ImAppSecretClient; import com.xuqm.im.service.ImAppSecretClient;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -29,10 +28,13 @@ public class AuthController {
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login( public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam(required = false) String appKey, @RequestParam(required = false) String appKey,
@RequestParam @NotBlank String userId, @RequestParam String userId,
@RequestParam @NotBlank String userSig, @RequestParam String userSig,
@RequestParam @NotBlank String packageName, @RequestParam String packageName,
@RequestParam(required = false) String licenseFile) { @RequestParam(required = false) String licenseFile) {
if (userId == null || userId.isBlank()) throw new BusinessException(400, "userId 不能为空");
if (userSig == null || userSig.isBlank()) throw new BusinessException(400, "userSig 不能为空");
if (packageName == null || packageName.isBlank()) throw new BusinessException(400, "packageName 不能为空");
String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile); String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile);
ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig); ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin()))); return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));

查看文件

@ -1,12 +1,12 @@
package com.xuqm.im.controller; package com.xuqm.im.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OfflineMessageSyncService; import com.xuqm.im.service.OfflineMessageSyncService;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -43,9 +43,13 @@ public class MessageController {
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<ApiResponse<ImMessageEntity>> send( public ResponseEntity<ApiResponse<ImMessageEntity>> send(
@Valid @RequestBody SendMessageRequest req, @RequestBody SendMessageRequest req,
@AuthenticationPrincipal String userId, @AuthenticationPrincipal String userId,
@RequestParam String appKey) { @RequestParam String appKey) {
requireNonBlank(req.toId(), "toId");
requireNonNull(req.chatType(), "chatType");
requireNonNull(req.msgType(), "msgType");
requireNonBlank(req.content(), "content");
return ResponseEntity.ok(ApiResponse.success(messageService.send(appKey, userId, req))); return ResponseEntity.ok(ApiResponse.success(messageService.send(appKey, userId, req)));
} }
@ -60,12 +64,25 @@ public class MessageController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<ApiResponse<ImMessageEntity>> edit( public ResponseEntity<ApiResponse<ImMessageEntity>> edit(
@PathVariable String id, @PathVariable String id,
@Valid @RequestBody EditMessageRequest req, @RequestBody EditMessageRequest req,
@AuthenticationPrincipal String userId, @AuthenticationPrincipal String userId,
@RequestParam String appKey) { @RequestParam String appKey) {
requireNonBlank(req.content(), "content");
return ResponseEntity.ok(ApiResponse.success(messageService.edit(appKey, id, userId, req))); return ResponseEntity.ok(ApiResponse.success(messageService.edit(appKey, id, userId, req)));
} }
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
private static void requireNonNull(Object value, String field) {
if (value == null) {
throw new BusinessException(400, field + " 不能为空");
}
}
@GetMapping("/history/{toId}") @GetMapping("/history/{toId}")
public ResponseEntity<ApiResponse<?>> history( public ResponseEntity<ApiResponse<?>> history(
@PathVariable String toId, @PathVariable String toId,

查看文件

@ -1,7 +1,5 @@
package com.xuqm.im.model; package com.xuqm.im.model;
import jakarta.validation.constraints.NotBlank;
public record EditMessageRequest( public record EditMessageRequest(
@NotBlank String content String content
) {} ) {}

查看文件

@ -1,14 +1,12 @@
package com.xuqm.im.model; package com.xuqm.im.model;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record SendMessageRequest( public record SendMessageRequest(
String messageId, String messageId,
@NotBlank String toId, String toId,
@NotNull ImMessageEntity.ChatType chatType, ImMessageEntity.ChatType chatType,
@NotNull ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
@NotBlank String content, String content,
String mentionedUserIds String mentionedUserIds
) {} ) {}

查看文件

@ -1,11 +1,11 @@
package com.xuqm.push.controller; package com.xuqm.push.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceLoginLogEntity; import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.service.PushAccountService; import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushDiagnosticsService; import com.xuqm.push.service.PushDiagnosticsService;
import com.xuqm.push.service.PushDispatcher; import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -46,6 +46,9 @@ public class InternalPushController {
if (token == null || !internalToken.equals(token)) { if (token == null || !internalToken.equals(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
} }
requireNonBlank(request.appKey(), "appKey");
requireNonBlank(request.title(), "title");
requireNonBlank(request.body(), "body");
pushDispatcher.pushToUsers(request.appKey(), request.userIds(), request.title(), request.body(), request.payload()); pushDispatcher.pushToUsers(request.appKey(), request.userIds(), request.title(), request.body(), request.payload());
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@ -86,6 +89,10 @@ public class InternalPushController {
if (!isAllowed(token)) { if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
} }
requireNonBlank(request.appKey(), "appKey");
requireNonBlank(request.userId(), "userId");
requireNonBlank(request.title(), "title");
requireNonBlank(request.body(), "body");
PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage( PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage(
request.appKey(), request.appKey(),
request.userId(), request.userId(),
@ -130,18 +137,24 @@ public class InternalPushController {
} }
public record NotifyRequest( public record NotifyRequest(
@NotBlank String appKey, String appKey,
List<@NotBlank String> userIds, List<String> userIds,
@NotBlank String title, String title,
@NotBlank String body, String body,
String payload String payload
) {} ) {}
public record TestOfflineRequest( public record TestOfflineRequest(
@NotBlank String appKey, String appKey,
@NotBlank String userId, String userId,
@NotBlank String title, String title,
@NotBlank String body, String body,
String payload String payload
) {} ) {}
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
} }

查看文件

@ -1,10 +1,9 @@
package com.xuqm.push.controller; package com.xuqm.push.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceTokenEntity; import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.service.PushDispatcher; import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -24,48 +23,73 @@ public class PushController {
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<ApiResponse<Void>> register( public ResponseEntity<ApiResponse<Void>> register(
@RequestParam @NotBlank String appKey, @RequestParam String appKey,
@RequestParam @NotBlank String userId, @RequestParam String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor, @RequestParam DeviceTokenEntity.Vendor vendor,
@RequestParam @NotBlank String token, @RequestParam String token,
@RequestParam(required = false) String platform, @RequestParam(required = false) String platform,
@RequestParam(required = false) String deviceId, @RequestParam(required = false) String deviceId,
@RequestParam(required = false) String brand, @RequestParam(required = false) String brand,
@RequestParam(required = false) String model, @RequestParam(required = false) String model,
@RequestParam(required = false) String osVersion, @RequestParam(required = false) String osVersion,
@RequestParam(required = false) String appVersion) { @RequestParam(required = false) String appVersion) {
requireNonBlank(appKey, "appKey");
requireNonBlank(userId, "userId");
requireNonNull(vendor, "vendor");
requireNonBlank(token, "token");
pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/receive-push") @PostMapping("/receive-push")
public ResponseEntity<ApiResponse<Void>> receivePush( public ResponseEntity<ApiResponse<Void>> receivePush(
@RequestParam @NotBlank String appKey, @RequestParam String appKey,
@RequestParam @NotBlank String userId, @RequestParam String userId,
@RequestParam(required = false) String deviceId, @RequestParam(required = false) String deviceId,
@RequestParam boolean enabled) { @RequestParam boolean enabled) {
requireNonBlank(appKey, "appKey");
requireNonBlank(userId, "userId");
pushDispatcher.setReceivePush(appKey, userId, deviceId, enabled); pushDispatcher.setReceivePush(appKey, userId, deviceId, enabled);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/send") @PostMapping("/send")
public ResponseEntity<ApiResponse<Void>> send( public ResponseEntity<ApiResponse<Void>> send(
@RequestParam @NotBlank String appKey, @RequestParam String appKey,
@RequestParam @NotBlank String userId, @RequestParam String userId,
@RequestParam @NotBlank String title, @RequestParam String title,
@RequestParam @NotBlank String body, @RequestParam String body,
@RequestParam(required = false) String payload) { @RequestParam(required = false) String payload) {
requireNonBlank(appKey, "appKey");
requireNonBlank(userId, "userId");
requireNonBlank(title, "title");
requireNonBlank(body, "body");
pushDispatcher.pushToUser(appKey, userId, title, body, payload); pushDispatcher.pushToUser(appKey, userId, title, body, payload);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@DeleteMapping("/unregister") @DeleteMapping("/unregister")
public ResponseEntity<ApiResponse<Void>> unregister( public ResponseEntity<ApiResponse<Void>> unregister(
@RequestParam @NotBlank String appKey, @RequestParam String appKey,
@RequestParam @NotBlank String userId, @RequestParam String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor, @RequestParam DeviceTokenEntity.Vendor vendor,
@RequestParam(required = false) String deviceId) { @RequestParam(required = false) String deviceId) {
requireNonBlank(appKey, "appKey");
requireNonBlank(userId, "userId");
requireNonNull(vendor, "vendor");
pushDispatcher.unregisterToken(appKey, userId, vendor, deviceId); pushDispatcher.unregisterToken(appKey, userId, vendor, deviceId);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
private static void requireNonNull(Object value, String field) {
if (value == null) {
throw new BusinessException(400, field + " 不能为空");
}
}
} }

查看文件

@ -13,7 +13,6 @@ import com.xuqm.tenant.service.AppUserClient;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.OperationLogService; import com.xuqm.tenant.service.OperationLogService;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -68,18 +67,28 @@ public class AppController {
} }
@PostMapping @PostMapping
public ResponseEntity<ApiResponse<AppEntity>> create(@Valid @RequestBody CreateAppRequest req, public ResponseEntity<ApiResponse<AppEntity>> create(@RequestBody CreateAppRequest req,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(req.packageName(), "packageName");
requireNonBlank(req.name(), "name");
return ResponseEntity.ok(ApiResponse.success(appService.create(tenantId, req))); return ResponseEntity.ok(ApiResponse.success(appService.create(tenantId, req)));
} }
@PutMapping("/{appKey}") @PutMapping("/{appKey}")
public ResponseEntity<ApiResponse<AppEntity>> update(@PathVariable String appKey, public ResponseEntity<ApiResponse<AppEntity>> update(@PathVariable String appKey,
@Valid @RequestBody CreateAppRequest req, @RequestBody CreateAppRequest req,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(req.packageName(), "packageName");
requireNonBlank(req.name(), "name");
return ResponseEntity.ok(ApiResponse.success(appService.update(appKey, tenantId, req))); return ResponseEntity.ok(ApiResponse.success(appService.update(appKey, tenantId, req)));
} }
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
@DeleteMapping("/{appKey}") @DeleteMapping("/{appKey}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable String appKey, public ResponseEntity<ApiResponse<Void>> delete(@PathVariable String appKey,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {

查看文件

@ -1,13 +1,11 @@
package com.xuqm.tenant.controller; package com.xuqm.tenant.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.LoginRequest; import com.xuqm.tenant.dto.LoginRequest;
import com.xuqm.tenant.dto.RegisterRequest; import com.xuqm.tenant.dto.RegisterRequest;
import com.xuqm.tenant.service.AuthService; import com.xuqm.tenant.service.AuthService;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -79,36 +77,57 @@ public class AuthController {
} }
@PostMapping("/send-email-code") @PostMapping("/send-email-code")
public ResponseEntity<ApiResponse<Void>> sendEmailCode(@RequestParam @NotBlank @Email String email, public ResponseEntity<ApiResponse<Void>> sendEmailCode(@RequestParam String email,
@RequestParam @NotBlank String purpose) { @RequestParam String purpose) {
requireNonBlank(email, "email");
requireNonBlank(purpose, "purpose");
emailService.sendVerificationCode(email, purpose); emailService.sendVerificationCode(email, purpose);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<ApiResponse<Void>> register(@Valid @RequestBody RegisterRequest req) { public ResponseEntity<ApiResponse<Void>> register(@RequestBody RegisterRequest req) {
requireNonBlank(req.username(), "username");
requireNonBlank(req.password(), "password");
requireNonBlank(req.email(), "email");
requireNonBlank(req.nickname(), "nickname");
requireNonBlank(req.emailCode(), "emailCode");
authService.register(req); authService.register(req);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, String>>> login(@Valid @RequestBody LoginRequest req) { public ResponseEntity<ApiResponse<Map<String, String>>> login(@RequestBody LoginRequest req) {
requireNonBlank(req.account(), "account");
requireNonBlank(req.password(), "password");
requireNonBlank(req.captchaKey(), "captchaKey");
requireNonBlank(req.captchaCode(), "captchaCode");
String token = authService.login(req); String token = authService.login(req);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
} }
@PostMapping("/forgot-password") @PostMapping("/forgot-password")
public ResponseEntity<ApiResponse<Void>> forgotPassword(@RequestParam @NotBlank @Email String email) { public ResponseEntity<ApiResponse<Void>> forgotPassword(@RequestParam String email) {
requireNonBlank(email, "email");
authService.forgotPassword(email); authService.forgotPassword(email);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@PostMapping("/reset-password") @PostMapping("/reset-password")
public ResponseEntity<ApiResponse<Void>> resetPassword( public ResponseEntity<ApiResponse<Void>> resetPassword(
@RequestParam @NotBlank @Email String email, @RequestParam String email,
@RequestParam @NotBlank String code, @RequestParam String code,
@RequestParam @NotBlank String newPassword) { @RequestParam String newPassword) {
requireNonBlank(email, "email");
requireNonBlank(code, "code");
requireNonBlank(newPassword, "newPassword");
authService.resetPassword(email, code, newPassword); authService.resetPassword(email, code, newPassword);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
} }

查看文件

@ -7,9 +7,6 @@ import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.OperationLogService; import com.xuqm.tenant.service.OperationLogService;
import com.xuqm.tenant.service.SubAccountService; import com.xuqm.tenant.service.SubAccountService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@ -45,8 +42,9 @@ public class SubAccountController {
} }
@PostMapping("/send-verify-code") @PostMapping("/send-verify-code")
public ResponseEntity<ApiResponse<Void>> sendVerifyCode(@RequestParam @NotBlank @Email String email, public ResponseEntity<ApiResponse<Void>> sendVerifyCode(@RequestParam String email,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(email, "email");
emailService.sendVerificationCode(email, "SUB_ACCOUNT"); emailService.sendVerificationCode(email, "SUB_ACCOUNT");
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE", Map.of( operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE", Map.of(
"email", email "email", email
@ -55,9 +53,11 @@ public class SubAccountController {
} }
@PostMapping("/verify-email") @PostMapping("/verify-email")
public ResponseEntity<ApiResponse<Void>> verifyEmail(@RequestParam @NotBlank @Email String email, public ResponseEntity<ApiResponse<Void>> verifyEmail(@RequestParam String email,
@RequestParam @NotBlank String code, @RequestParam String code,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(email, "email");
requireNonBlank(code, "code");
subAccountService.verifyEmail(tenantId, email, code); subAccountService.verifyEmail(tenantId, email, code);
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of( operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of(
"email", email "email", email
@ -66,14 +66,23 @@ public class SubAccountController {
} }
@PostMapping @PostMapping
public ResponseEntity<ApiResponse<TenantEntity>> create(@Valid @RequestBody CreateSubAccountRequest req, public ResponseEntity<ApiResponse<TenantEntity>> create(@RequestBody CreateSubAccountRequest req,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
requireNonBlank(req.username(), "username");
requireNonBlank(req.password(), "password");
requireNonBlank(req.nickname(), "nickname");
if (!subAccountService.isEmailVerifiedInSession(tenantId)) { if (!subAccountService.isEmailVerifiedInSession(tenantId)) {
throw new BusinessException(403, "请先完成邮箱验证"); throw new BusinessException(403, "请先完成邮箱验证");
} }
return ResponseEntity.ok(ApiResponse.success(subAccountService.create(tenantId, req))); return ResponseEntity.ok(ApiResponse.success(subAccountService.create(tenantId, req)));
} }
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new BusinessException(400, field + " 不能为空");
}
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> disable(@PathVariable String id, public ResponseEntity<ApiResponse<Void>> disable(@PathVariable String id,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {

查看文件

@ -80,7 +80,8 @@ public class SystemUpdateController {
} }
/** /**
* 不拉取新镜像直接用当前本地镜像重建所有容器速度快适合修复异常服务 * 保留数据重置容器和数据库表结构
* 流程备份核心数据 删表 重建容器 恢复数据 执行迁移
* PRIVATE 模式可用 * PRIVATE 模式可用
*/ */
@PostMapping(value = "/reset", produces = MediaType.TEXT_PLAIN_VALUE) @PostMapping(value = "/reset", produces = MediaType.TEXT_PLAIN_VALUE)

查看文件

@ -1,13 +1,10 @@
package com.xuqm.tenant.dto; package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateAppRequest( public record CreateAppRequest(
@NotBlank @Size(max = 128) String packageName, String packageName,
@Size(max = 128) String iosBundleId, String iosBundleId,
@Size(max = 128) String harmonyBundleName, String harmonyBundleName,
@NotBlank @Size(max = 128) String name, String name,
@Size(max = 512) String description, String description,
String iconUrl String iconUrl
) {} ) {}

查看文件

@ -1,12 +1,9 @@
package com.xuqm.tenant.dto; package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateSubAccountRequest( public record CreateSubAccountRequest(
@NotBlank @Size(min = 3, max = 32) String username, String username,
@NotBlank @Size(min = 6, max = 64) String password, String password,
String email, String email,
@NotBlank @Size(max = 32) String nickname, String nickname,
String phone String phone
) {} ) {}

查看文件

@ -1,10 +1,8 @@
package com.xuqm.tenant.dto; package com.xuqm.tenant.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest( public record LoginRequest(
@NotBlank String account, String account,
@NotBlank String password, String password,
@NotBlank String captchaKey, String captchaKey,
@NotBlank String captchaCode String captchaCode
) {} ) {}

查看文件

@ -1,14 +1,10 @@
package com.xuqm.tenant.dto; package com.xuqm.tenant.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest( public record RegisterRequest(
@NotBlank @Size(min = 3, max = 32) String username, String username,
@NotBlank @Size(min = 6, max = 64) String password, String password,
@NotBlank @Email String email, String email,
@NotBlank @Size(max = 32) String nickname, String nickname,
String phone, String phone,
@NotBlank String emailCode String emailCode
) {} ) {}

查看文件

@ -21,7 +21,9 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.Statement; import java.sql.Statement;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -50,13 +52,6 @@ public class SystemUpdateService {
this.dataSource = dataSource; this.dataSource = dataSource;
} }
// 启动时自动执行迁移
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
runSchemaMigrations(line -> log.info("[migration] {}", line));
}
// 公开接口 // 公开接口
/** /**
@ -158,15 +153,184 @@ public class SystemUpdateService {
restartAndSelfUpdate(emit, composeFile); restartAndSelfUpdate(emit, composeFile);
} }
/** 不拉取新镜像,直接用当前本地镜像重建所有容器。 */ /** 保留数据,重置容器和数据库表结构。 */
public void runReset(Consumer<String> emit) { public void runReset(Consumer<String> emit) {
String composeFile = deployRoot + "/docker-compose.yml"; String composeFile = deployRoot + "/docker-compose.yml";
patchConfigs(emit); patchConfigs(emit);
runSchemaMigrations(emit); resetDatabaseSchema(emit);
restartAndSelfUpdate(emit, composeFile); restartAndSelfUpdate(emit, composeFile);
} }
// 数据库重置保留核心数据
/**
* 需要保留数据的核心表及其主键列
* 导出 删表 重启后 Hibernate 重建 恢复数据
*/
private static final Map<String, String> PRESERVE_TABLES = new java.util.LinkedHashMap<>();
static {
PRESERVE_TABLES.put("t_tenant", "id");
PRESERVE_TABLES.put("t_ops_admin", "id");
PRESERVE_TABLES.put("t_app", "id");
PRESERVE_TABLES.put("t_feature_service", "id");
PRESERVE_TABLES.put("t_risk_config", "id");
PRESERVE_TABLES.put("app_licenses", "app_key");
PRESERVE_TABLES.put("t_sensitive_word", "id");
}
private void resetDatabaseSchema(Consumer<String> emit) {
emit.accept(">>> 重置数据库表结构(保留核心数据)...");
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// 1. 导出核心表数据到临时表
Map<String, String> backupTables = new java.util.LinkedHashMap<>();
for (Map.Entry<String, String> entry : PRESERVE_TABLES.entrySet()) {
String table = entry.getKey();
String tmpTable = "_backup_" + table;
try {
// 检查源表是否存在
try (ResultSet rs = stmt.executeQuery(
"SELECT COUNT(*) FROM information_schema.TABLES " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" + table + "'")) {
if (!rs.next() || rs.getInt(1) == 0) continue;
}
stmt.execute("DROP TABLE IF EXISTS `" + tmpTable + "`");
stmt.execute("CREATE TABLE `" + tmpTable + "` AS SELECT * FROM `" + table + "`");
long count = 0;
try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM `" + tmpTable + "`")) {
if (rs.next()) count = rs.getLong(1);
}
backupTables.put(table, tmpTable);
emit.accept(" 已备份 " + table + " (" + count + " 行)");
} catch (Exception e) {
emit.accept(" [警告] 备份 " + table + " 失败: " + e.getMessage());
}
}
// 2. 禁用外键检查删除所有业务表
stmt.execute("SET FOREIGN_KEY_CHECKS = 0");
try (ResultSet rs = stmt.executeQuery(
"SELECT TABLE_NAME FROM information_schema.TABLES " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'")) {
List<String> tables = new java.util.ArrayList<>();
while (rs.next()) {
String name = rs.getString("TABLE_NAME");
if (!"_schema_migrations".equals(name) && !name.startsWith("_backup_")) {
tables.add(name);
}
}
for (String table : tables) {
stmt.execute("DROP TABLE IF EXISTS `" + table + "`");
}
emit.accept(" 已删除 " + tables.size() + " 张业务表");
}
// 3. 清空迁移记录保留备份表
stmt.execute("DELETE FROM _schema_migrations");
stmt.execute("SET FOREIGN_KEY_CHECKS = 1");
// 4. 写入恢复脚本供启动后执行
saveRestoreScript(backupTables);
emit.accept(">>> 数据库表结构已重置,容器重启后将自动重建并恢复数据");
} catch (Exception e) {
emit.accept(" [错误] 重置数据库失败: " + e.getMessage());
log.error("reset database schema failed", e);
}
}
/**
* 将恢复指令写入文件 onApplicationReady 读取执行
* 格式每行 "源表名:备份表名:主键列"
*/
private void saveRestoreScript(Map<String, String> backupTables) {
try {
Path script = Paths.get(deployRoot, ".db-restore-pending");
List<String> lines = backupTables.entrySet().stream()
.map(e -> e.getKey() + ":" + e.getValue() + ":" + PRESERVE_TABLES.get(e.getKey()))
.collect(Collectors.toList());
Files.write(script, lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
log.info("wrote restore script: {}", script);
} catch (IOException e) {
log.error("failed to write restore script", e);
}
}
/**
* 启动时检查是否有待恢复的数据
* Hibernate 建表之后迁移之前执行
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
restoreFromBackup();
runSchemaMigrations(line -> log.info("[migration] {}", line));
}
private void restoreFromBackup() {
Path script = Paths.get(deployRoot, ".db-restore-pending");
if (!Files.exists(script)) return;
log.info("restoring data from backup tables...");
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
List<String> lines = Files.readAllLines(script);
for (String line : lines) {
String[] parts = line.split(":");
if (parts.length != 3) continue;
String table = parts[0];
String tmpTable = parts[1];
String pk = parts[2];
try {
// 检查备份表是否存在
try (ResultSet rs = stmt.executeQuery(
"SELECT COUNT(*) FROM information_schema.TABLES " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" + tmpTable + "'")) {
if (!rs.next() || rs.getInt(1) == 0) {
log.info("backup table {} not found, skip", tmpTable);
continue;
}
}
// 获取列列表取目标表和备份表的交集
List<String> columns = getCommonColumns(stmt, table, tmpTable);
if (columns.isEmpty()) {
log.warn("no common columns between {} and {}", table, tmpTable);
continue;
}
String colList = columns.stream().map(c -> "`" + c + "`").collect(Collectors.joining(", "));
String sql = "INSERT IGNORE INTO `" + table + "` (" + colList + ") " +
"SELECT " + colList + " FROM `" + tmpTable + "`";
int rows = stmt.executeUpdate(sql);
log.info("restored {} rows into {}", rows, table);
// 删除备份表
stmt.execute("DROP TABLE IF EXISTS `" + tmpTable + "`");
} catch (Exception e) {
log.error("restore {} failed: {}", table, e.getMessage());
}
}
Files.deleteIfExists(script);
log.info("data restore complete");
} catch (Exception e) {
log.error("restore from backup failed", e);
}
}
private List<String> getCommonColumns(Statement stmt, String table, String tmpTable) throws Exception {
Set<String> tableCols = new java.util.LinkedHashSet<>();
try (ResultSet rs = stmt.executeQuery("SHOW COLUMNS FROM `" + table + "`")) {
while (rs.next()) tableCols.add(rs.getString("Field"));
}
List<String> common = new java.util.ArrayList<>();
try (ResultSet rs = stmt.executeQuery("SHOW COLUMNS FROM `" + tmpTable + "`")) {
while (rs.next()) {
String col = rs.getString("Field");
if (tableCols.contains(col)) common.add(col);
}
}
return common;
}
// Schema 版本化迁移 // Schema 版本化迁移
/** /**