feat: IM admin APIs, appSecret security, remove SecretKey, CI/CD pipeline

- im-service: add admin register user and create group endpoints
- tenant-service: add reveal/reset appSecret with email verification
- tenant-service: remove secretKey from FeatureService (app appKey/appSecret is sufficient)
- tenant-service: service activation now requires ops approval via request-activation
- Add Jenkinsfile for parameterized build → Alibaba Cloud ACR → SSH deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-24 20:53:48 +08:00
父节点 f79a27862f
当前提交 161218420c
共有 7 个文件被更改,包括 188 次插入29 次删除

77
Jenkinsfile vendored 普通文件
查看文件

@ -0,0 +1,77 @@
pipeline {
agent any
parameters {
choice(name: 'SERVICE', choices: ['tenant-service', 'im-service', 'push-service', 'update-service'], description: '要构建的服务模块')
string(name: 'IMAGE_TAG', defaultValue: 'latest', description: '镜像 Tag如 v1.2.3 或 latest')
booleanParam(name: 'DEPLOY', defaultValue: true, description: '构建后是否自动部署到生产服务器')
}
environment {
// 阿里云 ACR 配置 — 在 Jenkins Credentials 里添加 Secret Text,ID 为 ACR_PASSWORD
ACR_REGISTRY = 'registry.cn-hangzhou.aliyuncs.com' // 替换为你的 ACR 地址
ACR_NAMESPACE = 'xuqmgroup' // 替换为你的命名空间
ACR_USERNAME = 'your-acr-username' // 替换为 ACR 用户名
// 生产服务器
PROD_HOST = '106.54.23.149'
PROD_USER = 'ubuntu'
COMPOSE_FILE = '/opt/xuqm/deploy/compose.production.yaml'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build JAR') {
steps {
sh """
mvn -pl ${params.SERVICE} -am -DskipTests -q clean package
"""
}
}
stage('Docker Build & Push') {
steps {
withCredentials([string(credentialsId: 'ACR_PASSWORD', variable: 'ACR_PASS')]) {
script {
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${params.SERVICE}:${params.IMAGE_TAG}"
sh """
docker login ${ACR_REGISTRY} -u ${ACR_USERNAME} -p \${ACR_PASS}
docker build --build-arg SERVICE_MODULE=${params.SERVICE} -t ${imageName} .
docker push ${imageName}
docker rmi ${imageName}
"""
}
}
}
}
stage('Deploy to Production') {
when { expression { return params.DEPLOY } }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'PROD_SSH_KEY', keyFileVariable: 'SSH_KEY')]) {
script {
def svcName = params.SERVICE.replace('-service', '')
def imageName = "${ACR_REGISTRY}/${ACR_NAMESPACE}/${params.SERVICE}:${params.IMAGE_TAG}"
sh """
ssh -i \${SSH_KEY} -o StrictHostKeyChecking=no ${PROD_USER}@${PROD_HOST} "
docker pull ${imageName} &&
cd /opt/xuqm/deploy &&
docker compose -f ${COMPOSE_FILE} up -d --no-deps ${svcName} &&
docker image prune -f
"
"""
}
}
}
}
}
post {
success { echo "✅ ${params.SERVICE}:${params.IMAGE_TAG} 构建部署成功" }
failure { echo "❌ 构建失败,请检查日志" }
}
}

查看文件

@ -6,6 +6,8 @@ import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.ImGroupService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
@ -21,13 +23,19 @@ public class ImAdminController {
private final ImAccountRepository accountRepository;
private final ImGroupRepository groupRepository;
private final ImMessageRepository messageRepository;
private final ImAccountService accountService;
private final ImGroupService groupService;
public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository,
ImMessageRepository messageRepository) {
ImMessageRepository messageRepository,
ImAccountService accountService,
ImGroupService groupService) {
this.accountRepository = accountRepository;
this.groupRepository = groupRepository;
this.messageRepository = messageRepository;
this.accountService = accountService;
this.groupService = groupService;
}
/** List all registered IM users for the given appId. */
@ -58,6 +66,26 @@ public class ImAdminController {
return ResponseEntity.ok(ApiResponse.success(groupRepository.findByAppId(appId)));
}
/** Admin registers a new IM user (or returns existing). */
@PostMapping("/users")
public ResponseEntity<ApiResponse<ImAccountEntity>> registerUser(
@RequestParam String appId,
@RequestBody RegisterUserRequest req) {
accountService.loginOrRegister(appId, req.userId(), req.nickname(), req.avatar());
ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, req.userId())
.orElseThrow();
return ResponseEntity.ok(ApiResponse.success(account));
}
/** Admin creates a group. */
@PostMapping("/groups")
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
@RequestParam String appId,
@RequestBody CreateGroupRequest req) {
return ResponseEntity.ok(ApiResponse.success(
groupService.create(appId, req.name(), req.creatorId(), req.memberIds())));
}
/** Message statistics for the given appId. */
@GetMapping("/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> stats(@RequestParam String appId) {
@ -73,4 +101,7 @@ public class ImAdminController {
"todayMessages", todayMessages
)));
}
public record RegisterUserRequest(String userId, String nickname, String avatar) {}
public record CreateGroupRequest(String name, String creatorId, List<String> memberIds) {}
}

查看文件

@ -3,7 +3,10 @@ package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.TenantRepository;
import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.EmailService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -14,18 +17,24 @@ 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.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/apps")
public class AppController {
private final AppService appService;
private final EmailService emailService;
private final TenantRepository tenantRepository;
public AppController(AppService appService) {
public AppController(AppService appService, EmailService emailService, TenantRepository tenantRepository) {
this.appService = appService;
this.emailService = emailService;
this.tenantRepository = tenantRepository;
}
@GetMapping
@ -58,4 +67,44 @@ public class AppController {
appService.delete(id, tenantId);
return ResponseEntity.ok(ApiResponse.ok());
}
/** Step 1: send email verification code for secret reveal or reset. */
@PostMapping("/{id}/request-secret-verify")
public ResponseEntity<ApiResponse<Void>> requestSecretVerify(
@PathVariable String id,
@RequestParam String purpose,
@AuthenticationPrincipal String tenantId) {
appService.getById(id, tenantId);
TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.sendVerificationCode(tenant.getEmail(), purpose);
return ResponseEntity.ok(ApiResponse.ok());
}
/** Step 2a: verify code and return the full appSecret. */
@PostMapping("/{id}/reveal-secret")
public ResponseEntity<ApiResponse<Map<String, String>>> revealSecret(
@PathVariable String id,
@RequestBody Map<String, String> body,
@AuthenticationPrincipal String tenantId) {
AppEntity app = appService.getById(id, tenantId);
TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET");
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret())));
}
/** Step 2b: verify code and regenerate appSecret (old one invalidated immediately). */
@PostMapping("/{id}/reset-secret")
public ResponseEntity<ApiResponse<Map<String, String>>> resetSecret(
@PathVariable String id,
@RequestBody Map<String, String> body,
@AuthenticationPrincipal String tenantId) {
AppEntity app = appService.getById(id, tenantId);
TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.verify(tenant.getEmail(), body.get("code"), "RESET_SECRET");
String newSecret = appService.resetSecret(id, tenantId);
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", newSecret)));
}
}

查看文件

@ -2,6 +2,7 @@ package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.FeatureServiceManager;
import org.springframework.http.ResponseEntity;
@ -34,6 +35,7 @@ public class FeatureServiceController {
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listByApp(appId)));
}
/** Disable a service (enable=false only; enabling requires ops approval via request-activation). */
@PostMapping("/toggle")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> toggle(
@PathVariable String appId,
@ -42,16 +44,30 @@ public class FeatureServiceController {
@RequestParam boolean enable,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
if (enable) {
throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请");
}
return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.toggle(appId, platform, serviceType, enable)));
featureServiceManager.disable(appId, platform, serviceType)));
}
@PostMapping("/{id}/regenerate-key")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> regenerateKey(
/** Submit an activation request for ops approval. */
@PostMapping("/request-activation")
public ResponseEntity<ApiResponse<ServiceActivationRequestEntity>> requestActivation(
@PathVariable String appId,
@PathVariable String id,
@RequestParam FeatureServiceEntity.Platform platform,
@RequestParam FeatureServiceEntity.ServiceType serviceType,
@RequestParam(required = false) String applyReason,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.regenerateKey(id)));
return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason)));
}
@GetMapping("/requests")
public ResponseEntity<ApiResponse<List<ServiceActivationRequestEntity>>> listRequests(
@PathVariable String appId, @AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
}
}

查看文件

@ -32,9 +32,6 @@ public class FeatureServiceEntity {
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false, unique = true, length = 128)
private String secretKey;
@Column(columnDefinition = "TEXT")
private String config;
@ -56,9 +53,6 @@ public class FeatureServiceEntity {
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getSecretKey() { return secretKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
public String getConfig() { return config; }
public void setConfig(String config) { this.config = config; }

查看文件

@ -65,6 +65,14 @@ public class AppService {
appRepository.delete(app);
}
public String resetSecret(String id, String tenantId) {
AppEntity app = getById(id, tenantId);
String newSecret = generateSecret();
app.setAppSecret(newSecret);
appRepository.save(app);
return newSecret;
}
private String generateAppKey() {
return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
}

查看文件

@ -9,9 +9,7 @@ import com.xuqm.tenant.repository.ServiceActivationRequestRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
@ -20,7 +18,6 @@ public class FeatureServiceManager {
private final FeatureServiceRepository repository;
private final ServiceActivationRequestRepository requestRepository;
private static final SecureRandom random = new SecureRandom();
public FeatureServiceManager(FeatureServiceRepository repository,
ServiceActivationRequestRepository requestRepository) {
@ -98,7 +95,6 @@ public class FeatureServiceManager {
e.setAppId(req.getAppId());
e.setPlatform(req.getPlatform());
e.setServiceType(req.getServiceType());
e.setSecretKey(generateSecretKey());
e.setCreatedAt(LocalDateTime.now());
return e;
});
@ -133,16 +129,4 @@ public class FeatureServiceManager {
.orElseThrow(() -> new BusinessException(404, "服务未配置"));
}
public FeatureServiceEntity regenerateKey(String id) {
FeatureServiceEntity entity = repository.findById(id)
.orElseThrow(() -> new BusinessException(404, "服务不存在"));
entity.setSecretKey(generateSecretKey());
return repository.save(entity);
}
private String generateSecretKey() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return "sk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}