mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat(phase4): complete auth, governance, observability, and ops polish
This commit is contained in:
parent
8c5b4d176a
commit
33c44fb9cc
71 changed files with 2623 additions and 105 deletions
53
README.md
53
README.md
|
|
@ -120,6 +120,59 @@ Pass `X-Mock-User-Id` to the backend when you need an authenticated
|
|||
session without configuring GitHub OAuth. If the GHCR package remains
|
||||
private, run `docker login ghcr.io` before `docker compose up -d`.
|
||||
|
||||
### Monitoring
|
||||
|
||||
The Phase 4 monitoring stack lives under [`monitoring/`](./monitoring).
|
||||
It provides a local Prometheus + Grafana pair that scrapes the backend's
|
||||
Actuator Prometheus endpoint.
|
||||
|
||||
Start it with:
|
||||
|
||||
```bash
|
||||
cd monitoring
|
||||
docker compose -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- Prometheus: `http://localhost:9090`
|
||||
- Grafana: `http://localhost:3001` (`admin` / `admin`)
|
||||
|
||||
By default Prometheus scrapes `http://host.docker.internal:8080/actuator/prometheus`,
|
||||
so start the backend locally on port `8080` first.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s):
|
||||
|
||||
- `configmap.yaml`
|
||||
- `secret.yaml.example`
|
||||
- `backend-deployment.yaml`
|
||||
- `frontend-deployment.yaml`
|
||||
- `services.yaml`
|
||||
- `ingress.yaml`
|
||||
|
||||
Apply them after creating your own secret:
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/k8s/configmap.yaml
|
||||
kubectl apply -f deploy/k8s/secret.yaml
|
||||
kubectl apply -f deploy/k8s/backend-deployment.yaml
|
||||
kubectl apply -f deploy/k8s/frontend-deployment.yaml
|
||||
kubectl apply -f deploy/k8s/services.yaml
|
||||
kubectl apply -f deploy/k8s/ingress.yaml
|
||||
```
|
||||
|
||||
## Smoke Test
|
||||
|
||||
A lightweight smoke test script is available at [`scripts/smoke-test.sh`](./scripts/smoke-test.sh).
|
||||
|
||||
Run it against a local backend:
|
||||
|
||||
```bash
|
||||
./scripts/smoke-test.sh http://localhost:8080
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
|
|||
89
deploy/k8s/backend-deployment.yaml
Normal file
89
deploy/k8s/backend-deployment.yaml
Normal 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
19
deploy/k8s/configmap.yaml
Normal 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
|
||||
35
deploy/k8s/frontend-deployment.yaml
Normal file
35
deploy/k8s/frontend-deployment.yaml
Normal 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
26
deploy/k8s/ingress.yaml
Normal 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
|
||||
11
deploy/k8s/secret.yaml.example
Normal file
11
deploy/k8s/secret.yaml.example
Normal 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
27
deploy/k8s/services.yaml
Normal 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
|
||||
17
monitoring/docker-compose.monitoring.yml
Normal file
17
monitoring/docker-compose.monitoring.yml
Normal 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
10
monitoring/prometheus.yml
Normal 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
48
scripts/smoke-test.sh
Executable 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.ChangePasswordRequest;
|
|||
import com.iflytek.skillhub.dto.LocalLoginRequest;
|
||||
import com.iflytek.skillhub.dto.LocalRegisterRequest;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
|
|
@ -28,17 +29,21 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
public class LocalAuthController extends BaseApiController {
|
||||
|
||||
private final LocalAuthService localAuthService;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
|
||||
public LocalAuthController(ApiResponseFactory responseFactory,
|
||||
LocalAuthService localAuthService) {
|
||||
LocalAuthService localAuthService,
|
||||
SkillHubMetrics skillHubMetrics) {
|
||||
super(responseFactory);
|
||||
this.localAuthService = localAuthService;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<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));
|
||||
}
|
||||
|
|
@ -46,7 +51,14 @@ public class LocalAuthController extends BaseApiController {
|
|||
@PostMapping("/login")
|
||||
public ApiResponse<AuthMeResponse> login(@Valid @RequestBody LocalLoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
PlatformPrincipal principal = localAuthService.login(request.username(), request.password());
|
||||
PlatformPrincipal principal;
|
||||
try {
|
||||
principal = localAuthService.login(request.username(), request.password());
|
||||
} catch (RuntimeException ex) {
|
||||
skillHubMetrics.recordLocalLogin(false);
|
||||
throw ex;
|
||||
}
|
||||
skillHubMetrics.recordLocalLogin(true);
|
||||
establishSession(principal, httpRequest);
|
||||
return ok("response.success.read", AuthMeResponse.from(principal));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
|||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PublishResponse;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
|
@ -22,11 +23,14 @@ import java.util.zip.ZipInputStream;
|
|||
public class CliPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
|
||||
public CliPublishController(SkillPublishService skillPublishService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
ApiResponseFactory responseFactory,
|
||||
SkillHubMetrics skillHubMetrics) {
|
||||
super(responseFactory);
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
}
|
||||
|
||||
@PostMapping("/publish")
|
||||
|
|
@ -57,6 +61,7 @@ public class CliPublishController extends BaseApiController {
|
|||
publishResult.version().getFileCount(),
|
||||
publishResult.version().getTotalSize()
|
||||
);
|
||||
skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name());
|
||||
|
||||
return ok("response.success.published", response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import org.springframework.data.domain.Page;
|
|||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
|
@ -272,11 +273,7 @@ public class SkillController extends BaseApiController {
|
|||
SkillDownloadService.DownloadResult result = skillDownloadService.downloadLatest(
|
||||
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(result.contentType()))
|
||||
.contentLength(result.contentLength())
|
||||
.body(new InputStreamResource(result.content()));
|
||||
return buildDownloadResponse(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{namespace}/{slug}/versions/{version}/download")
|
||||
|
|
@ -291,11 +288,7 @@ public class SkillController extends BaseApiController {
|
|||
SkillDownloadService.DownloadResult result = skillDownloadService.downloadVersion(
|
||||
namespace, slug, version, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + result.filename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(result.contentType()))
|
||||
.contentLength(result.contentLength())
|
||||
.body(new InputStreamResource(result.content()));
|
||||
return buildDownloadResponse(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{namespace}/{slug}/tags/{tagName}/download")
|
||||
|
|
@ -310,6 +303,16 @@ public class SkillController extends BaseApiController {
|
|||
SkillDownloadService.DownloadResult result = skillDownloadService.downloadByTag(
|
||||
namespace, slug, tagName, userId, userNsRoles != null ? userNsRoles : Map.of());
|
||||
|
||||
return buildDownloadResponse(result);
|
||||
}
|
||||
|
||||
private ResponseEntity<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()))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
|||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.PublishResponse;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
|
@ -22,11 +23,14 @@ import java.util.zip.ZipInputStream;
|
|||
public class SkillPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final SkillHubMetrics skillHubMetrics;
|
||||
|
||||
public SkillPublishController(SkillPublishService skillPublishService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
ApiResponseFactory responseFactory,
|
||||
SkillHubMetrics skillHubMetrics) {
|
||||
super(responseFactory);
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.skillHubMetrics = skillHubMetrics;
|
||||
}
|
||||
|
||||
@PostMapping("/{namespace}/publish")
|
||||
|
|
@ -57,6 +61,7 @@ public class SkillPublishController extends BaseApiController {
|
|||
publishResult.version().getFileCount(),
|
||||
publishResult.version().getTotalSize()
|
||||
);
|
||||
skillHubMetrics.incrementSkillPublish(namespace, publishResult.version().getStatus().name());
|
||||
|
||||
return ok("response.success.published", response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillActionRequest(String reason) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillMutationResponse(
|
||||
Long skillId,
|
||||
Long versionId,
|
||||
String action,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MergeInitiateRequest(
|
||||
@NotBlank(message = "待合并账号标识不能为空")
|
||||
String secondaryIdentifier
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record MergeInitiateResponse(
|
||||
Long mergeRequestId,
|
||||
String secondaryUserId,
|
||||
String verificationToken,
|
||||
String expiresAt
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
server:
|
||||
port: 8080
|
||||
shutdown: graceful
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
http-only: true
|
||||
secure: ${SESSION_COOKIE_SECURE:false}
|
||||
same-site: lax
|
||||
max-age: 28800
|
||||
|
||||
spring:
|
||||
messages:
|
||||
|
|
@ -67,10 +74,16 @@ management:
|
|||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
include: health,info,prometheus,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
metrics:
|
||||
tags:
|
||||
application: skillhub
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
---
|
||||
# Docker profile
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -89,3 +89,16 @@ error.auth.local.notEnabled=Password login is not enabled for this account
|
|||
error.auth.local.password.tooShort=Password must be at least 8 characters
|
||||
error.auth.local.password.tooLong=Password must not exceed 128 characters
|
||||
error.auth.local.password.tooWeak=Password must contain at least three character types
|
||||
error.auth.merge.identifierRequired=Secondary account identifier is required
|
||||
error.auth.merge.identifierInvalid=Secondary account identifier is invalid
|
||||
error.auth.merge.primaryNotFound=Primary account not found
|
||||
error.auth.merge.primaryNotActive=Primary account must be active
|
||||
error.auth.merge.secondaryNotFound=Secondary account not found
|
||||
error.auth.merge.secondaryNotActive=Secondary account must be active
|
||||
error.auth.merge.sameAccount=Cannot merge the current account into itself
|
||||
error.auth.merge.pendingExists=A pending merge request already exists for this secondary account
|
||||
error.auth.merge.localCredentialConflict=Both accounts already have local credentials
|
||||
error.auth.merge.requestNotFound=Merge request not found
|
||||
error.auth.merge.requestNotPending=Merge request is not pending
|
||||
error.auth.merge.tokenExpired=Merge verification token has expired
|
||||
error.auth.merge.invalidToken=Invalid merge verification token
|
||||
|
|
|
|||
|
|
@ -89,3 +89,16 @@ error.auth.local.notEnabled=当前账号未启用密码登录
|
|||
error.auth.local.password.tooShort=密码长度至少为 8 位
|
||||
error.auth.local.password.tooLong=密码长度不能超过 128 位
|
||||
error.auth.local.password.tooWeak=密码至少需要包含三种字符类型
|
||||
error.auth.merge.identifierRequired=待合并账号标识不能为空
|
||||
error.auth.merge.identifierInvalid=待合并账号标识格式不正确
|
||||
error.auth.merge.primaryNotFound=未找到主账号
|
||||
error.auth.merge.primaryNotActive=主账号必须处于激活状态
|
||||
error.auth.merge.secondaryNotFound=未找到待合并账号
|
||||
error.auth.merge.secondaryNotActive=待合并账号必须处于激活状态
|
||||
error.auth.merge.sameAccount=不能将当前账号合并到自己
|
||||
error.auth.merge.pendingExists=该待合并账号已有进行中的合并请求
|
||||
error.auth.merge.localCredentialConflict=两个账号都已启用本地密码登录,无法自动合并
|
||||
error.auth.merge.requestNotFound=未找到合并请求
|
||||
error.auth.merge.requestNotPending=该合并请求不处于待验证状态
|
||||
error.auth.merge.tokenExpired=合并验证 token 已过期
|
||||
error.auth.merge.invalidToken=合并验证 token 无效
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
||||
import com.iflytek.skillhub.auth.local.LocalAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.metrics.SkillHubMetrics;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -18,6 +22,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
|
|||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
|
@ -37,6 +42,9 @@ class LocalAuthControllerTest {
|
|||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private SkillHubMetrics skillHubMetrics;
|
||||
|
||||
@Test
|
||||
void login_returnsCurrentUserEnvelope() throws Exception {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
|
|
@ -59,6 +67,8 @@ class LocalAuthControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value("usr_1"))
|
||||
.andExpect(jsonPath("$.data.oauthProvider").value("local"));
|
||||
verify(skillHubMetrics).recordLocalLogin(true);
|
||||
verify(skillHubMetrics, never()).recordLocalLogin(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -82,6 +92,23 @@ class LocalAuthControllerTest {
|
|||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.displayName").value("bob"));
|
||||
verify(skillHubMetrics).incrementUserRegister();
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_failure_recordsFailureMetric() throws Exception {
|
||||
given(localAuthService.login("alice", "wrong"))
|
||||
.willThrow(new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/local/login")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"alice","password":"wrong"}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized());
|
||||
verify(skillHubMetrics).recordLocalLogin(false);
|
||||
verify(skillHubMetrics, never()).recordLocalLogin(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
|
||||
@Configuration
|
||||
|
|
@ -88,6 +89,14 @@ public class SecurityConfig {
|
|||
.successHandler(successHandler)
|
||||
.failureHandler(failureHandler)
|
||||
)
|
||||
.headers(headers -> headers
|
||||
.contentTypeOptions(contentTypeOptions -> {})
|
||||
.frameOptions(frameOptions -> frameOptions.deny())
|
||||
.httpStrictTransportSecurity(hsts -> hsts
|
||||
.includeSubDomains(true)
|
||||
.maxAgeInSeconds(31536000))
|
||||
.referrerPolicy(referrer -> referrer.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ public class LocalCredential {
|
|||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ public enum SkillVersionStatus {
|
|||
DRAFT,
|
||||
PENDING_REVIEW,
|
||||
PUBLISHED,
|
||||
REJECTED
|
||||
REJECTED,
|
||||
YANKED
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import org.springframework.stereotype.Service;
|
|||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
|
|
@ -58,5 +58,10 @@ public class LocalFileStorageService implements ObjectStorageService {
|
|||
} catch (IOException e) { throw new UncheckedIOException("Failed to get metadata: " + key, e); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String key, Duration expiry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolve(String key) { return basePath.resolve(key); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
221
web/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from './generated/schema'
|
||||
import type {
|
||||
ChangePasswordRequest,
|
||||
ApiToken,
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
LocalLoginRequest,
|
||||
LocalRegisterRequest,
|
||||
MergeInitiateRequest,
|
||||
MergeInitiateResponse,
|
||||
MergeVerifyRequest,
|
||||
OAuthProvider,
|
||||
User,
|
||||
} from './types'
|
||||
|
|
@ -145,6 +149,16 @@ export const authApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async changePassword(request: ChangePasswordRequest): Promise<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(),
|
||||
|
|
@ -155,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)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ export interface LocalRegisterRequest extends LocalLoginRequest {
|
|||
email?: string
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
export interface MergeInitiateRequest {
|
||||
secondaryIdentifier: string
|
||||
}
|
||||
|
||||
export interface MergeInitiateResponse {
|
||||
mergeRequestId: number
|
||||
secondaryUserId: string
|
||||
verificationToken: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface MergeVerifyRequest {
|
||||
mergeRequestId: number
|
||||
verificationToken: string
|
||||
}
|
||||
|
||||
// Namespace types
|
||||
export interface Namespace {
|
||||
id: number
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
import { lazy, type ComponentType } from 'react'
|
||||
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
|
||||
import { Layout } from './layout'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { LoginPage } from '@/pages/login'
|
||||
import { RegisterPage } from '@/pages/register'
|
||||
import { DashboardPage } from '@/pages/dashboard'
|
||||
import { SearchPage } from '@/pages/search'
|
||||
import { NamespacePage } from '@/pages/namespace'
|
||||
import { SkillDetailPage } from '@/pages/skill-detail'
|
||||
import { PublishPage } from '@/pages/dashboard/publish'
|
||||
import { MySkillsPage } from '@/pages/dashboard/my-skills'
|
||||
import { MyNamespacesPage } from '@/pages/dashboard/my-namespaces'
|
||||
import { NamespaceMembersPage } from '@/pages/dashboard/namespace-members'
|
||||
import { ReviewsPage } from '@/pages/dashboard/reviews'
|
||||
import { ReviewDetailPage } from '@/pages/dashboard/review-detail'
|
||||
import { DeviceAuthPage } from '@/pages/device'
|
||||
import { AdminUsersPage } from '@/pages/admin/users'
|
||||
import { AuditLogPage } from '@/pages/admin/audit-log'
|
||||
import { getCurrentUser } from '@/api/client'
|
||||
|
||||
function lazyRouteComponent<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 HomePage = lazyRouteComponent(() => import('@/pages/home'), 'HomePage')
|
||||
const LoginPage = lazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
|
||||
const RegisterPage = lazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
|
||||
const SearchPage = lazyRouteComponent(() => import('@/pages/search'), 'SearchPage')
|
||||
const NamespacePage = lazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
|
||||
const SkillDetailPage = lazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
|
||||
const DashboardPage = lazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
|
||||
const MySkillsPage = lazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
|
||||
const PublishPage = lazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
|
||||
const MyNamespacesPage = lazyRouteComponent(() => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage')
|
||||
const NamespaceMembersPage = lazyRouteComponent(() => import('@/pages/dashboard/namespace-members'), 'NamespaceMembersPage')
|
||||
const ReviewsPage = lazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const ReviewDetailPage = lazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
|
||||
const DeviceAuthPage = lazyRouteComponent(() => import('@/pages/device'), 'DeviceAuthPage')
|
||||
const SecuritySettingsPage = lazyRouteComponent(() => import('@/pages/settings/security'), 'SecuritySettingsPage')
|
||||
const AccountSettingsPage = lazyRouteComponent(() => import('@/pages/settings/accounts'), 'AccountSettingsPage')
|
||||
const AdminUsersPage = lazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage')
|
||||
const AuditLogPage = lazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage')
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: Layout,
|
||||
})
|
||||
|
|
@ -162,6 +178,32 @@ const deviceRoute = createRoute({
|
|||
component: DeviceAuthPage,
|
||||
})
|
||||
|
||||
const settingsSecurityRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings/security',
|
||||
beforeLoad: async () => {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: SecuritySettingsPage,
|
||||
})
|
||||
|
||||
const settingsAccountsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings/accounts',
|
||||
beforeLoad: async () => {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: AccountSettingsPage,
|
||||
})
|
||||
|
||||
const adminUsersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/admin/users',
|
||||
|
|
@ -209,6 +251,8 @@ const routeTree = rootRoute.addChildren([
|
|||
dashboardReviewsRoute,
|
||||
dashboardReviewDetailRoute,
|
||||
deviceRoute,
|
||||
settingsSecurityRoute,
|
||||
settingsAccountsRoute,
|
||||
adminUsersRoute,
|
||||
adminAuditLogRoute,
|
||||
])
|
||||
|
|
|
|||
15
web/src/features/auth/use-account-merge.ts
Normal file
15
web/src/features/auth/use-account-merge.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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} />,
|
||||
|
|
|
|||
100
web/src/pages/settings/accounts.tsx
Normal file
100
web/src/pages/settings/accounts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
web/src/pages/settings/security.tsx
Normal file
70
web/src/pages/settings/security.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue