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>
这个提交包含在:
父节点
f79a27862f
当前提交
161218420c
77
Jenkinsfile
vendored
普通文件
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);
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户