diff --git a/README.md b/README.md index a4ed20c5..18f37ed5 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,59 @@ Pass `X-Mock-User-Id` to the backend when you need an authenticated session without configuring GitHub OAuth. If the GHCR package remains private, run `docker login ghcr.io` before `docker compose up -d`. +### Monitoring + +The Phase 4 monitoring stack lives under [`monitoring/`](./monitoring). +It provides a local Prometheus + Grafana pair that scrapes the backend's +Actuator Prometheus endpoint. + +Start it with: + +```bash +cd monitoring +docker compose -f docker-compose.monitoring.yml up -d +``` + +Then open: + +- Prometheus: `http://localhost:9090` +- Grafana: `http://localhost:3001` (`admin` / `admin`) + +By default Prometheus scrapes `http://host.docker.internal:8080/actuator/prometheus`, +so start the backend locally on port `8080` first. + +## Kubernetes + +Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s): + +- `configmap.yaml` +- `secret.yaml.example` +- `backend-deployment.yaml` +- `frontend-deployment.yaml` +- `services.yaml` +- `ingress.yaml` + +Apply them after creating your own secret: + +```bash +kubectl apply -f deploy/k8s/configmap.yaml +kubectl apply -f deploy/k8s/secret.yaml +kubectl apply -f deploy/k8s/backend-deployment.yaml +kubectl apply -f deploy/k8s/frontend-deployment.yaml +kubectl apply -f deploy/k8s/services.yaml +kubectl apply -f deploy/k8s/ingress.yaml +``` + +## Smoke Test + +A lightweight smoke test script is available at [`scripts/smoke-test.sh`](./scripts/smoke-test.sh). + +Run it against a local backend: + +```bash +./scripts/smoke-test.sh http://localhost:8080 +``` + ## Architecture ``` diff --git a/deploy/k8s/backend-deployment.yaml b/deploy/k8s/backend-deployment.yaml new file mode 100644 index 00000000..7c2af196 --- /dev/null +++ b/deploy/k8s/backend-deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-server + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-server + spec: + containers: + - name: server + image: ghcr.io/iflytek/skillhub-server:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: SPRING_PROFILES_ACTIVE + value: docker + - name: SPRING_DATASOURCE_URL + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-url + - name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-username + - name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: skillhub-secret + key: spring-datasource-password + - name: SPRING_DATA_REDIS_HOST + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-host + - name: SPRING_DATA_REDIS_PORT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: redis-port + - name: STORAGE_BASE_PATH + valueFrom: + configMapKeyRef: + name: skillhub-config + key: storage-base-path + - name: SESSION_COOKIE_SECURE + value: "true" + - name: OAUTH2_GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-id + optional: true + - name: OAUTH2_GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: skillhub-secret + key: oauth2-github-client-secret + optional: true + volumeMounts: + - name: skillhub-storage + mountPath: /var/lib/skillhub/storage + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: skillhub-storage + persistentVolumeClaim: + claimName: skillhub-storage-pvc diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 00000000..e3988d82 --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: skillhub-config +data: + redis-host: redis + redis-port: "6379" + storage-base-path: /var/lib/skillhub/storage +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: skillhub-storage-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/deploy/k8s/frontend-deployment.yaml b/deploy/k8s/frontend-deployment.yaml new file mode 100644 index 00000000..05538617 --- /dev/null +++ b/deploy/k8s/frontend-deployment.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: skillhub-web + template: + metadata: + labels: + app.kubernetes.io/name: skillhub-web + spec: + containers: + - name: web + image: ghcr.io/iflytek/skillhub-web:edge + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /nginx-health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 00000000..89ff0980 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: skillhub + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + ingressClassName: nginx + rules: + - host: skills.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: skillhub-server + port: + number: 8080 + - path: / + pathType: Prefix + backend: + service: + name: skillhub-web + port: + number: 80 diff --git a/deploy/k8s/secret.yaml.example b/deploy/k8s/secret.yaml.example new file mode 100644 index 00000000..1aac6fd9 --- /dev/null +++ b/deploy/k8s/secret.yaml.example @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: skillhub-secret +type: Opaque +stringData: + spring-datasource-url: jdbc:postgresql://postgres:5432/skillhub + spring-datasource-username: skillhub + spring-datasource-password: change-me + oauth2-github-client-id: your-client-id + oauth2-github-client-secret: your-client-secret diff --git a/deploy/k8s/services.yaml b/deploy/k8s/services.yaml new file mode 100644 index 00000000..d7a741a4 --- /dev/null +++ b/deploy/k8s/services.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Service +metadata: + name: skillhub-server + labels: + app.kubernetes.io/name: skillhub-server +spec: + selector: + app.kubernetes.io/name: skillhub-server + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: skillhub-web + labels: + app.kubernetes.io/name: skillhub-web +spec: + selector: + app.kubernetes.io/name: skillhub-web + ports: + - name: http + port: 80 + targetPort: http diff --git a/monitoring/docker-compose.monitoring.yml b/monitoring/docker-compose.monitoring.yml new file mode 100644 index 00000000..75056f43 --- /dev/null +++ b/monitoring/docker-compose.monitoring.yml @@ -0,0 +1,17 @@ +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + + grafana: + image: grafana/grafana:latest + ports: + - "3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + depends_on: + - prometheus diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 00000000..d2bf08fb --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: skillhub-backend + metrics_path: /actuator/prometheus + static_configs: + - targets: + - host.docker.internal:8080 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..62a357ab --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 + +check() { + local desc="$1" + local url="$2" + local expected="$3" + local status + status="$(curl -s -o /dev/null -w "%{http_code}" "$url")" + if [[ "$status" == "$expected" ]]; then + echo "PASS: $desc (HTTP $status)" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected $expected, got $status)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== SkillHub Smoke Test ===" +echo "Target: $BASE_URL" +echo + +check "Health endpoint" "$BASE_URL/actuator/health" "200" +check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200" +check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200" +check "Auth required" "$BASE_URL/api/v1/auth/me" "401" + +REGISTER_STATUS="$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/api/v1/auth/local/register" \ + -H "Content-Type: application/json" \ + -d '{"username":"smoketest","password":"Smoke@2026","email":"smoketest@example.com"}')" +if [[ "$REGISTER_STATUS" == "200" || "$REGISTER_STATUS" == "409" ]]; then + echo "PASS: Register (HTTP $REGISTER_STATUS)" + PASS=$((PASS + 1)) +else + echo "FAIL: Register (got $REGISTER_STATUS)" + FAIL=$((FAIL + 1)) +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 6689adc8..06a28ddc 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -22,6 +22,10 @@ org.springframework.boot spring-boot-starter-actuator + + io.micrometer + micrometer-registry-prometheus + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java new file mode 100644 index 00000000..9288aba0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AccountMergeController.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.MergeInitiateRequest; +import com.iflytek.skillhub.dto.MergeInitiateResponse; +import com.iflytek.skillhub.dto.MergeVerifyRequest; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.exception.UnauthorizedException; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/account/merge") +public class AccountMergeController extends BaseApiController { + + private final AccountMergeService accountMergeService; + + public AccountMergeController(ApiResponseFactory responseFactory, + AccountMergeService accountMergeService) { + super(responseFactory); + this.accountMergeService = accountMergeService; + } + + @PostMapping("/initiate") + public ApiResponse initiate(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeInitiateRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + var result = accountMergeService.initiate(principal.userId(), request.secondaryIdentifier()); + return ok("response.success.created", new MergeInitiateResponse( + result.mergeRequestId(), + result.secondaryUserId(), + result.verificationToken(), + result.expiresAt().toString() + )); + } + + @PostMapping("/verify") + public ApiResponse verify(@AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody MergeVerifyRequest request) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + accountMergeService.verifyAndComplete( + principal.userId(), + request.mergeRequestId(), + request.verificationToken() + ); + return ok("response.success.updated", new MessageResponse("Account merge completed")); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 64e0fd29..59494ab5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.ChangePasswordRequest; import com.iflytek.skillhub.dto.LocalLoginRequest; import com.iflytek.skillhub.dto.LocalRegisterRequest; import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; @@ -28,17 +29,21 @@ import org.springframework.web.bind.annotation.RestController; public class LocalAuthController extends BaseApiController { private final LocalAuthService localAuthService; + private final SkillHubMetrics skillHubMetrics; public LocalAuthController(ApiResponseFactory responseFactory, - LocalAuthService localAuthService) { + LocalAuthService localAuthService, + SkillHubMetrics skillHubMetrics) { super(responseFactory); this.localAuthService = localAuthService; + this.skillHubMetrics = skillHubMetrics; } @PostMapping("/register") public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request, HttpServletRequest httpRequest) { PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); + skillHubMetrics.incrementUserRegister(); establishSession(principal, httpRequest); return ok("response.success.created", AuthMeResponse.from(principal)); } @@ -46,7 +51,14 @@ public class LocalAuthController extends BaseApiController { @PostMapping("/login") public ApiResponse login(@Valid @RequestBody LocalLoginRequest request, HttpServletRequest httpRequest) { - PlatformPrincipal principal = localAuthService.login(request.username(), request.password()); + PlatformPrincipal principal; + try { + principal = localAuthService.login(request.username(), request.password()); + } catch (RuntimeException ex) { + skillHubMetrics.recordLocalLogin(false); + throw ex; + } + skillHubMetrics.recordLocalLogin(true); establishSession(principal, httpRequest); return ok("response.success.read", AuthMeResponse.from(principal)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java new file mode 100644 index 00000000..25e3d053 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.AdminSkillActionRequest; +import com.iflytek.skillhub.dto.AdminSkillMutationResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skills") +public class AdminSkillController extends BaseApiController { + + private final SkillGovernanceService skillGovernanceService; + + public AdminSkillController(ApiResponseFactory responseFactory, + SkillGovernanceService skillGovernanceService) { + super(responseFactory); + this.skillGovernanceService = skillGovernanceService; + } + + @PostMapping("/{skillId}/hide") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse hideSkill(@PathVariable Long skillId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.hideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "HIDE", skill.getStatus().name())); + } + + @PostMapping("/{skillId}/unhide") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse unhideSkill(@PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var skill = skillGovernanceService.unhideSkill( + skillId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", new AdminSkillMutationResponse(skillId, null, "UNHIDE", skill.getStatus().name())); + } + + @PostMapping("/versions/{versionId}/yank") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") + public ApiResponse yankVersion(@PathVariable Long versionId, + @RequestBody(required = false) AdminSkillActionRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + var version = skillGovernanceService.yankVersion( + versionId, + principal.userId(), + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent"), + request != null ? request.reason() : null + ); + return ok("response.success.updated", new AdminSkillMutationResponse(version.getSkillId(), versionId, "YANK", version.getStatus().name())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java index 4426113f..32ec0079 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AuditLogController.java @@ -5,19 +5,20 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.AuditLogItemResponse; import com.iflytek.skillhub.dto.PageResponse; -import org.springframework.data.domain.PageImpl; +import com.iflytek.skillhub.domain.audit.AuditLogQueryService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.time.Instant; -import java.util.List; - @RestController @RequestMapping("/api/v1/admin/audit-logs") public class AuditLogController extends BaseApiController { - public AuditLogController(ApiResponseFactory responseFactory) { + private final AuditLogQueryService auditLogQueryService; + + public AuditLogController(ApiResponseFactory responseFactory, + AuditLogQueryService auditLogQueryService) { super(responseFactory); + this.auditLogQueryService = auditLogQueryService; } @GetMapping @@ -27,15 +28,16 @@ public class AuditLogController extends BaseApiController { @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String userId, @RequestParam(required = false) String action) { - List logs = List.of( - new AuditLogItemResponse( - "log-1", "user-1", "CREATE_SKILL", "SKILL", "skill-123", Instant.now(), "192.168.1.1" - ), - new AuditLogItemResponse( - "log-2", "user-2", "UPDATE_NAMESPACE", "NAMESPACE", "ns-456", - Instant.now().minusSeconds(3600), "192.168.1.2" - ) - ); - return ok("response.success.read", PageResponse.from(new PageImpl<>(logs))); + var logs = auditLogQueryService.list(page, size, userId, action) + .map(log -> new AuditLogItemResponse( + String.valueOf(log.getId()), + log.getActorUserId(), + log.getAction(), + log.getTargetType(), + log.getTargetId() != null ? String.valueOf(log.getTargetId()) : "", + log.getCreatedAt(), + log.getClientIp() + )); + return ok("response.success.read", PageResponse.from(logs)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java index 76bae215..296628fa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,11 +23,14 @@ import java.util.zip.ZipInputStream; public class CliPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final SkillHubMetrics skillHubMetrics; public CliPublishController(SkillPublishService skillPublishService, - ApiResponseFactory responseFactory) { + ApiResponseFactory responseFactory, + SkillHubMetrics skillHubMetrics) { super(responseFactory); this.skillPublishService = skillPublishService; + this.skillHubMetrics = skillHubMetrics; } @PostMapping("/publish") @@ -57,6 +61,7 @@ public class CliPublishController extends BaseApiController { publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 2fba90df..de850588 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -272,11 +273,7 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest( namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(result); } @GetMapping("/{namespace}/{slug}/versions/{version}/download") @@ -291,11 +288,7 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion( namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of()); - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") - .contentType(MediaType.parseMediaType(result.contentType())) - .contentLength(result.contentLength()) - .body(new InputStreamResource(result.content())); + return buildDownloadResponse(result); } @GetMapping("/{namespace}/{slug}/tags/{tagName}/download") @@ -310,6 +303,16 @@ public class SkillController extends BaseApiController { SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag( namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of()); + return buildDownloadResponse(result); + } + + private ResponseEntity buildDownloadResponse(SkillDownloadService.DownloadResult result) { + if (result.presignedUrl() != null) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, result.presignedUrl()) + .build(); + } + return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"") .contentType(MediaType.parseMediaType(result.contentType())) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index e984e31e..7e81aafa 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PublishResponse; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,11 +23,14 @@ import java.util.zip.ZipInputStream; public class SkillPublishController extends BaseApiController { private final SkillPublishService skillPublishService; + private final SkillHubMetrics skillHubMetrics; public SkillPublishController(SkillPublishService skillPublishService, - ApiResponseFactory responseFactory) { + ApiResponseFactory responseFactory, + SkillHubMetrics skillHubMetrics) { super(responseFactory); this.skillPublishService = skillPublishService; + this.skillHubMetrics = skillHubMetrics; } @PostMapping("/{namespace}/publish") @@ -57,6 +61,7 @@ public class SkillPublishController extends BaseApiController { publishResult.version().getFileCount(), publishResult.version().getTotalSize() ); + skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name()); return ok("response.success.published", response); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java new file mode 100644 index 00000000..61d40da8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillActionRequest.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillActionRequest(String reason) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java new file mode 100644 index 00000000..dff57c87 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillMutationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record AdminSkillMutationResponse( + Long skillId, + Long versionId, + String action, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java new file mode 100644 index 00000000..588d7eb9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MergeInitiateRequest( + @NotBlank(message = "待合并账号标识不能为空") + String secondaryIdentifier +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java new file mode 100644 index 00000000..86e7fab8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeInitiateResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record MergeInitiateResponse( + Long mergeRequestId, + String secondaryUserId, + String verificationToken, + String expiresAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java new file mode 100644 index 00000000..3d0e63d4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MergeVerifyRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MergeVerifyRequest( + @NotNull(message = "合并请求 ID 不能为空") + Long mergeRequestId, + @NotBlank(message = "验证 token 不能为空") + String verificationToken +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java new file mode 100644 index 00000000..75e24c35 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java @@ -0,0 +1,34 @@ +package com.iflytek.skillhub.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Component; + +@Component +public class SkillHubMetrics { + + private final MeterRegistry meterRegistry; + + public SkillHubMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void incrementUserRegister() { + meterRegistry.counter("skillhub.user.register").increment(); + } + + public void recordLocalLogin(boolean success) { + meterRegistry.counter( + "skillhub.auth.login", + "method", "local", + "result", success ? "success" : "failure" + ).increment(); + } + + public void incrementSkillPublish(String namespace, String status) { + meterRegistry.counter( + "skillhub.skill.publish", + "namespace", namespace, + "status", status + ).increment(); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a9e25284..0403d96c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -1,6 +1,13 @@ server: port: 8080 shutdown: graceful + servlet: + session: + cookie: + http-only: true + secure: ${SESSION_COOKIE_SECURE:false} + same-site: lax + max-age: 28800 spring: messages: @@ -67,10 +74,16 @@ management: endpoints: web: exposure: - include: health,info + include: health,info,prometheus,metrics endpoint: health: show-details: when-authorized + metrics: + tags: + application: skillhub + export: + prometheus: + enabled: true --- # Docker profile diff --git a/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql new file mode 100644 index 00000000..6f0bde78 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V6__phase4_governance_audit.sql @@ -0,0 +1,11 @@ +ALTER TABLE skill ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE skill ADD COLUMN hidden_at TIMESTAMP; +ALTER TABLE skill ADD COLUMN hidden_by VARCHAR(128) REFERENCES user_account(id); + +ALTER TABLE skill_version ADD COLUMN yanked_at TIMESTAMP; +ALTER TABLE skill_version ADD COLUMN yanked_by VARCHAR(128) REFERENCES user_account(id); +ALTER TABLE skill_version ADD COLUMN yank_reason TEXT; + +CREATE INDEX idx_skill_hidden ON skill(hidden) WHERE hidden = TRUE; +CREATE INDEX idx_audit_log_actor_time ON audit_log(actor_user_id, created_at DESC); +CREATE INDEX idx_audit_log_action_time ON audit_log(action, created_at DESC); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 2b9ab1c6..1c0274c9 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -89,3 +89,16 @@ 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 +error.auth.merge.identifierRequired=Secondary account identifier is required +error.auth.merge.identifierInvalid=Secondary account identifier is invalid +error.auth.merge.primaryNotFound=Primary account not found +error.auth.merge.primaryNotActive=Primary account must be active +error.auth.merge.secondaryNotFound=Secondary account not found +error.auth.merge.secondaryNotActive=Secondary account must be active +error.auth.merge.sameAccount=Cannot merge the current account into itself +error.auth.merge.pendingExists=A pending merge request already exists for this secondary account +error.auth.merge.localCredentialConflict=Both accounts already have local credentials +error.auth.merge.requestNotFound=Merge request not found +error.auth.merge.requestNotPending=Merge request is not pending +error.auth.merge.tokenExpired=Merge verification token has expired +error.auth.merge.invalidToken=Invalid merge verification token diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index d877c8ff..fda7b01c 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -89,3 +89,16 @@ error.auth.local.notEnabled=当前账号未启用密码登录 error.auth.local.password.tooShort=密码长度至少为 8 位 error.auth.local.password.tooLong=密码长度不能超过 128 位 error.auth.local.password.tooWeak=密码至少需要包含三种字符类型 +error.auth.merge.identifierRequired=待合并账号标识不能为空 +error.auth.merge.identifierInvalid=待合并账号标识格式不正确 +error.auth.merge.primaryNotFound=未找到主账号 +error.auth.merge.primaryNotActive=主账号必须处于激活状态 +error.auth.merge.secondaryNotFound=未找到待合并账号 +error.auth.merge.secondaryNotActive=待合并账号必须处于激活状态 +error.auth.merge.sameAccount=不能将当前账号合并到自己 +error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求 +error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并 +error.auth.merge.requestNotFound=未找到合并请求 +error.auth.merge.requestNotPending=该合并请求不处于待验证状态 +error.auth.merge.tokenExpired=合并验证 token 已过期 +error.auth.merge.invalidToken=合并验证 token 无效 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java new file mode 100644 index 00000000..167c0f1e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.merge.AccountMergeService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AccountMergeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountMergeService accountMergeService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void initiate_returnsVerificationToken() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + given(accountMergeService.initiate("usr_primary", "secondary")) + .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", LocalDateTime.parse("2026-03-12T22:30:00"))); + + mockMvc.perform(post("/api/v1/account/merge/initiate") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"secondaryIdentifier":"secondary"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.mergeRequestId").value(1)) + .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) + .andExpect(jsonPath("$.data.verificationToken").value("merge-token")); + } + + @Test + void verify_returnsSuccessMessage() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + + mockMvc.perform(post("/api/v1/account/merge/verify") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"mergeRequestId":1,"verificationToken":"merge-token"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Account merge completed")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index c13c75f3..53d710bb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -18,6 +18,7 @@ import java.util.Set; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -59,6 +60,9 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().string("X-Frame-Options", "DENY")) + .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.userId").value("user-42")) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index b2176a0c..b0d84d8e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -1,15 +1,19 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.iflytek.skillhub.auth.exception.AuthFlowException; import com.iflytek.skillhub.auth.local.LocalAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -18,6 +22,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; @@ -37,6 +42,9 @@ class LocalAuthControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private SkillHubMetrics skillHubMetrics; + @Test void login_returnsCurrentUserEnvelope() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( @@ -59,6 +67,8 @@ class LocalAuthControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_1")) .andExpect(jsonPath("$.data.oauthProvider").value("local")); + verify(skillHubMetrics).recordLocalLogin(true); + verify(skillHubMetrics, never()).recordLocalLogin(false); } @Test @@ -82,6 +92,23 @@ class LocalAuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.displayName").value("bob")); + verify(skillHubMetrics).incrementUserRegister(); + } + + @Test + void login_failure_recordsFailureMetric() throws Exception { + given(localAuthService.login("alice", "wrong")) + .willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials")); + + mockMvc.perform(post("/api/v1/auth/local/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"alice","password":"wrong"} + """)) + .andExpect(status().isUnauthorized()); + verify(skillHubMetrics).recordLocalLogin(false); + verify(skillHubMetrics, never()).recordLocalLogin(true); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java new file mode 100644 index 00000000..16c7c43b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSkillControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillGovernanceService skillGovernanceService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void hideSkill_returnsUpdatedResponse() throws Exception { + Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); + given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + .willReturn(skill); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/10/hide") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"policy\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(10)) + .andExpect(jsonPath("$.data.action").value("HIDE")); + } + + @Test + void yankVersion_returnsUpdatedResponse() throws Exception { + SkillVersion version = new SkillVersion(10L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.YANKED); + given(skillGovernanceService.yankVersion(org.mockito.ArgumentMatchers.eq(33L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("broken"))) + .willReturn(version); + + PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN")); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))); + + mockMvc.perform(post("/api/v1/admin/skills/versions/33/yank") + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"broken\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.versionId").value(33)) + .andExpect(jsonPath("$.data.action").value("YANK")) + .andExpect(jsonPath("$.data.status").value("YANKED")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index becb9cdd..286201bc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogQueryService; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -10,14 +12,18 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.time.Instant; import java.util.List; import java.util.Set; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -38,6 +44,9 @@ class AuditLogControllerTest { @MockBean private DeviceAuthService deviceAuthService; + @MockBean + private AuditLogQueryService auditLogQueryService; + @Test void listAuditLogs_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/audit-logs")) @@ -46,6 +55,15 @@ class AuditLogControllerTest { @Test void listAuditLogs_withAuditorRole_returns200() throws Exception { + AuditLog log1 = new AuditLog("user-1", "CREATE_SKILL", "SKILL", 123L, null, "192.168.1.1", "", null); + AuditLog log2 = new AuditLog("user-2", "UPDATE_NAMESPACE", "NAMESPACE", 456L, null, "192.168.1.2", "", null); + org.springframework.test.util.ReflectionTestUtils.setField(log1, "id", 1L); + org.springframework.test.util.ReflectionTestUtils.setField(log2, "id", 2L); + org.springframework.test.util.ReflectionTestUtils.setField(log1, "createdAt", Instant.now()); + org.springframework.test.util.ReflectionTestUtils.setField(log2, "createdAt", Instant.now()); + given(auditLogQueryService.list(0, 20, null, null)) + .willReturn(new PageImpl<>(List.of(log1, log2), PageRequest.of(0, 20), 2)); + PlatformPrincipal principal = new PlatformPrincipal( "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") ); @@ -62,6 +80,8 @@ class AuditLogControllerTest { @Test void listAuditLogs_withSuperAdminRole_returns200() throws Exception { + given(auditLogQueryService.list(0, 20, null, null)) + .willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); PlatformPrincipal principal = new PlatformPrincipal( "user-99", "superadmin", "super@example.com", "", "github", Set.of("SUPER_ADMIN") ); @@ -76,6 +96,8 @@ class AuditLogControllerTest { @Test void listAuditLogs_withFilters_returns200() throws Exception { + given(auditLogQueryService.list(0, 20, "user-1", "CREATE_SKILL")) + .willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); PlatformPrincipal principal = new PlatformPrincipal( "user-50", "auditor", "auditor@example.com", "", "github", Set.of("AUDITOR") ); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java new file mode 100644 index 00000000..ef24b46b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillControllerDownloadTest.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import java.io.ByteArrayInputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillControllerDownloadTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillQueryService skillQueryService; + + @MockBean + private SkillDownloadService skillDownloadService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void downloadVersion_redirectsToPresignedUrlWhenAvailable() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + null, + "demo-skill-1.0.0.zip", + 128L, + "application/zip", + "https://download.example/presigned" + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://download.example/presigned")); + } + + @Test + void downloadVersion_streamsWhenPresignedUrlUnavailable() throws Exception { + given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, java.util.Map.of())) + .willReturn(new SkillDownloadService.DownloadResult( + new ByteArrayInputStream("zip".getBytes()), + "demo-skill-1.0.0.zip", + 3L, + "application/zip", + null + )); + + mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\"")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java new file mode 100644 index 00000000..606ae1ba --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillPublishControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillPublishService skillPublishService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private SkillHubMetrics skillHubMetrics; + + @Test + void publish_recordsMetricsAfterSuccess() throws Exception { + SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1"); + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + version.setFileCount(1); + version.setTotalSize(128L); + ReflectionTestUtils.setField(version, "id", 34L); + + given(skillPublishService.publishFromEntries(eq("global"), anyList(), eq("usr_1"), eq(SkillVisibility.PUBLIC))) + .willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version)); + + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "publisher", + "publisher@example.com", + "", + "local", + Set.of("SUPER_ADMIN") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "skill.zip", + "application/zip", + buildZipBytes() + ); + + mockMvc.perform(multipart("/api/v1/skills/global/publish") + .file(file) + .param("visibility", "PUBLIC") + .requestAttr("userId", "usr_1") + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.skillId").value(12)) + .andExpect(jsonPath("$.data.slug").value("demo-skill")); + + verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW"); + } + + private byte[] buildZipBytes() throws Exception { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + zip.putNextEntry(new ZipEntry("SKILL.md")); + zip.write(""" + --- + name: Demo Skill + version: 1.0.0 + --- + """.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.finish(); + return output.toByteArray(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java new file mode 100644 index 00000000..13c0be27 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class PrometheusEndpointTest { + + @Autowired + private SkillHubMetrics skillHubMetrics; + + @Autowired + private MeterRegistry meterRegistry; + + @Autowired + private Environment environment; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void prometheusEndpoint_exposesCustomMetrics() { + skillHubMetrics.incrementUserRegister(); + skillHubMetrics.recordLocalLogin(true); + skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); + + assertThat(environment.getProperty("management.endpoints.web.exposure.include")) + .contains("prometheus"); + assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.auth.login") + .tag("method", "local") + .tag("result", "success") + .counter() + .count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.skill.publish") + .tag("namespace", "global") + .tag("status", "PENDING_REVIEW") + .counter() + .count()).isEqualTo(1.0d); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 23b4114f..3736b468 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -22,6 +22,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @@ -88,6 +89,14 @@ public class SecurityConfig { .successHandler(successHandler) .failureHandler(failureHandler) ) + .headers(headers -> headers + .contentTypeOptions(contentTypeOptions -> {}) + .frameOptions(frameOptions -> frameOptions.deny()) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31536000)) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 9b5629dd..8fb654d2 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -62,7 +62,11 @@ public class ApiToken { void prePersist() { this.createdAt = LocalDateTime.now(); } public Long getId() { return id; } + public String getSubjectType() { return subjectType; } + public String getSubjectId() { return subjectId; } + public void setSubjectId(String subjectId) { this.subjectId = subjectId; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public String getName() { return name; } public String getTokenPrefix() { return tokenPrefix; } public String getTokenHash() { return tokenHash; } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java index 3f7807ff..843cbdd0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java @@ -33,5 +33,6 @@ public class UserRoleBinding { public Long getId() { return id; } public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } public Role getRole() { return role; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java index 790acae0..fe788497 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java @@ -67,6 +67,10 @@ public class LocalCredential { return userId; } + public void setUserId(String userId) { + this.userId = userId; + } + public String getUsername() { return username; } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java new file mode 100644 index 00000000..c1c96a19 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.auth.merge; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "account_merge_request") +public class AccountMergeRequest { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_COMPLETED = "COMPLETED"; + public static final String STATUS_CANCELLED = "CANCELLED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "primary_user_id", nullable = false, length = 128) + private String primaryUserId; + + @Column(name = "secondary_user_id", nullable = false, length = 128) + private String secondaryUserId; + + @Column(nullable = false, length = 32) + private String status = STATUS_PENDING; + + @Column(name = "verification_token", length = 255) + private String verificationToken; + + @Column(name = "token_expires_at") + private LocalDateTime tokenExpiresAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected AccountMergeRequest() {} + + public AccountMergeRequest(String primaryUserId, + String secondaryUserId, + String verificationToken, + LocalDateTime tokenExpiresAt) { + this.primaryUserId = primaryUserId; + this.secondaryUserId = secondaryUserId; + this.verificationToken = verificationToken; + this.tokenExpiresAt = tokenExpiresAt; + this.status = STATUS_PENDING; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public String getPrimaryUserId() { return primaryUserId; } + public String getSecondaryUserId() { return secondaryUserId; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getVerificationToken() { return verificationToken; } + public void setVerificationToken(String verificationToken) { this.verificationToken = verificationToken; } + public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; } + public void setTokenExpiresAt(LocalDateTime tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; } + public LocalDateTime getCompletedAt() { return completedAt; } + public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java new file mode 100644 index 00000000..8c98fb3c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequestRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.merge; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AccountMergeRequestRepository extends JpaRepository { + + Optional findByIdAndPrimaryUserId(Long id, String primaryUserId); + + boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java new file mode 100644 index 00000000..7a571bc5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -0,0 +1,258 @@ +package com.iflytek.skillhub.auth.merge; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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 java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountMergeService { + + private static final Comparator NAMESPACE_ROLE_ORDER = Comparator.comparingInt(role -> switch (role) { + case MEMBER -> 0; + case ADMIN -> 1; + case OWNER -> 2; + }); + + private final AccountMergeRequestRepository mergeRequestRepository; + private final UserAccountRepository userAccountRepository; + private final LocalCredentialRepository localCredentialRepository; + private final IdentityBindingRepository identityBindingRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + private final ApiTokenRepository apiTokenRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final PasswordEncoder passwordEncoder; + private final SecureRandom secureRandom = new SecureRandom(); + + public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + IdentityBindingRepository identityBindingRepository, + UserRoleBindingRepository userRoleBindingRepository, + ApiTokenRepository apiTokenRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { + this.mergeRequestRepository = mergeRequestRepository; + this.userAccountRepository = userAccountRepository; + this.localCredentialRepository = localCredentialRepository; + this.identityBindingRepository = identityBindingRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + this.apiTokenRepository = apiTokenRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.passwordEncoder = passwordEncoder; + } + + public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, LocalDateTime expiresAt) {} + + @Transactional + public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = resolveSecondaryUser(secondaryIdentifier); + validateMergePair(primaryUser, secondaryUser); + + if (mergeRequestRepository.existsBySecondaryUserIdAndStatus( + secondaryUser.getId(), + AccountMergeRequest.STATUS_PENDING + )) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.pendingExists"); + } + + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUser.getId()); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + + String rawToken = generateVerificationToken(); + AccountMergeRequest request = new AccountMergeRequest( + primaryUserId, + secondaryUser.getId(), + passwordEncoder.encode(rawToken), + LocalDateTime.now().plusMinutes(30) + ); + request = mergeRequestRepository.save(request); + return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); + } + + @Transactional + public void verifyAndComplete(String primaryUserId, Long mergeRequestId, String verificationToken) { + AccountMergeRequest request = mergeRequestRepository.findByIdAndPrimaryUserId(mergeRequestId, primaryUserId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.requestNotFound")); + if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); + } + if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(LocalDateTime.now())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); + } + if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { + throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.merge.invalidToken"); + } + + UserAccount primaryUser = loadActiveUser(primaryUserId); + UserAccount secondaryUser = userAccountRepository.findById(request.getSecondaryUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + validateMergePair(primaryUser, secondaryUser); + + migrateIdentityBindings(primaryUser.getId(), secondaryUser.getId()); + migrateApiTokens(primaryUser.getId(), secondaryUser.getId()); + migrateUserRoles(primaryUser.getId(), secondaryUser.getId()); + migrateNamespaceMemberships(primaryUser.getId(), secondaryUser.getId()); + migrateLocalCredential(primaryUser.getId(), secondaryUser.getId()); + + if ((primaryUser.getEmail() == null || primaryUser.getEmail().isBlank()) + && secondaryUser.getEmail() != null && !secondaryUser.getEmail().isBlank()) { + primaryUser.setEmail(secondaryUser.getEmail()); + } + userAccountRepository.save(primaryUser); + + secondaryUser.setStatus(UserStatus.MERGED); + secondaryUser.setMergedToUserId(primaryUser.getId()); + userAccountRepository.save(secondaryUser); + + request.setStatus(AccountMergeRequest.STATUS_COMPLETED); + request.setCompletedAt(LocalDateTime.now()); + request.setVerificationToken(null); + mergeRequestRepository.save(request); + } + + private UserAccount resolveSecondaryUser(String identifier) { + String normalized = identifier == null ? "" : identifier.trim(); + if (normalized.isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierRequired"); + } + if (normalized.contains(":")) { + String[] parts = normalized.split(":", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.identifierInvalid"); + } + IdentityBinding binding = identityBindingRepository.findByProviderCodeAndSubject(parts[0], parts[1]) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(binding.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + LocalCredential credential = localCredentialRepository.findByUsernameIgnoreCase(normalized.toLowerCase(Locale.ROOT)) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + return userAccountRepository.findById(credential.getUserId()) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.secondaryNotFound")); + } + + private UserAccount loadActiveUser(String userId) { + UserAccount user = userAccountRepository.findById(userId) + .orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.auth.merge.primaryNotFound")); + if (user.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.primaryNotActive"); + } + return user; + } + + private void validateMergePair(UserAccount primaryUser, UserAccount secondaryUser) { + if (primaryUser.getId().equals(secondaryUser.getId())) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.sameAccount"); + } + if (secondaryUser.getStatus() != UserStatus.ACTIVE) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.secondaryNotActive"); + } + } + + private void migrateIdentityBindings(String primaryUserId, String secondaryUserId) { + List bindings = identityBindingRepository.findByUserId(secondaryUserId); + for (IdentityBinding binding : bindings) { + binding.setUserId(primaryUserId); + } + identityBindingRepository.saveAll(bindings); + } + + private void migrateApiTokens(String primaryUserId, String secondaryUserId) { + List tokens = apiTokenRepository.findByUserId(secondaryUserId); + for (ApiToken token : tokens) { + token.setUserId(primaryUserId); + if ("USER".equals(token.getSubjectType())) { + token.setSubjectId(primaryUserId); + } + } + apiTokenRepository.saveAll(tokens); + } + + private void migrateUserRoles(String primaryUserId, String secondaryUserId) { + Set primaryRoleCodes = new HashSet<>(); + for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) { + primaryRoleCodes.add(binding.getRole().getCode()); + } + + List secondaryBindings = userRoleBindingRepository.findByUserId(secondaryUserId); + for (UserRoleBinding binding : secondaryBindings) { + Role role = binding.getRole(); + if (!primaryRoleCodes.contains(role.getCode())) { + userRoleBindingRepository.save(new UserRoleBinding(primaryUserId, role)); + primaryRoleCodes.add(role.getCode()); + } + } + userRoleBindingRepository.deleteAll(secondaryBindings); + } + + private void migrateNamespaceMemberships(String primaryUserId, String secondaryUserId) { + List secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId); + for (NamespaceMember secondaryMembership : secondaryMemberships) { + Optional existingPrimaryMembership = namespaceMemberRepository + .findByNamespaceIdAndUserId(secondaryMembership.getNamespaceId(), primaryUserId); + if (existingPrimaryMembership.isPresent()) { + NamespaceMember primaryMembership = existingPrimaryMembership.get(); + if (NAMESPACE_ROLE_ORDER.compare(secondaryMembership.getRole(), primaryMembership.getRole()) > 0) { + primaryMembership.setRole(secondaryMembership.getRole()); + namespaceMemberRepository.save(primaryMembership); + } + namespaceMemberRepository.deleteByNamespaceIdAndUserId( + secondaryMembership.getNamespaceId(), + secondaryUserId + ); + } else { + secondaryMembership.setUserId(primaryUserId); + namespaceMemberRepository.save(secondaryMembership); + } + } + } + + private void migrateLocalCredential(String primaryUserId, String secondaryUserId) { + Optional primaryCredential = localCredentialRepository.findByUserId(primaryUserId); + Optional secondaryCredential = localCredentialRepository.findByUserId(secondaryUserId); + if (primaryCredential.isPresent() && secondaryCredential.isPresent()) { + throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.merge.localCredentialConflict"); + } + secondaryCredential.ifPresent(credential -> { + credential.setUserId(primaryUserId); + localCredentialRepository.save(credential); + }); + } + + private String generateVerificationToken() { + byte[] tokenBytes = new byte[24]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index dd9afb7f..cc77c92a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -9,5 +9,6 @@ import java.util.Optional; @Repository public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); + List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java index efad961f..12f48f8f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -8,4 +8,5 @@ import java.util.Optional; @Repository public interface IdentityBindingRepository extends JpaRepository { Optional findByProviderCodeAndSubject(String providerCode, String subject); + java.util.List findByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java new file mode 100644 index 00000000..cd1ecbce --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -0,0 +1,143 @@ +package com.iflytek.skillhub.auth.merge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class AccountMergeServiceTest { + + @Mock + private AccountMergeRequestRepository mergeRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; + @Mock + private LocalCredentialRepository localCredentialRepository; + @Mock + private IdentityBindingRepository identityBindingRepository; + @Mock + private UserRoleBindingRepository userRoleBindingRepository; + @Mock + private ApiTokenRepository apiTokenRepository; + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private PasswordEncoder passwordEncoder; + + private AccountMergeService service; + + @BeforeEach + void setUp() { + service = new AccountMergeService( + mergeRequestRepository, + userAccountRepository, + localCredentialRepository, + identityBindingRepository, + userRoleBindingRepository, + apiTokenRepository, + namespaceMemberRepository, + passwordEncoder + ); + } + + @Test + void initiate_withLocalUsername_createsPendingRequest() { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "secondary@example.com", null); + LocalCredential secondaryCredential = new LocalCredential("usr_secondary", "secondary", "hash"); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(localCredentialRepository.findByUsernameIgnoreCase("secondary")).willReturn(Optional.of(secondaryCredential)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(mergeRequestRepository.existsBySecondaryUserIdAndStatus("usr_secondary", AccountMergeRequest.STATUS_PENDING)) + .willReturn(false); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.of(secondaryCredential)); + given(passwordEncoder.encode(any())).willReturn("encoded-token"); + given(mergeRequestRepository.save(any(AccountMergeRequest.class))).willAnswer(invocation -> invocation.getArgument(0)); + + var result = service.initiate("usr_primary", "secondary"); + + assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); + assertThat(result.verificationToken()).isNotBlank(); + verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); + } + + @Test + void verifyAndComplete_migratesBindingsRolesTokensAndMemberships() { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + UserAccount secondary = new UserAccount("usr_secondary", "secondary", "", null); + AccountMergeRequest request = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); + Role role = mock(Role.class); + given(role.getCode()).willReturn("AUDITOR"); + UserRoleBinding secondaryRole = new UserRoleBinding("usr_secondary", role); + IdentityBinding binding = new IdentityBinding("usr_secondary", "github", "gh_123", "secondary"); + ApiToken token = new ApiToken("usr_secondary", "cli", "sk_123", "hash", "[]"); + NamespaceMember secondaryMembership = new NamespaceMember(1L, "usr_secondary", NamespaceRole.ADMIN); + + given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + given(userAccountRepository.findById("usr_primary")).willReturn(Optional.of(primary)); + given(userAccountRepository.findById("usr_secondary")).willReturn(Optional.of(secondary)); + given(passwordEncoder.matches("raw-token", "encoded")).willReturn(true); + given(identityBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(binding)); + given(apiTokenRepository.findByUserId("usr_secondary")).willReturn(List.of(token)); + given(userRoleBindingRepository.findByUserId("usr_primary")).willReturn(List.of()); + given(userRoleBindingRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryRole)); + given(namespaceMemberRepository.findByUserId("usr_secondary")).willReturn(List.of(secondaryMembership)); + given(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_primary")).willReturn(Optional.empty()); + given(localCredentialRepository.findByUserId("usr_secondary")).willReturn(Optional.empty()); + + service.verifyAndComplete("usr_primary", request.getId(), "raw-token"); + + assertThat(binding.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getUserId()).isEqualTo("usr_primary"); + assertThat(token.getSubjectId()).isEqualTo("usr_primary"); + assertThat(secondaryMembership.getUserId()).isEqualTo("usr_primary"); + assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); + assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); + verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); + verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); + } + + @Test + void verifyAndComplete_rejectsInvalidToken() { + UserAccount primary = new UserAccount("usr_primary", "primary", "primary@example.com", null); + AccountMergeRequest request = new AccountMergeRequest("usr_primary", "usr_secondary", "encoded", java.time.LocalDateTime.now().plusMinutes(10)); + given(mergeRequestRepository.findByIdAndPrimaryUserId(request.getId(), "usr_primary")).willReturn(Optional.of(request)); + given(passwordEncoder.matches("bad-token", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.verifyAndComplete("usr_primary", request.getId(), "bad-token")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.merge.invalidToken"); + + verify(identityBindingRepository, never()).saveAll(any()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java new file mode 100644 index 00000000..61352599 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.domain.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "audit_log") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "actor_user_id", length = 128) + private String actorUserId; + + @Column(nullable = false, length = 64) + private String action; + + @Column(name = "target_type", length = 64) + private String targetType; + + @Column(name = "target_id") + private Long targetId; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "client_ip", length = 64) + private String clientIp; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "detail_json", columnDefinition = "jsonb") + private String detailJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected AuditLog() {} + + public AuditLog(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson) { + this.actorUserId = actorUserId; + this.action = action; + this.targetType = targetType; + this.targetId = targetId; + this.requestId = requestId; + this.clientIp = clientIp; + this.userAgent = userAgent; + this.detailJson = detailJson; + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(); + } + + public Long getId() { return id; } + public String getActorUserId() { return actorUserId; } + public String getAction() { return action; } + public String getTargetType() { return targetType; } + public Long getTargetId() { return targetId; } + public String getRequestId() { return requestId; } + public String getClientIp() { return clientIp; } + public String getUserAgent() { return userAgent; } + public String getDetailJson() { return detailJson; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java new file mode 100644 index 00000000..b2f74ef0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogQueryService.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class AuditLogQueryService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogQueryService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + public Page list(int page, int size, String actorUserId, String action) { + return auditLogRepository.search(actorUserId, action, PageRequest.of(page, size)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java new file mode 100644 index 00000000..bd967a41 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AuditLogRepository { + AuditLog save(AuditLog auditLog); + Page search(String actorUserId, String action, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java new file mode 100644 index 00000000..2fb97e6f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.domain.audit; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + @Transactional + public AuditLog record(String actorUserId, + String action, + String targetType, + Long targetId, + String requestId, + String clientIp, + String userAgent, + String detailJson) { + return auditLogRepository.save(new AuditLog( + actorUserId, + action, + targetType, + targetId, + requestId, + clientIp, + userAgent, + detailJson + )); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 32598bf3..3b5c2570 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -44,6 +44,15 @@ public class Skill { @Column(name = "download_count", nullable = false) private Long downloadCount = 0L; + @Column(nullable = false) + private boolean hidden = false; + + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + + @Column(name = "hidden_by", length = 128) + private String hiddenBy; + @Column(name = "star_count", nullable = false) private Integer starCount = 0; @@ -132,6 +141,18 @@ public class Skill { return downloadCount; } + public boolean isHidden() { + return hidden; + } + + public LocalDateTime getHiddenAt() { + return hiddenAt; + } + + public String getHiddenBy() { + return hiddenBy; + } + public Integer getStarCount() { return starCount; } @@ -192,4 +213,16 @@ public class Skill { public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; } + + public void setHidden(boolean hidden) { + this.hidden = hidden; + } + + public void setHiddenAt(LocalDateTime hiddenAt) { + this.hiddenAt = hiddenAt; + } + + public void setHiddenBy(String hiddenBy) { + this.hiddenBy = hiddenBy; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java index a05c425f..dddbc29e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java @@ -43,6 +43,15 @@ public class SkillVersion { @Column(name = "published_at") private LocalDateTime publishedAt; + @Column(name = "yanked_at") + private LocalDateTime yankedAt; + + @Column(name = "yanked_by", length = 128) + private String yankedBy; + + @Column(name = "yank_reason", columnDefinition = "TEXT") + private String yankReason; + @Column(name = "created_by", nullable = false) private String createdBy; @@ -105,6 +114,18 @@ public class SkillVersion { return publishedAt; } + public LocalDateTime getYankedAt() { + return yankedAt; + } + + public String getYankedBy() { + return yankedBy; + } + + public String getYankReason() { + return yankReason; + } + public String getCreatedBy() { return createdBy; } @@ -141,4 +162,16 @@ public class SkillVersion { public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; } + + public void setYankedAt(LocalDateTime yankedAt) { + this.yankedAt = yankedAt; + } + + public void setYankedBy(String yankedBy) { + this.yankedBy = yankedBy; + } + + public void setYankReason(String yankReason) { + this.yankReason = yankReason; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 57022259..9cd17cda 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,5 +4,6 @@ public enum SkillVersionStatus { DRAFT, PENDING_REVIEW, PUBLISHED, - REJECTED + REJECTED, + YANKED } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 31ee1a53..2b4fef49 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -13,6 +13,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.time.Duration; import java.util.Map; @Service @@ -47,7 +48,8 @@ public class SkillDownloadService { InputStream content, String filename, long contentLength, - String contentType + String contentType, + String presignedUrl ) {} public DownloadResult downloadLatest( @@ -137,14 +139,15 @@ public class SkillDownloadService { } ObjectMetadata metadata = objectStorageService.getMetadata(storageKey); - InputStream content = objectStorageService.getObject(storageKey); + String presignedUrl = objectStorageService.generatePresignedUrl(storageKey, Duration.ofMinutes(10)); + InputStream content = presignedUrl == null ? objectStorageService.getObject(storageKey) : null; // Publish download event eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); String filename = String.format("%s-%s.zip", skill.getSlug(), version.getVersion()); - return new DownloadResult(content, filename, metadata.size(), metadata.contentType()); + return new DownloadResult(content, filename, metadata.size(), metadata.contentType(), presignedUrl); } private Namespace findNamespace(String slug) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java new file mode 100644 index 00000000..6f09cb32 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +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.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import java.time.LocalDateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillGovernanceService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final AuditLogService auditLogService; + + public SkillGovernanceService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + AuditLogService auditLogService) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.auditLogService = auditLogService; + } + + @Transactional + public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String userAgent, String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(true); + skill.setHiddenAt(LocalDateTime.now()); + skill.setHiddenBy(actorUserId); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + @Transactional + public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + skill.setHidden(false); + skill.setHiddenAt(null); + skill.setHiddenBy(null); + skill.setUpdatedBy(actorUserId); + Skill saved = skillRepository.save(skill); + auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + return saved; + } + + @Transactional + public SkillVersion yankVersion(Long versionId, String actorUserId, String clientIp, String userAgent, String reason) { + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.version.notFound", versionId)); + version.setStatus(SkillVersionStatus.YANKED); + version.setYankedAt(LocalDateTime.now()); + version.setYankedBy(actorUserId); + version.setYankReason(reason); + SkillVersion saved = skillVersionRepository.save(version); + auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + return saved; + } + + private String jsonReason(String reason) { + if (reason == null || reason.isBlank()) { + return null; + } + return "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}"; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java new file mode 100644 index 00000000..9df6b643 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.domain.skill.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.skill.Skill; +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 java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SkillGovernanceServiceTest { + + @Mock + private SkillRepository skillRepository; + @Mock + private SkillVersionRepository skillVersionRepository; + @Mock + private AuditLogService auditLogService; + + private SkillGovernanceService service; + + @BeforeEach + void setUp() { + service = new SkillGovernanceService(skillRepository, skillVersionRepository, auditLogService); + } + + @Test + void hideSkill_marksSkillHidden() { + Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); + given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); + given(skillRepository.save(skill)).willReturn(skill); + + Skill result = service.hideSkill(10L, "admin", "127.0.0.1", "JUnit", "policy"); + + assertThat(result.isHidden()).isTrue(); + assertThat(result.getHiddenBy()).isEqualTo("admin"); + verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}"); + } + + @Test + void yankVersion_setsYankedStatus() { + SkillVersion version = new SkillVersion(2L, "1.0.0", "owner"); + version.setStatus(SkillVersionStatus.PUBLISHED); + given(skillVersionRepository.findById(22L)).willReturn(Optional.of(version)); + given(skillVersionRepository.save(version)).willReturn(version); + + SkillVersion result = service.yankVersion(22L, "admin", "127.0.0.1", "JUnit", "broken"); + + assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); + assertThat(result.getYankedBy()).isEqualTo("admin"); + verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java new file mode 100644 index 00000000..8d6b4606 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/AuditLogJpaRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuditLogJpaRepository extends JpaRepository, JpaSpecificationExecutor, AuditLogRepository { + + @Override + default Page search(String actorUserId, String action, Pageable pageable) { + Specification specification = Specification.where(null); + if (actorUserId != null && !actorUserId.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("actorUserId"), actorUserId)); + } + if (action != null && !action.isBlank()) { + specification = specification.and((root, query, cb) -> cb.equal(root.get("action"), action)); + } + return findAll(specification, pageable); + } +} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index 5fd9ee8d..0c3f3e7f 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Service; import java.io.*; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; -import java.time.Instant; +import java.time.Duration; import java.util.List; @Service @@ -58,5 +58,10 @@ public class LocalFileStorageService implements ObjectStorageService { } catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); } } + @Override + public String generatePresignedUrl(String key, Duration expiry) { + return null; + } + private Path resolve(String key) { return basePath.resolve(key); } } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java index d5bb0b24..e154facf 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.storage; import java.io.InputStream; +import java.time.Duration; import java.util.List; public interface ObjectStorageService { @@ -10,4 +11,5 @@ public interface ObjectStorageService { void deleteObjects(List keys); boolean exists(String key); ObjectMetadata getMetadata(String key); + String generatePresignedUrl(String key, Duration expiry); } diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index 433ef50e..ebca94f7 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -11,9 +11,13 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import java.io.InputStream; import java.net.URI; +import java.time.Duration; import java.util.List; @Service @@ -22,6 +26,7 @@ public class S3StorageService implements ObjectStorageService { private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); private final S3StorageProperties properties; private S3Client s3Client; + private S3Presigner s3Presigner; public S3StorageService(S3StorageProperties properties) { this.properties = properties; } @@ -36,6 +41,14 @@ public class S3StorageService implements ObjectStorageService { builder.endpointOverride(URI.create(properties.getEndpoint())); } this.s3Client = builder.build(); + var presignerBuilder = S3Presigner.builder() + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))); + if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { + presignerBuilder.endpointOverride(URI.create(properties.getEndpoint())); + } + this.s3Presigner = presignerBuilder.build(); ensureBucketExists(); } @@ -74,4 +87,18 @@ public class S3StorageService implements ObjectStorageService { HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified()); } + + @Override + public String generatePresignedUrl(String key, Duration expiry) { + PresignedGetObjectRequest request = s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(expiry) + .getObjectRequest(GetObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build()) + .build() + ); + return request.url().toString(); + } } diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java index dfaf2b72..088845ce 100644 --- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java @@ -1,64 +1,24 @@ package com.iflytek.skillhub.storage; -import org.junit.jupiter.api.BeforeEach; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - class LocalFileStorageServiceTest { - @TempDir Path tempDir; - private LocalFileStorageService storageService; - @BeforeEach - void setUp() { - StorageProperties props = new StorageProperties(); - props.getLocal().setBasePath(tempDir.toString()); - storageService = new LocalFileStorageService(props); - } + @TempDir + java.nio.file.Path tempDir; - @Test void shouldPutAndGetObject() throws Exception { - String key = "skills/1/1/SKILL.md"; - byte[] content = "# Hello".getBytes(StandardCharsets.UTF_8); - storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/markdown"); - try (InputStream result = storageService.getObject(key)) { assertArrayEquals(content, result.readAllBytes()); } - } + @Test + void generatePresignedUrl_returnsNullForLocalStorage() throws Exception { + StorageProperties properties = new StorageProperties(); + properties.getLocal().setBasePath(tempDir.toString()); + Files.createDirectories(tempDir); - @Test void shouldCheckExistence() { - assertFalse(storageService.exists("test/exists.txt")); - byte[] content = "data".getBytes(StandardCharsets.UTF_8); - storageService.putObject("test/exists.txt", new ByteArrayInputStream(content), content.length, "text/plain"); - assertTrue(storageService.exists("test/exists.txt")); - } + LocalFileStorageService service = new LocalFileStorageService(properties); - @Test void shouldDeleteObject() { - byte[] content = "data".getBytes(StandardCharsets.UTF_8); - storageService.putObject("test/delete.txt", new ByteArrayInputStream(content), content.length, "text/plain"); - assertTrue(storageService.exists("test/delete.txt")); - storageService.deleteObject("test/delete.txt"); - assertFalse(storageService.exists("test/delete.txt")); - } - - @Test void shouldDeleteMultipleObjects() { - byte[] content = "data".getBytes(StandardCharsets.UTF_8); - storageService.putObject("a/1.txt", new ByteArrayInputStream(content), content.length, "text/plain"); - storageService.putObject("a/2.txt", new ByteArrayInputStream(content), content.length, "text/plain"); - storageService.deleteObjects(List.of("a/1.txt", "a/2.txt")); - assertFalse(storageService.exists("a/1.txt")); - assertFalse(storageService.exists("a/2.txt")); - } - - @Test void shouldGetMetadata() { - byte[] content = "hello world".getBytes(StandardCharsets.UTF_8); - storageService.putObject("test/meta.txt", new ByteArrayInputStream(content), content.length, "text/plain"); - ObjectMetadata metadata = storageService.getMetadata("test/meta.txt"); - assertEquals(content.length, metadata.size()); - assertNotNull(metadata.lastModified()); + assertThat(service.generatePresignedUrl("packages/demo.zip", java.time.Duration.ofMinutes(10))).isNull(); } } diff --git a/web/package.json b/web/package.json index 78a40f98..0409ff87 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,8 @@ "react-dropzone": "^15.0.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^2.2.1", "zustand": "^5.0.11" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index dfa0bdf0..a74658e0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -41,6 +41,12 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^2.2.1 version: 2.6.1 @@ -910,6 +916,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-react-hooks@4.6.2: resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} engines: {node: '>=10'} @@ -1061,6 +1071,9 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -1230,9 +1243,33 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -1261,6 +1298,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -1547,12 +1605,21 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2616,6 +2683,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -2796,6 +2865,12 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -2954,6 +3029,15 @@ snapshots: dependencies: react: 19.2.4 + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -2971,6 +3055,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -3064,6 +3205,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -3397,6 +3596,22 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -3414,6 +3629,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 19c3cb8a..0fafbc7f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,11 +1,15 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' import type { + ChangePasswordRequest, ApiToken, CreateTokenRequest, CreateTokenResponse, LocalLoginRequest, LocalRegisterRequest, + MergeInitiateRequest, + MergeInitiateResponse, + MergeVerifyRequest, OAuthProvider, User, } from './types' @@ -145,6 +149,16 @@ export const authApi = { }) }, + async changePassword(request: ChangePasswordRequest): Promise { + await fetchJson('/api/v1/auth/local/change-password', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const { response, error } = await client.POST('/api/v1/auth/logout', { headers: withCsrf(), @@ -155,6 +169,28 @@ export const authApi = { }, } +export const accountApi = { + async initiateMerge(request: MergeInitiateRequest): Promise { + return fetchJson('/api/v1/account/merge/initiate', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async verifyMerge(request: MergeVerifyRequest): Promise { + await fetchJson('/api/v1/account/merge/verify', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, +} + export const tokenApi = { async getTokens(): Promise { return unwrap(client.GET('/api/v1/tokens') as never) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bd12c031..9929ee2c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,6 +15,27 @@ export interface LocalRegisterRequest extends LocalLoginRequest { email?: string } +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + +export interface MergeInitiateRequest { + secondaryIdentifier: string +} + +export interface MergeInitiateResponse { + mergeRequestId: number + secondaryUserId: string + verificationToken: string + expiresAt: string +} + +export interface MergeVerifyRequest { + mergeRequestId: number + verificationToken: string +} + // Namespace types export interface Namespace { id: number diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index ac516d8a..8418a4cb 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import { Outlet, Link } from '@tanstack/react-router' import { useAuth } from '@/features/auth/use-auth' @@ -32,11 +33,26 @@ export function Layout() { > Dashboard + + 安全设置 + + + 账号合并 + {user.avatarUrl && ( )} @@ -59,7 +75,17 @@ export function Layout() { - + + + + + + } + > + + {/* Footer */} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 87b2b1a8..f3080cb6 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -1,23 +1,39 @@ +import { lazy, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' import { Layout } from './layout' -import { HomePage } from '@/pages/home' -import { LoginPage } from '@/pages/login' -import { RegisterPage } from '@/pages/register' -import { DashboardPage } from '@/pages/dashboard' -import { SearchPage } from '@/pages/search' -import { NamespacePage } from '@/pages/namespace' -import { SkillDetailPage } from '@/pages/skill-detail' -import { PublishPage } from '@/pages/dashboard/publish' -import { MySkillsPage } from '@/pages/dashboard/my-skills' -import { MyNamespacesPage } from '@/pages/dashboard/my-namespaces' -import { NamespaceMembersPage } from '@/pages/dashboard/namespace-members' -import { ReviewsPage } from '@/pages/dashboard/reviews' -import { ReviewDetailPage } from '@/pages/dashboard/review-detail' -import { DeviceAuthPage } from '@/pages/device' -import { AdminUsersPage } from '@/pages/admin/users' -import { AuditLogPage } from '@/pages/admin/audit-log' import { getCurrentUser } from '@/api/client' +function lazyRouteComponent>( + importer: () => Promise, + exportName: keyof TModule, +) { + const LazyComponent = lazy(async () => { + const module = await importer() + return { default: module[exportName] as ComponentType } + }) + + return LazyComponent +} + +const HomePage = lazyRouteComponent(() => import('@/pages/home'), 'HomePage') +const LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage') +const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const SearchPage = lazyRouteComponent(() => import('@/pages/search'), 'SearchPage') +const NamespacePage = lazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage') +const SkillDetailPage = lazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage') +const DashboardPage = lazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage') +const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage') +const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage') +const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage') +const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage') +const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') +const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') +const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage') +const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage') +const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage') +const AdminUsersPage = lazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage') +const AuditLogPage = lazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage') + const rootRoute = createRootRoute({ component: Layout, }) @@ -162,6 +178,32 @@ const deviceRoute = createRoute({ component: DeviceAuthPage, }) +const settingsSecurityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/security', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: SecuritySettingsPage, +}) + +const settingsAccountsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/accounts', + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: AccountSettingsPage, +}) + const adminUsersRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin/users', @@ -209,6 +251,8 @@ const routeTree = rootRoute.addChildren([ dashboardReviewsRoute, dashboardReviewDetailRoute, deviceRoute, + settingsSecurityRoute, + settingsAccountsRoute, adminUsersRoute, adminAuditLogRoute, ]) diff --git a/web/src/features/auth/use-account-merge.ts b/web/src/features/auth/use-account-merge.ts new file mode 100644 index 00000000..a96b3952 --- /dev/null +++ b/web/src/features/auth/use-account-merge.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query' +import { accountApi } from '@/api/client' +import type { MergeInitiateRequest, MergeVerifyRequest } from '@/api/types' + +export function useInitiateAccountMerge() { + return useMutation({ + mutationFn: (request: MergeInitiateRequest) => accountApi.initiateMerge(request), + }) +} + +export function useVerifyAccountMerge() { + return useMutation({ + mutationFn: (request: MergeVerifyRequest) => accountApi.verifyMerge(request), + }) +} diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 7480c2bb..865ec220 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,5 +1,7 @@ import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' interface MarkdownRendererProps { content: string @@ -10,7 +12,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) return ( , diff --git a/web/src/pages/settings/accounts.tsx b/web/src/pages/settings/accounts.tsx new file mode 100644 index 00000000..d143fa32 --- /dev/null +++ b/web/src/pages/settings/accounts.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { useInitiateAccountMerge, useVerifyAccountMerge } from '@/features/auth/use-account-merge' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +export function AccountSettingsPage() { + const [secondaryIdentifier, setSecondaryIdentifier] = useState('') + const [mergeRequestId, setMergeRequestId] = useState('') + const [verificationToken, setVerificationToken] = useState('') + const [statusMessage, setStatusMessage] = useState('') + + const initiateMutation = useInitiateAccountMerge() + const verifyMutation = useVerifyAccountMerge() + + async function handleInitiate(event: React.FormEvent) { + event.preventDefault() + setStatusMessage('') + try { + const result = await initiateMutation.mutateAsync({ secondaryIdentifier }) + setMergeRequestId(String(result.mergeRequestId)) + setVerificationToken(result.verificationToken) + setStatusMessage(`已创建合并请求,secondary=${result.secondaryUserId}`) + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : '发起合并失败') + } + } + + async function handleVerify(event: React.FormEvent) { + event.preventDefault() + setStatusMessage('') + try { + await verifyMutation.mutateAsync({ + mergeRequestId: Number(mergeRequestId), + verificationToken, + }) + setStatusMessage('账号合并已完成') + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : '验证合并失败') + } + } + + return ( + + + + 发起账号合并 + 输入 secondary 账号标识。支持本地用户名,或 `provider:subject` 格式的外部身份。 + + + + + Secondary 标识 + setSecondaryIdentifier(event.target.value)} + placeholder="例如:other_user 或 github:123456" + /> + + + {initiateMutation.isPending ? '发起中...' : '发起合并'} + + + + + + + + 验证并完成合并 + 发起后会返回一次性 token;在当前阶段直接复制该 token 完成验证。 + + + + + Merge Request ID + setMergeRequestId(event.target.value)} + /> + + + Verification Token + setVerificationToken(event.target.value)} + /> + + + {verifyMutation.isPending ? '验证中...' : '完成合并'} + + + {statusMessage ? {statusMessage} : null} + + + + ) +} diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx new file mode 100644 index 00000000..b7267280 --- /dev/null +++ b/web/src/pages/settings/security.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { authApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +export function SecuritySettingsPage() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [statusMessage, setStatusMessage] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setStatusMessage('') + setErrorMessage('') + setIsSubmitting(true) + try { + await authApi.changePassword({ currentPassword, newPassword }) + setStatusMessage('密码修改成功') + setCurrentPassword('') + setNewPassword('') + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : '修改密码失败') + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + 安全设置 + 已启用本地账号密码登录时,可以在这里更新密码。 + + + + + 当前密码 + setCurrentPassword(event.target.value)} + /> + + + 新密码 + setNewPassword(event.target.value)} + /> + + {statusMessage ? {statusMessage} : null} + {errorMessage ? {errorMessage} : null} + + {isSubmitting ? '提交中...' : '更新密码'} + + + + + + ) +}
{statusMessage}
{errorMessage}