refactor(web): move portal APIs under api/web

This commit is contained in:
yun-zhi-ztl 2026-03-15 15:48:14 +08:00
parent 17dcb4807a
commit 2b6f4e335e
17 changed files with 76 additions and 44 deletions

View file

@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/me")
@RequestMapping({"/api/v1/me", "/api/web/me"})
public class MeController extends BaseApiController {
private final MySkillAppService mySkillAppService;

View file

@ -11,7 +11,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/namespaces")
@RequestMapping({"/api/v1/namespaces", "/api/web/namespaces"})
public class NamespaceController extends BaseApiController {
private final NamespaceService namespaceService;

View file

@ -41,7 +41,7 @@ import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/v1/promotions")
@RequestMapping({"/api/v1/promotions", "/api/web/promotions"})
public class PromotionController extends BaseApiController {
private final PromotionService promotionService;

View file

@ -42,7 +42,7 @@ import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/v1/reviews")
@RequestMapping({"/api/v1/reviews", "/api/web/reviews"})
public class ReviewController extends BaseApiController {
private final ReviewService reviewService;

View file

@ -32,7 +32,7 @@ import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/skills")
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillController extends BaseApiController {
private final SkillQueryService skillQueryService;

View file

@ -20,7 +20,7 @@ import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/v1/skills")
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillPublishController extends BaseApiController {
private final SkillPublishService skillPublishService;

View file

@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/skills")
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillRatingController extends BaseApiController {
private final SkillRatingService skillRatingService;

View file

@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/skills")
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillSearchController extends BaseApiController {
private final SkillSearchAppService skillSearchAppService;

View file

@ -9,7 +9,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/skills")
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillStarController extends BaseApiController {
private final SkillStarService skillStarService;

View file

@ -17,7 +17,10 @@ import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/skills/{namespace}/{slug}/tags")
@RequestMapping({
"/api/v1/skills/{namespace}/{slug}/tags",
"/api/web/skills/{namespace}/{slug}/tags"
})
public class SkillTagController extends BaseApiController {
private final SkillTagService skillTagService;

View file

@ -70,7 +70,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfHandler)
.ignoringRequestMatchers("/api/v1/**", "/api/compat/**")
.ignoringRequestMatchers("/api/v1/**", "/api/web/**", "/api/compat/**")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(
@ -92,7 +92,13 @@ public class SecurityConfig {
"/api/compat/v1/resolve/**",
"/api/compat/v1/download/**"
).permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/skills/*/star", "/api/v1/skills/*/rating").authenticated()
.requestMatchers(
HttpMethod.GET,
"/api/v1/skills/*/star",
"/api/v1/skills/*/rating",
"/api/web/skills/*/star",
"/api/web/skills/*/rating"
).authenticated()
.requestMatchers(
HttpMethod.GET,
"/api/v1/skills",
@ -104,9 +110,25 @@ public class SecurityConfig {
"/api/v1/skills/*/*/resolve",
"/api/v1/skills/*/*/tags",
"/api/v1/skills/*/*/tags/*/files",
"/api/v1/skills/*/*/tags/*/file"
"/api/v1/skills/*/*/tags/*/file",
"/api/web/skills",
"/api/web/skills/*/*",
"/api/web/skills/*/*/versions",
"/api/web/skills/*/*/versions/*",
"/api/web/skills/*/*/versions/*/files",
"/api/web/skills/*/*/versions/*/file",
"/api/web/skills/*/*/resolve",
"/api/web/skills/*/*/tags",
"/api/web/skills/*/*/tags/*/files",
"/api/web/skills/*/*/tags/*/file"
).permitAll()
.requestMatchers(
HttpMethod.GET,
"/api/v1/namespaces",
"/api/v1/namespaces/*",
"/api/web/namespaces",
"/api/web/namespaces/*"
).permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll()
.requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR")
.anyRequest().authenticated()
)

View file

@ -24,8 +24,12 @@ public class ApiTokenScopeService {
ScopeRule.allow("GET", "/api/v1/whoami"),
ScopeRule.allow("GET", "/api/v1/skills"),
ScopeRule.allow("GET", "/api/v1/skills/**"),
ScopeRule.allow("GET", "/api/web/skills"),
ScopeRule.allow("GET", "/api/web/skills/**"),
ScopeRule.allow("GET", "/api/v1/namespaces"),
ScopeRule.allow("GET", "/api/v1/namespaces/*"),
ScopeRule.allow("GET", "/api/web/namespaces"),
ScopeRule.allow("GET", "/api/web/namespaces/*"),
ScopeRule.allow("GET", "/api/compat/v1/search"),
ScopeRule.allow("GET", "/api/compat/v1/resolve/**"),
ScopeRule.allow("GET", "/api/compat/v1/whoami"),
@ -39,6 +43,7 @@ public class ApiTokenScopeService {
ScopeRule.require(null, "/api/v1/tokens", "token:manage"),
ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"),
ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"),
ScopeRule.require("POST", "/api/web/skills/*/publish", "skill:publish"),
ScopeRule.require("POST", "/api/v1/publish", "skill:publish"),
ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish")
);
@ -96,7 +101,7 @@ public class ApiTokenScopeService {
}
private boolean isApiPath(String path) {
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/compat/"));
return path != null && (path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/compat/"));
}
public record AuthorizationDecision(boolean allowed, String requiredScope, String message) {

View file

@ -24,6 +24,8 @@ import i18n from '@/i18n/config'
export { ApiError }
export const WEB_API_PREFIX = '/api/web'
type RuntimeConfig = {
apiBaseUrl?: string
appBaseUrl?: string
@ -495,16 +497,16 @@ export const reviewApi = {
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: ReviewTask[]; total: number; page: number; size: number }>(
`/api/v1/reviews?${searchParams.toString()}`,
`${WEB_API_PREFIX}/reviews?${searchParams.toString()}`,
)
},
async get(id: number): Promise<ReviewTask> {
return fetchJson<ReviewTask>(`/api/v1/reviews/${id}`)
return fetchJson<ReviewTask>(`${WEB_API_PREFIX}/reviews/${id}`)
},
async approve(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/reviews/${id}/approve`, {
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/approve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
@ -514,7 +516,7 @@ export const reviewApi = {
},
async reject(id: number, comment: string): Promise<void> {
await fetchJson<void>(`/api/v1/reviews/${id}/reject`, {
await fetchJson<void>(`${WEB_API_PREFIX}/reviews/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
@ -531,16 +533,16 @@ export const promotionApi = {
searchParams.set('page', String(params.page ?? 0))
searchParams.set('size', String(params.size ?? 20))
return fetchJson<{ items: PromotionTask[]; total: number; page: number; size: number }>(
`/api/v1/promotions?${searchParams.toString()}`,
`${WEB_API_PREFIX}/promotions?${searchParams.toString()}`,
)
},
async get(id: number): Promise<PromotionTask> {
return fetchJson<PromotionTask>(`/api/v1/promotions/${id}`)
return fetchJson<PromotionTask>(`${WEB_API_PREFIX}/promotions/${id}`)
},
async approve(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/promotions/${id}/approve`, {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/approve`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
@ -550,7 +552,7 @@ export const promotionApi = {
},
async reject(id: number, comment?: string): Promise<void> {
await fetchJson<void>(`/api/v1/promotions/${id}/reject`, {
await fetchJson<void>(`${WEB_API_PREFIX}/promotions/${id}/reject`, {
method: 'POST',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
@ -562,7 +564,7 @@ export const promotionApi = {
export const meApi = {
async getStars(): Promise<SkillSummary[]> {
return fetchJson<SkillSummary[]>('/api/v1/me/stars')
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)
},
}

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client'
import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
interface UserRating {
score: number
@ -8,7 +8,7 @@ interface UserRating {
async function getUserRating(skillId: number): Promise<UserRating> {
try {
return await fetchJson<UserRating>(`/api/v1/skills/${skillId}/rating`)
return await fetchJson<UserRating>(`${WEB_API_PREFIX}/skills/${skillId}/rating`)
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return { score: 0, rated: false }
@ -18,7 +18,7 @@ async function getUserRating(skillId: number): Promise<UserRating> {
}
async function rateSkill(skillId: number, rating: number): Promise<void> {
await fetchJson<void>(`/api/v1/skills/${skillId}/rating`, {
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/rating`, {
method: 'PUT',
headers: getCsrfHeaders({
'Content-Type': 'application/json',

View file

@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ApiError, fetchJson, getCsrfHeaders } from '@/api/client'
import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
interface StarStatus {
starred: boolean
@ -7,7 +7,7 @@ interface StarStatus {
async function getStarStatus(skillId: number): Promise<StarStatus> {
try {
const starred = await fetchJson<boolean>(`/api/v1/skills/${skillId}/star`)
const starred = await fetchJson<boolean>(`${WEB_API_PREFIX}/skills/${skillId}/star`)
return { starred }
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
@ -19,12 +19,12 @@ async function getStarStatus(skillId: number): Promise<StarStatus> {
async function toggleStar(skillId: number, starred: boolean): Promise<void> {
if (starred) {
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/star`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
} else {
await fetchJson<void>(`/api/v1/skills/${skillId}/star`, {
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/star`, {
method: 'PUT',
headers: getCsrfHeaders(),
})

View file

@ -8,7 +8,7 @@ import { InstallCommand } from '@/features/skill/install-command'
import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi } from '@/api/client'
import { adminApi, WEB_API_PREFIX } from '@/api/client'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
@ -67,7 +67,7 @@ export function SkillDetailPage() {
return
}
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
const downloadUrl = `/api/v1/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download`
const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download`
window.open(downloadUrl, '_blank')
}

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client'
import { fetchJson, fetchText, getCsrfHeaders, meApi, WEB_API_PREFIX } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
@ -15,36 +15,36 @@ async function searchSkills(params: SearchParams): Promise<PagedResponse<SkillSu
if (params.page !== undefined) queryParams.append('page', String(params.page))
if (params.size !== undefined) queryParams.append('size', String(params.size))
return fetchJson<PagedResponse<SkillSummary>>(`/api/v1/skills?${queryParams.toString()}`)
return fetchJson<PagedResponse<SkillSummary>>(`${WEB_API_PREFIX}/skills?${queryParams.toString()}`)
}
async function getSkillDetail(namespace: string, slug: string): Promise<SkillDetail> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
return fetchJson<SkillDetail>(`/api/v1/skills/${cleanNamespace}/${slug}`)
return fetchJson<SkillDetail>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}`)
}
async function getSkillVersions(namespace: string, slug: string): Promise<SkillVersion[]> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
const page = await fetchJson<PagedResponse<SkillVersion>>(`/api/v1/skills/${cleanNamespace}/${slug}/versions`)
const page = await fetchJson<PagedResponse<SkillVersion>>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions`)
return page.items
}
async function getSkillFiles(namespace: string, slug: string, version: string): Promise<SkillFile[]> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
return fetchJson<SkillFile[]>(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
return fetchJson<SkillFile[]>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/files`)
}
async function getSkillReadme(namespace: string, slug: string, version: string): Promise<string> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
try {
return await fetchText(`/api/v1/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`)
} catch {
return ''
}
}
async function getMySkills(): Promise<SkillSummary[]> {
return fetchJson<SkillSummary[]>('/api/v1/me/skills')
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/skills`)
}
async function getMyStars(): Promise<SkillSummary[]> {
@ -52,18 +52,18 @@ async function getMyStars(): Promise<SkillSummary[]> {
}
async function getMyNamespaces(): Promise<Namespace[]> {
const page = await fetchJson<PagedResponse<Namespace>>('/api/v1/namespaces')
const page = await fetchJson<PagedResponse<Namespace>>(`${WEB_API_PREFIX}/namespaces`)
return page.items
}
async function getNamespaceDetail(slug: string): Promise<Namespace> {
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
return fetchJson<Namespace>(`/api/v1/namespaces/${cleanSlug}`)
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}`)
}
async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
const page = await fetchJson<PagedResponse<NamespaceMember>>(`/api/v1/namespaces/${cleanSlug}/members`)
const page = await fetchJson<PagedResponse<NamespaceMember>>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}/members`)
return page.items
}
@ -73,7 +73,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
formData.append('file', params.file)
formData.append('visibility', params.visibility)
return fetchJson<PublishResult>(`/api/v1/skills/${cleanNamespace}/publish`, {
return fetchJson<PublishResult>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, {
method: 'POST',
headers: getCsrfHeaders(),
body: formData,