From 161218420ccf20446ab0db8ce77090a303ab334d Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 24 Apr 2026 20:53:48 +0800 Subject: [PATCH] feat: IM admin APIs, appSecret security, remove SecretKey, CI/CD pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Jenkinsfile | 77 +++++++++++++++++++ .../xuqm/im/controller/ImAdminController.java | 33 +++++++- .../xuqm/tenant/controller/AppController.java | 51 +++++++++++- .../controller/FeatureServiceController.java | 26 +++++-- .../tenant/entity/FeatureServiceEntity.java | 6 -- .../com/xuqm/tenant/service/AppService.java | 8 ++ .../tenant/service/FeatureServiceManager.java | 16 ---- 7 files changed, 188 insertions(+), 29 deletions(-) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..65dcd83 --- /dev/null +++ b/Jenkinsfile @@ -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 "❌ 构建失败,请检查日志" } + } +} diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index e60080b..0a1b29f 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -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> 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> 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>> 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 memberIds) {} } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index 3d4af25..ccf0380 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -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> 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>> revealSecret( + @PathVariable String id, + @RequestBody Map 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>> resetSecret( + @PathVariable String id, + @RequestBody Map 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))); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 4946fbe..a88498e 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -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> 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> regenerateKey( + /** Submit an activation request for ops approval. */ + @PostMapping("/request-activation") + public ResponseEntity> 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>> listRequests( + @PathVariable String appId, @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId))); } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java index e51e83c..f5b3324 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -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; } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index 8888e0c..6d6dbed 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -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); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 9c44f72..f7a2c27 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -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); - } }