merge: bring phase4 worktree implementation into feature/project-init

# Conflicts:
#	server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java
#	server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java
#	server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java
#	web/src/app/router.tsx
This commit is contained in:
vsxd 2026-03-13 10:35:42 +08:00
commit 0ca38e73ba
86 changed files with 3687 additions and 105 deletions

View file

@ -122,6 +122,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
```

View file

@ -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

19
deploy/k8s/configmap.yaml Normal file
View file

@ -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

View file

@ -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

26
deploy/k8s/ingress.yaml Normal file
View file

@ -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

View file

@ -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

27
deploy/k8s/services.yaml Normal file
View file

@ -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

View file

@ -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

10
monitoring/prometheus.yml Normal file
View file

@ -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

48
scripts/smoke-test.sh Executable file
View file

@ -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

View file

@ -22,6 +22,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

View file

@ -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<MergeInitiateResponse> 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<MessageResponse> 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"));
}
}

View file

@ -0,0 +1,87 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
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;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
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/auth/local")
public class LocalAuthController extends BaseApiController {
private final LocalAuthService localAuthService;
private final SkillHubMetrics skillHubMetrics;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
}
@PostMapping("/register")
public ApiResponse<AuthMeResponse> 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));
}
@PostMapping("/login")
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
HttpServletRequest httpRequest) {
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));
}
@PostMapping("/change-password")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal PlatformPrincipal principal,
@Valid @RequestBody ChangePasswordRequest request) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
localAuthService.changePassword(principal.userId(), request.currentPassword(), request.newPassword());
return ok("response.success.updated", null);
}
private void establishSession(PlatformPrincipal principal, HttpServletRequest request) {
var authorities = principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
var authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute("platformPrincipal", principal);
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
}

View file

@ -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<AdminSkillMutationResponse> 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<AdminSkillMutationResponse> 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<AdminSkillMutationResponse> 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()));
}
}

View file

@ -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<AuditLogItemResponse> 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));
}
}

View file

@ -8,6 +8,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;
@ -21,13 +22,16 @@ public class CliPublishController extends BaseApiController {
private final SkillPublishService skillPublishService;
private final ZipPackageExtractor zipPackageExtractor;
private final SkillHubMetrics skillHubMetrics;
public CliPublishController(SkillPublishService skillPublishService,
ZipPackageExtractor zipPackageExtractor,
ApiResponseFactory responseFactory) {
ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics) {
super(responseFactory);
this.skillPublishService = skillPublishService;
this.zipPackageExtractor = zipPackageExtractor;
this.skillHubMetrics = skillHubMetrics;
}
@PostMapping("/publish")
@ -58,6 +62,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);
}

View file

@ -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.*;
@ -278,11 +279,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")
@ -297,11 +294,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")
@ -316,6 +309,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<InputStreamResource> 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()))

View file

@ -8,6 +8,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;
@ -21,13 +22,16 @@ public class SkillPublishController extends BaseApiController {
private final SkillPublishService skillPublishService;
private final ZipPackageExtractor zipPackageExtractor;
private final SkillHubMetrics skillHubMetrics;
public SkillPublishController(SkillPublishService skillPublishService,
ZipPackageExtractor zipPackageExtractor,
ApiResponseFactory responseFactory) {
ApiResponseFactory responseFactory,
SkillHubMetrics skillHubMetrics) {
super(responseFactory);
this.skillPublishService = skillPublishService;
this.zipPackageExtractor = zipPackageExtractor;
this.skillHubMetrics = skillHubMetrics;
}
@PostMapping("/{namespace}/publish")
@ -58,6 +62,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);
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record AdminSkillActionRequest(String reason) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record AdminSkillMutationResponse(
Long skillId,
Long versionId,
String action,
String status
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record ChangePasswordRequest(
@NotBlank(message = "当前密码不能为空")
String currentPassword,
@NotBlank(message = "新密码不能为空")
String newPassword
) {}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record LocalLoginRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password
) {}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LocalRegisterRequest(
@NotBlank(message = "用户名不能为空")
String username,
@NotBlank(message = "密码不能为空")
String password,
@Email(message = "邮箱格式不正确")
String email
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
public record MergeInitiateRequest(
@NotBlank(message = "待合并账号标识不能为空")
String secondaryIdentifier
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record MergeInitiateResponse(
Long mergeRequestId,
String secondaryUserId,
String verificationToken,
String expiresAt
) {}

View file

@ -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
) {}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
@ -32,6 +33,13 @@ public class GlobalExceptionHandler {
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(AuthFlowException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
HttpStatus status = ex.getStatus();
return ResponseEntity.status(status).body(
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
}
@ExceptionHandler(DomainBadRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
return ResponseEntity.badRequest().body(

View file

@ -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();
}
}

View file

@ -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:
@ -69,10 +76,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

View file

@ -0,0 +1,32 @@
CREATE TABLE local_credential (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_local_credential_username ON local_credential (username);
CREATE UNIQUE INDEX idx_local_credential_user_id ON local_credential (user_id);
CREATE TABLE account_merge_request (
id BIGSERIAL PRIMARY KEY,
primary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
secondary_user_id VARCHAR(128) NOT NULL REFERENCES user_account(id),
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
verification_token VARCHAR(255),
token_expires_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_merge_primary_status ON account_merge_request (primary_user_id, status);
CREATE UNIQUE INDEX idx_merge_secondary_pending
ON account_merge_request (secondary_user_id)
WHERE status = 'PENDING';
CREATE INDEX idx_merge_token_pending
ON account_merge_request (verification_token)
WHERE status = 'PENDING';

View file

@ -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);

View file

@ -77,3 +77,28 @@ error.deviceAuth.userCode.invalid=Invalid or expired user code
error.deviceAuth.deviceCode.expired=Device code expired
error.deviceAuth.deviceCode.invalid=Device code expired or invalid
error.deviceAuth.deviceCode.used=Device code has already been used
error.auth.local.username.invalid=Username must be 3-64 characters and contain only letters, numbers, or underscores
error.auth.local.username.exists=Username already exists
error.auth.local.email.exists=Email already exists
error.auth.local.invalidCredentials=Invalid username or password
error.auth.local.accountDisabled=Account has been disabled
error.auth.local.accountPending=Account is pending approval
error.auth.local.accountMerged=Account has been merged into another account
error.auth.local.locked=Account is locked. Try again in {0} minute(s)
error.auth.local.notEnabled=Password login is not enabled for this account
error.auth.local.password.tooShort=Password must be at least 8 characters
error.auth.local.password.tooLong=Password must not exceed 128 characters
error.auth.local.password.tooWeak=Password must contain at least three character types
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

View file

@ -77,3 +77,28 @@ error.deviceAuth.userCode.invalid=无效或已过期的用户验证码
error.deviceAuth.deviceCode.expired=设备验证码已过期
error.deviceAuth.deviceCode.invalid=设备验证码无效或已过期
error.deviceAuth.deviceCode.used=设备验证码已被使用
error.auth.local.username.invalid=用户名长度必须为 3 到 64 个字符,且只能包含字母、数字或下划线
error.auth.local.username.exists=用户名已存在
error.auth.local.email.exists=邮箱已存在
error.auth.local.invalidCredentials=用户名或密码错误
error.auth.local.accountDisabled=账号已被禁用
error.auth.local.accountPending=账号仍在审核中
error.auth.local.accountMerged=账号已合并到其他账号
error.auth.local.locked=账号已锁定,请 {0} 分钟后重试
error.auth.local.notEnabled=当前账号未启用密码登录
error.auth.local.password.tooShort=密码长度至少为 8 位
error.auth.local.password.tooLong=密码长度不能超过 128 位
error.auth.local.password.tooWeak=密码至少需要包含三种字符类型
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 无效

View file

@ -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"));
}
}

View file

@ -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"))

View file

@ -0,0 +1,151 @@
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;
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.http.HttpStatus;
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 LocalAuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private LocalAuthService localAuthService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private SkillHubMetrics skillHubMetrics;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"alice",
"alice@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
given(localAuthService.login("alice", "Abcd123!")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"Abcd123!"}
"""))
.andExpect(status().isOk())
.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
void register_returnsCreatedEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_2",
"bob",
"bob@example.com",
"",
"local",
Set.of()
);
given(localAuthService.register("bob", "Abcd123!", "bob@example.com")).willReturn(principal);
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"bob","password":"Abcd123!","email":"bob@example.com"}
"""))
.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
void changePassword_requiresAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isUnauthorized());
}
@Test
void changePassword_withAuthentication_returnsUpdated() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_3",
"carol",
"carol@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(authentication(auth))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
}

View file

@ -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"));
}
}

View file

@ -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")
);

View file

@ -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\""));
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}

View file

@ -10,6 +10,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpMethod;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -20,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
@ -66,6 +69,7 @@ public class SecurityConfig {
"/api/v1/health",
"/api/v1/auth/providers",
"/api/v1/auth/me",
"/api/v1/auth/local/**",
"/api/v1/cli/auth/device/**",
"/api/v1/cli/check",
"/actuator/health",
@ -99,6 +103,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)
)
@ -124,4 +136,9 @@ public class SecurityConfig {
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

View file

@ -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; }

View file

@ -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; }
}

View file

@ -0,0 +1,29 @@
package com.iflytek.skillhub.auth.exception;
import org.springframework.http.HttpStatus;
public class AuthFlowException extends RuntimeException {
private final HttpStatus status;
private final String messageCode;
private final Object[] messageArgs;
public AuthFlowException(HttpStatus status, String messageCode, Object... messageArgs) {
super(messageCode);
this.status = status;
this.messageCode = messageCode;
this.messageArgs = messageArgs;
}
public HttpStatus getStatus() {
return status;
}
public String getMessageCode() {
return messageCode;
}
public Object[] getMessageArgs() {
return messageArgs;
}
}

View file

@ -0,0 +1,188 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
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 LocalAuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final LocalCredentialRepository credentialRepository;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final PasswordPolicyValidator passwordPolicyValidator;
private final PasswordEncoder passwordEncoder;
public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public PlatformPrincipal register(String username, String password, String email) {
String normalizedUsername = normalizeUsername(username);
validateUsername(normalizedUsername);
if (credentialRepository.existsByUsernameIgnoreCase(normalizedUsername)) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.username.exists");
}
String normalizedEmail = normalizeEmail(email);
if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists");
}
var passwordErrors = passwordPolicyValidator.validate(password);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
UserAccount user = new UserAccount(
"usr_" + UUID.randomUUID(),
normalizedUsername,
normalizedEmail,
null
);
user.setStatus(UserStatus.ACTIVE);
userAccountRepository.save(user);
credentialRepository.save(new LocalCredential(
user.getId(),
normalizedUsername,
passwordEncoder.encode(password)
));
return buildPrincipal(user);
}
@Transactional
public PlatformPrincipal login(String username, String password) {
String normalizedUsername = normalizeUsername(username);
LocalCredential credential = credentialRepository.findByUsernameIgnoreCase(normalizedUsername)
.orElseThrow(() -> invalidCredentials());
UserAccount user = userAccountRepository.findById(credential.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for local credential"));
ensureUserCanLogin(user);
ensureNotLocked(credential);
if (!passwordEncoder.matches(password, credential.getPasswordHash())) {
handleFailedLogin(credential);
throw invalidCredentials();
}
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
return buildPrincipal(user);
}
@Transactional
public void changePassword(String userId, String currentPassword, String newPassword) {
LocalCredential credential = credentialRepository.findByUserId(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.notEnabled"));
if (!passwordEncoder.matches(currentPassword, credential.getPasswordHash())) {
throw new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
var passwordErrors = passwordPolicyValidator.validate(newPassword);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
}
private PlatformPrincipal buildPrincipal(UserAccount user) {
Set<String> roles = userRoleBindingRepository.findByUserId(user.getId()).stream()
.map(binding -> binding.getRole().getCode())
.collect(Collectors.toSet());
return new PlatformPrincipal(
user.getId(),
user.getDisplayName(),
user.getEmail(),
user.getAvatarUrl(),
"local",
roles
);
}
private void ensureUserCanLogin(UserAccount user) {
if (user.getStatus() == UserStatus.DISABLED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountDisabled");
}
if (user.getStatus() == UserStatus.PENDING) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountPending");
}
if (user.getStatus() == UserStatus.MERGED) {
throw new AuthFlowException(HttpStatus.FORBIDDEN, "error.auth.local.accountMerged");
}
}
private void ensureNotLocked(LocalCredential credential) {
if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(LocalDateTime.now())) {
long minutes = Math.max(1, Duration.between(LocalDateTime.now(), credential.getLockedUntil()).toMinutes());
throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes);
}
}
private void handleFailedLogin(LocalCredential credential) {
int failedAttempts = credential.getFailedAttempts() + 1;
credential.setFailedAttempts(failedAttempts);
if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
credential.setLockedUntil(LocalDateTime.now().plus(LOCK_DURATION));
}
credentialRepository.save(credential);
}
private AuthFlowException invalidCredentials() {
return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials");
}
private String normalizeUsername(String username) {
return username == null ? "" : username.trim().toLowerCase(Locale.ROOT);
}
private String normalizeEmail(String email) {
if (email == null || email.isBlank()) {
return null;
}
return email.trim().toLowerCase(Locale.ROOT);
}
private void validateUsername(String username) {
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid");
}
}
}

View file

@ -0,0 +1,101 @@
package com.iflytek.skillhub.auth.local;
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.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "local_credential")
public class LocalCredential {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, length = 128, unique = true)
private String userId;
@Column(nullable = false, length = 64, unique = true)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts;
@Column(name = "locked_until")
private LocalDateTime lockedUntil;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected LocalCredential() {}
public LocalCredential(String userId, String username, String passwordHash) {
this.userId = userId;
this.username = username;
this.passwordHash = passwordHash;
this.failedAttempts = 0;
}
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public int getFailedAttempts() {
return failedAttempts;
}
public void setFailedAttempts(int failedAttempts) {
this.failedAttempts = failedAttempts;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public void setLockedUntil(LocalDateTime lockedUntil) {
this.lockedUntil = lockedUntil;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.local;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LocalCredentialRepository extends JpaRepository<LocalCredential, Long> {
Optional<LocalCredential> findByUsernameIgnoreCase(String username);
Optional<LocalCredential> findByUserId(String userId);
boolean existsByUsernameIgnoreCase(String username);
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.local;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class PasswordPolicyValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 128;
private static final int MIN_CHAR_TYPES = 3;
public List<String> validate(String password) {
List<String> errors = new ArrayList<>();
if (password == null || password.length() < MIN_LENGTH) {
errors.add("error.auth.local.password.tooShort");
return errors;
}
if (password.length() > MAX_LENGTH) {
errors.add("error.auth.local.password.tooLong");
return errors;
}
int typeCount = 0;
if (password.chars().anyMatch(Character::isLowerCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isUpperCase)) {
typeCount++;
}
if (password.chars().anyMatch(Character::isDigit)) {
typeCount++;
}
if (password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch))) {
typeCount++;
}
if (typeCount < MIN_CHAR_TYPES) {
errors.add("error.auth.local.password.tooWeak");
}
return errors;
}
}

View file

@ -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; }
}

View file

@ -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<AccountMergeRequest, Long> {
Optional<AccountMergeRequest> findByIdAndPrimaryUserId(Long id, String primaryUserId);
boolean existsBySecondaryUserIdAndStatus(String secondaryUserId, String status);
}

View file

@ -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<NamespaceRole> 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<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
Optional<LocalCredential> 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<IdentityBinding> bindings = identityBindingRepository.findByUserId(secondaryUserId);
for (IdentityBinding binding : bindings) {
binding.setUserId(primaryUserId);
}
identityBindingRepository.saveAll(bindings);
}
private void migrateApiTokens(String primaryUserId, String secondaryUserId) {
List<ApiToken> 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<String> primaryRoleCodes = new HashSet<>();
for (UserRoleBinding binding : userRoleBindingRepository.findByUserId(primaryUserId)) {
primaryRoleCodes.add(binding.getRole().getCode());
}
List<UserRoleBinding> 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<NamespaceMember> secondaryMemberships = namespaceMemberRepository.findByUserId(secondaryUserId);
for (NamespaceMember secondaryMembership : secondaryMemberships) {
Optional<NamespaceMember> 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<LocalCredential> primaryCredential = localCredentialRepository.findByUserId(primaryUserId);
Optional<LocalCredential> 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);
}
}

View file

@ -9,5 +9,6 @@ import java.util.Optional;
@Repository
public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
Optional<ApiToken> findByTokenHash(String tokenHash);
List<ApiToken> findByUserId(String userId);
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
}

View file

@ -8,4 +8,5 @@ import java.util.Optional;
@Repository
public interface IdentityBindingRepository extends JpaRepository<IdentityBinding, Long> {
Optional<IdentityBinding> findByProviderCodeAndSubject(String providerCode, String subject);
java.util.List<IdentityBinding> findByUserId(String userId);
}

View file

@ -0,0 +1,128 @@
package com.iflytek.skillhub.auth.local;
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.verify;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.LocalDateTime;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class LocalAuthServiceTest {
@Mock
private LocalCredentialRepository credentialRepository;
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private UserRoleBindingRepository userRoleBindingRepository;
@Mock
private PasswordEncoder passwordEncoder;
private LocalAuthService service;
@BeforeEach
void setUp() {
service = new LocalAuthService(
credentialRepository,
userAccountRepository,
userRoleBindingRepository,
new PasswordPolicyValidator(),
passwordEncoder
);
}
@Test
void register_createsUserAndCredential() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty());
given(passwordEncoder.encode("Abcd123!")).willReturn("encoded");
given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0));
given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of());
var principal = service.register("Alice", "Abcd123!", "alice@example.com");
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userAccountRepository).save(userCaptor.capture());
assertThat(userCaptor.getValue().getDisplayName()).isEqualTo("alice");
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.email()).isEqualTo("alice@example.com");
verify(credentialRepository).save(any(LocalCredential.class));
}
@Test
void login_withValidPassword_resetsCounters() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
credential.setFailedAttempts(3);
credential.setLockedUntil(LocalDateTime.now().minusMinutes(1));
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
Role role = mock(Role.class);
given(role.getCode()).willReturn("USER_ADMIN");
UserRoleBinding binding = new UserRoleBinding("usr_1", role);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("Abcd123!", "encoded")).willReturn(true);
given(userRoleBindingRepository.findByUserId("usr_1")).willReturn(List.of(binding));
var principal = service.login("alice", "Abcd123!");
assertThat(credential.getFailedAttempts()).isZero();
assertThat(credential.getLockedUntil()).isNull();
assertThat(principal.platformRoles()).containsExactly("USER_ADMIN");
}
@Test
void login_withInvalidPassword_incrementsCounter() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
given(passwordEncoder.matches("bad", "encoded")).willReturn(false);
assertThatThrownBy(() -> service.login("alice", "bad"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(credential.getFailedAttempts()).isEqualTo(1);
verify(credentialRepository).save(credential);
}
@Test
void login_withDisabledAccount_fails() {
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential));
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.login("alice", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.accountDisabled");
}
}

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class PasswordPolicyValidatorTest {
private final PasswordPolicyValidator validator = new PasswordPolicyValidator();
@Test
void validPassword_passes() {
assertThat(validator.validate("Abcdef1!")).isEmpty();
}
@Test
void tooShort_fails() {
assertThat(validator.validate("Ab1!xyz")).containsExactly("error.auth.local.password.tooShort");
}
@Test
void tooLong_fails() {
assertThat(validator.validate("A".repeat(129))).containsExactly("error.auth.local.password.tooLong");
}
@Test
void twoCharTypes_fails() {
assertThat(validator.validate("abcdefgh1")).containsExactly("error.auth.local.password.tooWeak");
}
@ParameterizedTest
@ValueSource(strings = {"Abcdefg1", "Abcdef1!", "abcdef1!", "ABCDEF1!"})
void threeCharTypes_pass(String password) {
assertThat(validator.validate(password)).isEmpty();
}
}

View file

@ -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());
}
}

View file

@ -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; }
}

View file

@ -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<AuditLog> list(int page, int size, String actorUserId, String action) {
return auditLogRepository.search(actorUserId, action, PageRequest.of(page, size));
}
}

View file

@ -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<AuditLog> search(String actorUserId, String action, Pageable pageable);
}

View file

@ -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
));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -4,5 +4,6 @@ public enum SkillVersionStatus {
DRAFT,
PENDING_REVIEW,
PUBLISHED,
REJECTED
REJECTED,
YANKED
}

View file

@ -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) {

View file

@ -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("\"", "\\\"") + "\"}";
}
}

View file

@ -4,5 +4,6 @@ import java.util.Optional;
public interface UserAccountRepository {
Optional<UserAccount> findById(String id);
Optional<UserAccount> findByEmailIgnoreCase(String email);
UserAccount save(UserAccount user);
}

View file

@ -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\"}");
}
}

View file

@ -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<AuditLog, Long>, JpaSpecificationExecutor<AuditLog>, AuditLogRepository {
@Override
default Page<AuditLog> search(String actorUserId, String action, Pageable pageable) {
Specification<AuditLog> 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);
}
}

View file

@ -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,6 +58,11 @@ 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) {
Path resolved = basePath.resolve(key).normalize();
if (!resolved.startsWith(basePath)) {

View file

@ -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<String> keys);
boolean exists(String key);
ObjectMetadata getMetadata(String key);
String generatePresignedUrl(String key, Duration expiry);
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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"
},

221
web/pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -1,6 +1,18 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import type { ApiToken, CreateTokenRequest, CreateTokenResponse, OAuthProvider, User } from './types'
import type {
ChangePasswordRequest,
ApiToken,
CreateTokenRequest,
CreateTokenResponse,
LocalLoginRequest,
LocalRegisterRequest,
MergeInitiateRequest,
MergeInitiateResponse,
MergeVerifyRequest,
OAuthProvider,
User,
} from './types'
const client = createClient<paths>({ baseUrl: '' })
@ -21,6 +33,13 @@ function withCsrf(headers?: HeadersInit): HeadersInit {
}
}
async function ensureCsrfHeaders(headers?: HeadersInit): Promise<HeadersInit> {
if (!getCsrfToken()) {
await client.GET('/api/v1/auth/providers')
}
return withCsrf(headers)
}
function isApiEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
return typeof value === 'object' && value !== null && 'code' in value && 'msg' in value && 'data' in value
}
@ -110,6 +129,36 @@ export const authApi = {
return unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers') as never)
},
async localLogin(request: LocalLoginRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/login', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async localRegister(request: LocalRegisterRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/register', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async changePassword(request: ChangePasswordRequest): Promise<void> {
await fetchJson<void>('/api/v1/auth/local/change-password', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async logout(): Promise<void> {
const { response, error } = await client.POST('/api/v1/auth/logout', {
headers: withCsrf(),
@ -120,6 +169,28 @@ export const authApi = {
},
}
export const accountApi = {
async initiateMerge(request: MergeInitiateRequest): Promise<MergeInitiateResponse> {
return fetchJson<MergeInitiateResponse>('/api/v1/account/merge/initiate', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
async verifyMerge(request: MergeVerifyRequest): Promise<void> {
await fetchJson<void>('/api/v1/account/merge/verify', {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(request),
})
},
}
export const tokenApi = {
async getTokens(): Promise<ApiToken[]> {
return unwrap<ApiToken[]>(client.GET('/api/v1/tokens') as never)

View file

@ -6,6 +6,36 @@ export type ApiToken = components['schemas']['ApiToken']
export type CreateTokenRequest = components['schemas']['CreateTokenRequest']
export type CreateTokenResponse = components['schemas']['CreateTokenResponse']
export interface LocalLoginRequest {
username: string
password: string
}
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

View file

@ -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
</Link>
<Link
to="/settings/security"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'text-primary' }}
>
</Link>
<Link
to="/settings/accounts"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'text-primary' }}
>
</Link>
<div className="flex items-center gap-3">
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.displayName}
loading="lazy"
className="w-8 h-8 rounded-full border border-border/60"
/>
)}
@ -59,7 +75,17 @@ export function Layout() {
</header>
<main className="container mx-auto px-4 lg:px-8 py-12 relative z-10">
<Outlet />
<Suspense
fallback={
<div className="space-y-4 animate-fade-up">
<div className="h-10 w-48 animate-shimmer rounded-lg" />
<div className="h-5 w-72 animate-shimmer rounded-md" />
<div className="h-64 animate-shimmer rounded-xl" />
</div>
}
>
<Outlet />
</Suspense>
</main>
{/* Footer */}

View file

@ -1,23 +1,40 @@
import { lazy, type ComponentType } from 'react'
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
import { Layout } from './layout'
import { LandingPage } from '@/pages/landing'
import { HomePage } from '@/pages/home'
import { LoginPage } from '@/pages/login'
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<TModule extends Record<string, unknown>>(
importer: () => Promise<TModule>,
exportName: keyof TModule,
) {
const LazyComponent = lazy(async () => {
const module = await importer()
return { default: module[exportName] as ComponentType }
})
return LazyComponent
}
const LandingPage = lazyRouteComponent(() => import('@/pages/landing'), 'LandingPage')
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,
})
@ -40,6 +57,12 @@ const loginRoute = createRoute({
component: LoginPage,
})
const registerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/register',
component: RegisterPage,
})
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/search',
@ -162,6 +185,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',
@ -198,6 +247,7 @@ const routeTree = rootRoute.addChildren([
homeRoute,
skillsRoute,
loginRoute,
registerRoute,
searchRoute,
namespaceRoute,
skillDetailRoute,
@ -209,6 +259,8 @@ const routeTree = rootRoute.addChildren([
dashboardReviewsRoute,
dashboardReviewDetailRoute,
deviceRoute,
settingsSecurityRoute,
settingsAccountsRoute,
adminUsersRoute,
adminAuditLogRoute,
])

View file

@ -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),
})
}

View file

@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query'
import { authApi } from '@/api/client'
import type { LocalLoginRequest, LocalRegisterRequest } from '@/api/types'
export function useLocalLogin() {
return useMutation({
mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request),
})
}
export function useLocalRegister() {
return useMutation({
mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request),
})
}

View file

@ -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 (
<div className={className}>
<ReactMarkdown
rehypePlugins={[rehypeHighlight]}
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
components={{
// @ts-ignore - react-markdown types issue
div: ({ node, ...props }) => <div className="prose prose-sm dark:prose-invert max-w-none" {...props} />,

View file

@ -1,6 +1,26 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { LoginButton } from '@/features/auth/login-button'
import { useLocalLogin } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
export function LoginPage() {
const loginMutation = useLocalLogin()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
await loginMutation.mutateAsync({ username, password })
window.location.href = '/dashboard'
} catch {
// mutation state drives the error UI
}
}
return (
<div className="flex min-h-[70vh] items-center justify-center">
<div className="w-full max-w-md space-y-8 animate-fade-up">
@ -15,7 +35,58 @@ export function LoginPage() {
</div>
<div className="glass-strong p-8 rounded-2xl">
<LoginButton />
<Tabs defaultValue="password" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password"></TabsTrigger>
<TabsTrigger value="oauth">GitHub</TabsTrigger>
</TabsList>
<TabsContent value="password">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="username"></label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="输入用户名"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password"></label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</div>
{loginMutation.error ? (
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={loginMutation.isPending} type="submit">
{loginMutation.isPending ? '登录中...' : '登录'}
</Button>
<p className="text-center text-sm text-muted-foreground">
{' '}
<Link to="/register" className="font-medium text-primary hover:underline">
</Link>
</p>
</form>
</TabsContent>
<TabsContent value="oauth" className="space-y-4">
<p className="text-sm text-muted-foreground">
使 GitHub
</p>
<LoginButton />
</TabsContent>
</Tabs>
</div>
<p className="text-center text-xs text-muted-foreground">

View file

@ -0,0 +1,83 @@
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useLocalRegister } from '@/features/auth/use-local-auth'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
export function RegisterPage() {
const registerMutation = useLocalRegister()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
await registerMutation.mutateAsync({ username, email, password })
window.location.href = '/dashboard'
} catch {
// mutation state drives the error UI
}
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle></CardTitle>
<CardDescription> Dashboard</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-username"></label>
<Input
id="register-username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-64 位字母、数字或下划线"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-email"></label>
<Input
id="register-email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="可选,用于后续账号识别"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="register-password"></label>
<Input
id="register-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位,包含 3 种字符类型"
/>
</div>
{registerMutation.error ? (
<p className="text-sm text-red-600">{registerMutation.error.message}</p>
) : null}
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
{registerMutation.isPending ? '注册中...' : '注册并登录'}
</Button>
<p className="text-center text-sm text-muted-foreground">
{' '}
<Link to="/login" className="font-medium text-primary hover:underline">
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
event.preventDefault()
setStatusMessage('')
try {
await verifyMutation.mutateAsync({
mergeRequestId: Number(mergeRequestId),
verificationToken,
})
setStatusMessage('账号合并已完成')
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : '验证合并失败')
}
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> secondary `provider:subject` </CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleInitiate}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="secondary-identifier">Secondary </label>
<Input
id="secondary-identifier"
value={secondaryIdentifier}
onChange={(event) => setSecondaryIdentifier(event.target.value)}
placeholder="例如other_user 或 github:123456"
/>
</div>
<Button type="submit" disabled={initiateMutation.isPending}>
{initiateMutation.isPending ? '发起中...' : '发起合并'}
</Button>
</form>
</CardContent>
</Card>
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> token token </CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleVerify}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="merge-request-id">Merge Request ID</label>
<Input
id="merge-request-id"
value={mergeRequestId}
onChange={(event) => setMergeRequestId(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="merge-token">Verification Token</label>
<Input
id="merge-token"
value={verificationToken}
onChange={(event) => setVerificationToken(event.target.value)}
/>
</div>
<Button type="submit" disabled={verifyMutation.isPending}>
{verifyMutation.isPending ? '验证中...' : '完成合并'}
</Button>
</form>
{statusMessage ? <p className="mt-4 text-sm text-muted-foreground">{statusMessage}</p> : null}
</CardContent>
</Card>
</div>
)
}

View file

@ -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<HTMLFormElement>) {
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 (
<div className="mx-auto max-w-2xl">
<Card className="glass-strong">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="current-password"></label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-password"></label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
{statusMessage ? <p className="text-sm text-emerald-600">{statusMessage}</p> : null}
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '更新密码'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}