diff --git a/Makefile b/Makefile index 5417e36f..b33b02a5 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,7 @@ help: ## 显示帮助 awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' dev: ## 启动本地开发环境(仅依赖服务) - docker compose up -d - @echo "Waiting for services to be healthy..." - @sleep 5 + docker compose up -d --wait --remove-orphans @echo "Services ready." @echo "Start backend with: make dev-server" @echo "Start frontend with: make dev-web" @@ -32,7 +30,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ else \ echo "Starting backend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ fi @if [ -f $(DEV_WEB_PID) ] && kill -0 "$$(cat $(DEV_WEB_PID))" 2>/dev/null; then \ echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ @@ -71,15 +69,18 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) @echo "Local environment is ready:" @echo " Web UI: $(DEV_WEB_URL)" @echo " Backend: $(DEV_API_URL)" + @echo "Mock auth users:" + @echo " local-user -> X-Mock-User-Id: local-user" + @echo " local-admin -> X-Mock-User-Id: local-admin" @echo "Logs:" @echo " Backend: $(DEV_SERVER_LOG)" @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local + cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' dev-down: ## 停止本地开发环境 - docker compose down + docker compose down --remove-orphans dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @@ -89,7 +90,7 @@ dev-all-down: ## 停止本地开发环境(依赖 + 后端 + 前端) dev-all-reset: ## 重置本地开发环境(清理依赖数据卷后重新启动) @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) @$(DEV_PROCESS) stop --pid-file $(DEV_WEB_PID) - docker compose down -v + docker compose down -v --remove-orphans rm -rf $(DEV_DIR) @$(MAKE) dev-all @@ -127,8 +128,6 @@ lint-web: ## 前端代码检查 cd web && pnpm run lint db-reset: ## 重置数据库 - docker compose down -v - docker compose up -d postgres - @echo "Waiting for postgres..." - @sleep 3 + docker compose down -v --remove-orphans + docker compose up -d --wait --remove-orphans postgres cd server && ./mvnw flyway:migrate -pl skillhub-app diff --git a/scripts/dev_process.py b/scripts/dev_process.py new file mode 100644 index 00000000..3213c108 --- /dev/null +++ b/scripts/dev_process.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +import argparse +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def read_pid(pid_file: Path) -> int | None: + if not pid_file.exists(): + return None + content = pid_file.read_text(encoding="utf-8").strip() + if not content: + return None + try: + return int(content) + except ValueError: + return None + + +def write_pid(pid_file: Path, pid: int) -> None: + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(f"{pid}\n", encoding="utf-8") + + +def start_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + log_file = Path(args.log_file) + cwd = Path(args.cwd) + existing_pid = read_pid(pid_file) + if existing_pid and is_running(existing_pid): + print(existing_pid) + return 0 + + pid_file.unlink(missing_ok=True) + log_file.parent.mkdir(parents=True, exist_ok=True) + + command = list(args.command) + if command and command[0] == "--": + command = command[1:] + + with log_file.open("ab") as log_handle, open(os.devnull, "rb") as devnull: + process = subprocess.Popen( + command, + cwd=cwd, + stdin=devnull, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + write_pid(pid_file, process.pid) + print(process.pid) + return 0 + + +def stop_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 0 + + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + + os.kill(pid, signal.SIGTERM) + deadline = time.time() + args.timeout + while time.time() < deadline: + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 0 + time.sleep(0.2) + + os.kill(pid, signal.SIGKILL) + pid_file.unlink(missing_ok=True) + return 0 + + +def status_process(args: argparse.Namespace) -> int: + pid_file = Path(args.pid_file) + pid = read_pid(pid_file) + if not pid: + return 1 + if not is_running(pid): + pid_file.unlink(missing_ok=True) + return 1 + print(pid) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage detached dev processes.") + subparsers = parser.add_subparsers(dest="action", required=True) + + start_parser = subparsers.add_parser("start") + start_parser.add_argument("--pid-file", required=True) + start_parser.add_argument("--log-file", required=True) + start_parser.add_argument("--cwd", required=True) + start_parser.add_argument("command", nargs=argparse.REMAINDER) + + stop_parser = subparsers.add_parser("stop") + stop_parser.add_argument("--pid-file", required=True) + stop_parser.add_argument("--timeout", type=float, default=10.0) + + status_parser = subparsers.add_parser("status") + status_parser.add_argument("--pid-file", required=True) + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.action == "start": + if not args.command: + parser.error("start requires a command") + return start_process(args) + if args.action == "stop": + return stop_process(args) + if args.action == "status": + return status_process(args) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java new file mode 100644 index 00000000..b78a871c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializer.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Profile("local") +public class LocalDevDataInitializer implements ApplicationRunner { + + public static final String LOCAL_USER_ID = "local-user"; + public static final String LOCAL_ADMIN_ID = "local-admin"; + + private static final Logger log = LoggerFactory.getLogger(LocalDevDataInitializer.class); + + private final UserAccountRepository userAccountRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final RoleRepository roleRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + + public LocalDevDataInitializer(UserAccountRepository userAccountRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository) { + this.userAccountRepository = userAccountRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.roleRepository = roleRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + UserAccount localUser = ensureUser( + LOCAL_USER_ID, + "Local Developer", + "local-user@example.test" + ); + UserAccount localAdmin = ensureUser( + LOCAL_ADMIN_ID, + "Local Admin", + "local-admin@example.test" + ); + + Namespace globalNamespace = namespaceRepository.findBySlug("global") + .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); + + ensureMembership(globalNamespace.getId(), localUser.getId(), NamespaceRole.OWNER); + ensureMembership(globalNamespace.getId(), localAdmin.getId(), NamespaceRole.OWNER); + ensureRole(localAdmin.getId(), "SUPER_ADMIN"); + + log.info("Local dev accounts ready: {} / {}", LOCAL_USER_ID, LOCAL_ADMIN_ID); + } + + private UserAccount ensureUser(String userId, String displayName, String email) { + return userAccountRepository.findById(userId) + .map(existing -> { + existing.setDisplayName(displayName); + existing.setEmail(email); + existing.setStatus(UserStatus.ACTIVE); + return userAccountRepository.save(existing); + }) + .orElseGet(() -> userAccountRepository.save( + new UserAccount(userId, displayName, email, null) + )); + } + + private void ensureMembership(Long namespaceId, String userId, NamespaceRole role) { + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseGet(() -> new NamespaceMember(namespaceId, userId, role)); + if (member.getRole() != role) { + member.setRole(role); + } + namespaceMemberRepository.save(member); + } + + private void ensureRole(String userId, String roleCode) { + boolean exists = userRoleBindingRepository.findByUserId(userId).stream() + .map(binding -> binding.getRole().getCode()) + .anyMatch(roleCode::equals); + if (exists) { + return; + } + + Role role = roleRepository.findByCode(roleCode) + .orElseThrow(() -> new IllegalStateException("Missing built-in role: " + roleCode)); + userRoleBindingRepository.save(new UserRoleBinding(userId, role)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java index 270372be..d65bf9f9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/PromotionController.java @@ -58,7 +58,7 @@ public class PromotionController extends BaseApiController { PromotionRequest promotion = promotionService.submitPromotion( request.sourceSkillId(), request.sourceVersionId(), request.targetNamespaceId(), userId); - return ok("response.success.create", toResponse(promotion)); + return ok("response.success.created", toResponse(promotion)); } @PostMapping("/{id}/approve") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index 742c263a..781962a0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -61,7 +61,7 @@ public class ReviewController extends BaseApiController { .orElseThrow(); Skill skill = skillRepository.findById(sv.getSkillId()).orElseThrow(); ReviewTask task = reviewService.submitReview(request.skillVersionId(), skill.getNamespaceId(), userId); - return ok("response.success.create", toResponse(task)); + return ok("response.success.created", toResponse(task)); } @PostMapping("/{id}/approve") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index b754b4f4..49625f63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,10 +1,10 @@ package com.iflytek.skillhub.exception; -import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -32,13 +32,6 @@ public class GlobalExceptionHandler { apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); } - @ExceptionHandler(AuthFlowException.class) - public ResponseEntity> handleAuthFlowException(AuthFlowException ex) { - HttpStatus status = ex.getStatus(); - return ResponseEntity.status(status).body( - apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); - } - @ExceptionHandler(DomainBadRequestException.class) public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { return ResponseEntity.badRequest().body( @@ -51,6 +44,12 @@ public class GlobalExceptionHandler { apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } + @ExceptionHandler(DomainNotFoundException.class) + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { String msg = ex.getBindingResult().getFieldErrors().stream() diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 3355754a..58abc9e4 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -51,6 +51,12 @@ error.skill.publish.package.invalid=Package validation failed: {0} error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.skill.notFound=Skill not found: {0} +skill.not_found=Skill not found: {0} +skill_version.not_found=Skill version not found: {0} +namespace.not_found=Namespace not found: {0} +promotion.not_found=Promotion request not found: {0} +review_task.not_found=Review task not found: {0} +review_task.not_found_for_version=Review task not found for skill version: {0} error.skill.access.denied=Access denied to skill: {0} error.skill.status.notActive=Skill is not active error.skill.version.exists=Version already exists: {0} @@ -71,15 +77,3 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code error.deviceAuth.deviceCode.expired=Device code expired error.deviceAuth.deviceCode.invalid=Device code expired or invalid error.deviceAuth.deviceCode.used=Device code has already been used -error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores -error.auth.local.username.exists=Username already exists -error.auth.local.email.exists=Email already exists -error.auth.local.invalidCredentials=Invalid username or password -error.auth.local.accountDisabled=Account has been disabled -error.auth.local.accountPending=Account is pending approval -error.auth.local.accountMerged=Account has been merged into another account -error.auth.local.locked=Account is locked. Try again in {0} minute(s) -error.auth.local.notEnabled=Password login is not enabled for this account -error.auth.local.password.tooShort=Password must be at least 8 characters -error.auth.local.password.tooLong=Password must not exceed 128 characters -error.auth.local.password.tooWeak=Password must contain at least three character types diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 55f7f0f9..2775dc5e 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -51,6 +51,12 @@ error.skill.publish.package.invalid=技能包校验失败:{0} error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.failed=预发布校验失败:{0} error.skill.notFound=未找到技能:{0} +skill.not_found=未找到技能:{0} +skill_version.not_found=未找到技能版本:{0} +namespace.not_found=未找到命名空间:{0} +promotion.not_found=未找到推广申请:{0} +review_task.not_found=未找到审核任务:{0} +review_task.not_found_for_version=未找到该技能版本对应的审核任务:{0} error.skill.access.denied=没有权限访问技能:{0} error.skill.status.notActive=技能未处于 ACTIVE 状态 error.skill.version.exists=版本已存在:{0} @@ -71,15 +77,3 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码 error.deviceAuth.deviceCode.expired=设备验证码已过期 error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期 error.deviceAuth.deviceCode.used=设备验证码已被使用 -error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线 -error.auth.local.username.exists=用户名已存在 -error.auth.local.email.exists=邮箱已存在 -error.auth.local.invalidCredentials=用户名或密码错误 -error.auth.local.accountDisabled=账号已被禁用 -error.auth.local.accountPending=账号仍在审核中 -error.auth.local.accountMerged=账号已合并到其他账号 -error.auth.local.locked=账号已锁定,请 {0} 分钟后重试 -error.auth.local.notEnabled=当前账号未启用密码登录 -error.auth.local.password.tooShort=密码长度至少为 8 位 -error.auth.local.password.tooLong=密码长度不能超过 128 位 -error.auth.local.password.tooWeak=密码至少需要包含三种字符类型 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java new file mode 100644 index 00000000..945530c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/LocalDevDataInitializerTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.DefaultApplicationArguments; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocalDevDataInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + + private LocalDevDataInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new LocalDevDataInitializer( + userAccountRepository, + namespaceRepository, + namespaceMemberRepository, + roleRepository, + userRoleBindingRepository + ); + } + + @Test + void shouldSeedLocalUsersGlobalMembershipAndSuperAdminRole() throws Exception { + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_USER_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.findById(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(anyLong(), any())).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId(LocalDevDataInitializer.LOCAL_ADMIN_ID)).thenReturn(List.of()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, times(2)).save(userCaptor.capture()); + List savedUsers = userCaptor.getAllValues(); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_USER_ID.equals(user.getId()))); + assertTrue(savedUsers.stream().anyMatch(user -> LocalDevDataInitializer.LOCAL_ADMIN_ID.equals(user.getId()))); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, times(2)).save(memberCaptor.capture()); + assertEquals( + List.of(LocalDevDataInitializer.LOCAL_USER_ID, LocalDevDataInitializer.LOCAL_ADMIN_ID), + memberCaptor.getAllValues().stream().map(NamespaceMember::getUserId).toList() + ); + assertTrue(memberCaptor.getAllValues().stream().allMatch(member -> member.getRole() == NamespaceRole.OWNER)); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals(LocalDevDataInitializer.LOCAL_ADMIN_ID, roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 8bc0dceb..7e6e1cdf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillRatingService; import org.junit.jupiter.api.Test; @@ -107,4 +108,33 @@ class SkillRatingControllerTest { .content("{\"score\": 4}")) .andExpect(status().isUnauthorized()); } + + @Test + void get_user_rating_missing_skill_returns_404_envelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + when(skillRatingService.getUserRating(eq(999L), eq("user-42"))) + .thenThrow(new DomainNotFoundException("skill.not_found", 999L)); + + mockMvc.perform(get("/api/v1/skills/999/rating") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.msg").value("Skill not found: 999")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index de0b5f92..7397ec1c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.social.SkillStarService; import org.junit.jupiter.api.Test; @@ -126,4 +127,33 @@ class SkillStarControllerTest { .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); } + + @Test + void check_starred_missing_skill_returns_404_envelope() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + when(skillStarService.isStarred(eq(999L), eq("user-42"))) + .thenThrow(new DomainNotFoundException("skill.not_found", 999L)); + + mockMvc.perform(get("/api/v1/skills/999/star") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(404)) + .andExpect(jsonPath("$.msg").value("Skill not found: 999")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index 56ffa0ef..8007f29b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -1,6 +1,9 @@ package com.iflytek.skillhub.auth.device; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -20,12 +23,25 @@ public class DeviceAuthService { private final RedisTemplate redisTemplate; private final String verificationUri; + private final ObjectMapper objectMapper; + private final ApiTokenService apiTokenService; private final SecureRandom random = new SecureRandom(); + @Autowired public DeviceAuthService(RedisTemplate redisTemplate, + ApiTokenService apiTokenService, @Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) { + this(redisTemplate, apiTokenService, verificationUri, new ObjectMapper()); + } + + public DeviceAuthService(RedisTemplate redisTemplate, + ApiTokenService apiTokenService, + String verificationUri, + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.verificationUri = verificationUri; + this.objectMapper = objectMapper; + this.apiTokenService = apiTokenService; } public DeviceCodeResponse generateDeviceCode() { @@ -48,7 +64,7 @@ public class DeviceAuthService { throw new DomainBadRequestException("error.deviceAuth.userCode.invalid"); } - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.expired"); } @@ -60,7 +76,7 @@ public class DeviceAuthService { } public DeviceTokenResponse pollToken(String deviceCode) { - DeviceCodeData data = (DeviceCodeData) redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + DeviceCodeData data = readDeviceCodeData(deviceCode); if (data == null) { throw new DomainBadRequestException("error.deviceAuth.deviceCode.invalid"); @@ -72,7 +88,9 @@ public class DeviceAuthService { data.setStatus(DeviceCodeStatus.USED); redisTemplate.opsForValue().set( DEVICE_CODE_PREFIX + deviceCode, data, 1, TimeUnit.MINUTES); - yield DeviceTokenResponse.success(null); + String token = apiTokenService.createToken( + data.getUserId(), "device-auth", "[]").rawToken(); + yield DeviceTokenResponse.success(token); } case USED -> throw new DomainBadRequestException("error.deviceAuth.deviceCode.used"); }; @@ -92,4 +110,15 @@ public class DeviceAuthService { } return code.toString(); } + + private DeviceCodeData readDeviceCodeData(String deviceCode) { + Object raw = redisTemplate.opsForValue().get(DEVICE_CODE_PREFIX + deviceCode); + if (raw == null) { + return null; + } + if (raw instanceof DeviceCodeData data) { + return data; + } + return objectMapper.convertValue(raw, DeviceCodeData.class); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java index 993ae314..a8ada47d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/device/DeviceAuthServiceTest.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.device; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,6 +12,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -26,12 +30,15 @@ class DeviceAuthServiceTest { @Mock private ValueOperations valueOperations; + @Mock + private ApiTokenService apiTokenService; + private DeviceAuthService service; @BeforeEach void setUp() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - service = new DeviceAuthService(redisTemplate, "https://skillhub.example.com/device"); + service = new DeviceAuthService(redisTemplate, apiTokenService, "https://skillhub.example.com/device", new ObjectMapper()); } @Test @@ -103,4 +110,56 @@ class DeviceAuthServiceTest { assertThat(data.getUserId()).isEqualTo("42"); verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(15L), eq(TimeUnit.MINUTES)); } + + @Test + void authorizeDeviceCode_accepts_linked_hash_map_from_redis_serializer() { + Map redisValue = new HashMap<>(); + redisValue.put("deviceCode", "device123"); + redisValue.put("userCode", "ABCD-1234"); + redisValue.put("status", "PENDING"); + redisValue.put("userId", null); + when(valueOperations.get("device:usercode:ABCD-1234")).thenReturn("device123"); + when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + + service.authorizeDeviceCode("ABCD-1234", "42"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DeviceCodeData.class); + verify(valueOperations).set(eq("device:code:device123"), captor.capture(), eq(15L), eq(TimeUnit.MINUTES)); + assertThat(captor.getValue().getStatus()).isEqualTo(DeviceCodeStatus.AUTHORIZED); + assertThat(captor.getValue().getUserId()).isEqualTo("42"); + } + + @Test + void pollToken_accepts_linked_hash_map_from_redis_serializer() { + Map redisValue = new HashMap<>(); + redisValue.put("deviceCode", "device123"); + redisValue.put("userCode", "ABCD-1234"); + redisValue.put("status", "AUTHORIZED"); + redisValue.put("userId", "42"); + when(valueOperations.get("device:code:device123")).thenReturn(redisValue); + when(apiTokenService.createToken("42", "device-auth", "[]")) + .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); + + DeviceTokenResponse response = service.pollToken("device123"); + + assertThat(response.error()).isNull(); + assertThat(response.accessToken()).isEqualTo("sk_device_token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(valueOperations).set(eq("device:code:device123"), any(DeviceCodeData.class), eq(1L), eq(TimeUnit.MINUTES)); + } + + @Test + void pollToken_returns_access_token_when_authorized() { + DeviceCodeData data = new DeviceCodeData("device123", "ABCD-1234", DeviceCodeStatus.AUTHORIZED, "42"); + when(valueOperations.get("device:code:device123")).thenReturn(data); + when(apiTokenService.createToken("42", "device-auth", "[]")) + .thenReturn(new ApiTokenService.TokenCreateResult("sk_device_token", null)); + + DeviceTokenResponse response = service.pollToken("device123"); + + assertThat(response.error()).isNull(); + assertThat(response.accessToken()).isEqualTo("sk_device_token"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + verify(valueOperations).set(eq("device:code:device123"), eq(data), eq(1L), eq(TimeUnit.MINUTES)); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 01d4d581..006ba1bf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -8,10 +8,12 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.LocalDateTime; import java.util.ConcurrentModificationException; import java.util.List; @@ -27,6 +29,7 @@ public class PromotionService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final EntityManager entityManager; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -34,7 +37,8 @@ public class PromotionService { SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + EntityManager entityManager) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -42,6 +46,7 @@ public class PromotionService { this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.entityManager = entityManager; } @Transactional @@ -96,6 +101,11 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + entityManager.detach(request); + request.setStatus(ReviewTaskStatus.APPROVED); + request.setReviewedBy(reviewerId); + request.setReviewComment(comment); + request.setReviewedAt(Instant.now()); Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); @@ -138,9 +148,12 @@ public class PromotionService { .toList(); skillFileRepository.saveAll(copiedFiles); - // Update promotion request with target skill id + int targetUpdated = promotionRequestRepository.updateStatusWithVersion( + promotionId, ReviewTaskStatus.APPROVED, reviewerId, comment, newSkill.getId(), request.getVersion() + 1); + if (targetUpdated == 0) { + throw new ConcurrentModificationException("Promotion request target skill was modified concurrently"); + } request.setTargetSkillId(newSkill.getId()); - promotionRequestRepository.save(request); eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); @@ -167,7 +180,11 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - - return promotionRequestRepository.findById(promotionId).orElse(request); + entityManager.detach(request); + request.setStatus(ReviewTaskStatus.REJECTED); + request.setReviewedBy(reviewerId); + request.setReviewComment(comment); + request.setReviewedAt(Instant.now()); + return request; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index 0cc89df1..259372c9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -30,10 +30,14 @@ public class ReviewPermissionChecker { return false; } + if (platformRoles.contains("SKILL_ADMIN") + || platformRoles.contains("SUPER_ADMIN")) { + return true; + } + // Global namespace: only SKILL_ADMIN or SUPER_ADMIN if (namespaceType == NamespaceType.GLOBAL) { - return platformRoles.contains("SKILL_ADMIN") - || platformRoles.contains("SUPER_ADMIN"); + return false; } // Team namespace: namespace ADMIN or OWNER diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index fb2482af..a9392024 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -32,19 +33,22 @@ public class ReviewService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final EntityManager entityManager; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, SkillRepository skillRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + EntityManager entityManager) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.entityManager = entityManager; } @Transactional @@ -91,6 +95,11 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + entityManager.detach(task); + task.setStatus(ReviewTaskStatus.APPROVED); + task.setReviewedBy(reviewerId); + task.setReviewComment(comment); + task.setReviewedAt(Instant.now()); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); @@ -106,8 +115,7 @@ public class ReviewService { eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); - // Reload to return updated state - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } @Transactional @@ -134,13 +142,18 @@ public class ReviewService { if (updated == 0) { throw new ConcurrentModificationException("Review task was modified concurrently"); } + entityManager.detach(task); + task.setStatus(ReviewTaskStatus.REJECTED); + task.setReviewedBy(reviewerId); + task.setReviewComment(comment); + task.setReviewedAt(Instant.now()); SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); - return reviewTaskRepository.findById(reviewTaskId).orElse(task); + return task; } @Transactional diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 2d6fa0bb..009e8103 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -214,6 +214,9 @@ public class SkillPublishService { version.setTotalSize(totalSize); skillVersionRepository.save(version); + ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); + reviewTaskRepository.save(reviewTask); + // 12. Update skill skill.setLatestVersionId(version.getId()); skill.setDisplayName(metadata.name()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 814c1ff7..7d8a6235 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -11,16 +13,20 @@ import java.util.Optional; @Service public class SkillRatingService { private final SkillRatingRepository ratingRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillRatingService(SkillRatingRepository ratingRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.ratingRepository = ratingRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void rate(Long skillId, String userId, short score) { + ensureSkillExists(skillId); if (score < 1 || score > 5) { throw new DomainBadRequestException("error.rating.score.invalid"); } @@ -35,7 +41,14 @@ public class SkillRatingService { } public Optional getUserRating(Long skillId, String userId) { + ensureSkillExists(skillId); return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java index 2576d457..b1dd49bc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStarService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.springframework.context.ApplicationEventPublisher; @@ -9,16 +11,20 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SkillStarService { private final SkillStarRepository starRepository; + private final SkillRepository skillRepository; private final ApplicationEventPublisher eventPublisher; public SkillStarService(SkillStarRepository starRepository, + SkillRepository skillRepository, ApplicationEventPublisher eventPublisher) { this.starRepository = starRepository; + this.skillRepository = skillRepository; this.eventPublisher = eventPublisher; } @Transactional public void star(Long skillId, String userId) { + ensureSkillExists(skillId); if (starRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { return; // idempotent } @@ -28,6 +34,7 @@ public class SkillStarService { @Transactional public void unstar(Long skillId, String userId) { + ensureSkillExists(skillId); starRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(star -> { starRepository.delete(star); eventPublisher.publishEvent(new SkillUnstarredEvent(skillId, userId)); @@ -35,6 +42,13 @@ public class SkillStarService { } public boolean isStarred(Long skillId, String userId) { + ensureSkillExists(skillId); return starRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 7670a76d..b73e9e52 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import jakarta.persistence.EntityManager; import java.util.*; @@ -34,6 +35,7 @@ class PromotionServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private EntityManager entityManager; private PromotionService promotionService; @@ -50,7 +52,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, entityManager); } private static void setField(Object target, String fieldName, Object value) { @@ -233,6 +235,9 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) .thenReturn(1); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1)) + .thenReturn(1); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { @@ -292,8 +297,9 @@ class PromotionServiceTest { assertEquals(NEW_VERSION_ID, event.versionId()); assertEquals(REVIEWER_ID, event.publisherId()); - // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(pr); + verify(entityManager).detach(pr); + verify(promotionRequestRepository).updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", NEW_SKILL_ID, pr.getVersion() + 1); assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); } @@ -346,6 +352,9 @@ class PromotionServiceTest { when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion(any(), any(), any(), any(), any(), any())).thenReturn(1); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", NEW_SKILL_ID, pr.getVersion() + 1)) + .thenReturn(1); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); when(skillRepository.save(any(Skill.class))).thenAnswer(inv -> { @@ -382,14 +391,15 @@ class PromotionServiceTest { when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) .thenReturn(1); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); - PromotionRequest result = promotionService.rejectPromotion( PROMOTION_ID, REVIEWER_ID, "Not ready", Set.of("SKILL_ADMIN")); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); + verify(entityManager).detach(pr); verify(eventPublisher, never()).publishEvent(any()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index f3d43cd9..45a5a924 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -66,11 +66,19 @@ class ReviewPermissionCheckerTest { @Test void skillAdminCannotReviewTeamSkill() { ReviewTask task = new ReviewTask(1L, 10L, "user-2"); - assertFalse(checker.canReview(task, "user-1", + assertTrue(checker.canReview(task, "user-1", NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN"))); } + @Test + void superAdminCanReviewTeamSkill() { + ReviewTask task = new ReviewTask(1L, 10L, "user-2"); + assertTrue(checker.canReview(task, "user-1", + NamespaceType.TEAM, + Map.of(), Set.of("SUPER_ADMIN"))); + } + @Test void nonAdminCannotReviewGlobalSkill() { ReviewTask task = new ReviewTask(1L, 1L, "user-2"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 297a7701..42bbad07 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -41,6 +42,7 @@ class ReviewServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private EntityManager entityManager; private ReviewService reviewService; @@ -55,7 +57,7 @@ class ReviewServiceTest { void setUp() { reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher); + namespaceRepository, permissionChecker, eventPublisher, entityManager); } private SkillVersion createDraftSkillVersion() { @@ -164,13 +166,16 @@ class ReviewServiceTest { .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.approveReview( REVIEW_TASK_ID, REVIEWER_ID, "LGTM", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.APPROVED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("LGTM", result.getReviewComment()); + assertNotNull(result.getReviewedAt()); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); assertNotNull(sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); @@ -190,7 +195,6 @@ class ReviewServiceTest { when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); @@ -262,13 +266,16 @@ class ReviewServiceTest { when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); - when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.rejectReview( REVIEW_TASK_ID, REVIEWER_ID, "needs work", Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); assertNotNull(result); + assertEquals(ReviewTaskStatus.REJECTED, result.getStatus()); + assertEquals(REVIEWER_ID, result.getReviewedBy()); + assertEquals("needs work", result.getReviewComment()); + assertNotNull(result.getReviewedAt()); assertEquals(SkillVersionStatus.REJECTED, sv.getStatus()); verify(skillVersionRepository).save(sv); verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 416d441e..7f87af7b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.*; @@ -128,6 +129,7 @@ class SkillPublishServiceTest { verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); + verify(reviewTaskRepository).save(any(ReviewTask.class)); } @Test @@ -168,6 +170,7 @@ class SkillPublishServiceTest { assertEquals("smoke-skill-two", result.slug()); verify(skillRepository).findByNamespaceIdAndSlug(1L, "smoke-skill-two"); + verify(reviewTaskRepository).save(any(ReviewTask.class)); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 29ebd961..00bf2133 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillRatingServiceTest { @Mock SkillRatingRepository ratingRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillRatingService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void rate_creates_new_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillRatingServiceTest { @Test void rate_updates_existing_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 3); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); when(ratingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -45,6 +56,7 @@ class SkillRatingServiceTest { @Test void rate_invalid_score_throws() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); assertThatThrownBy(() -> service.rate(1L, "10", (short) 0)) .isInstanceOf(DomainBadRequestException.class); assertThatThrownBy(() -> service.rate(1L, "10", (short) 6)) @@ -53,8 +65,16 @@ class SkillRatingServiceTest { @Test void getUserRating_returns_score() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillRating existing = new SkillRating(1L, "10", (short) 4); when(ratingRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); assertThat(service.getUserRating(1L, "10")).hasValue((short) 4); } + + @Test + void getUserRating_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getUserRating(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java index a9e30d1b..2c4a2197 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillStarServiceTest.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.domain.social; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.social.event.SkillStarredEvent; import com.iflytek.skillhub.domain.social.event.SkillUnstarredEvent; import org.junit.jupiter.api.Test; @@ -16,11 +20,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillStarServiceTest { @Mock SkillStarRepository starRepository; + @Mock SkillRepository skillRepository; @Mock ApplicationEventPublisher eventPublisher; @InjectMocks SkillStarService service; + private Skill skill() { + return new Skill(1L, "skill-1", "owner-1", SkillVisibility.PUBLIC); + } + @Test void star_skill_creates_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); when(starRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -32,6 +42,7 @@ class SkillStarServiceTest { @Test void star_skill_already_starred_is_idempotent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); @@ -43,6 +54,7 @@ class SkillStarServiceTest { @Test void unstar_skill_deletes_record_and_publishes_event() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); SkillStar existing = new SkillStar(1L, "10"); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.of(existing)); @@ -54,6 +66,7 @@ class SkillStarServiceTest { @Test void unstar_skill_not_starred_is_noop() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")).thenReturn(Optional.empty()); service.unstar(1L, "10"); @@ -64,8 +77,16 @@ class SkillStarServiceTest { @Test void isStarred_returns_true_when_exists() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); when(starRepository.findBySkillIdAndUserId(1L, "10")) .thenReturn(Optional.of(new SkillStar(1L, "10"))); assertThat(service.isStarred(1L, "10")).isTrue(); } + + @Test + void star_skill_throws_when_skill_missing() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.star(99L, "10")) + .isInstanceOf(DomainNotFoundException.class); + } }