Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a46ca1e
fix: refresh skill download counts after download
yun-zhi-ztl Mar 16, 2026
fda82c9
fix: limit skill search query length
yun-zhi-ztl Mar 16, 2026
224962f
fix: truncate long error messages in ui
yun-zhi-ztl Mar 16, 2026
692aea3
fix: refresh auth roles promptly
yun-zhi-ztl Mar 16, 2026
367d305
fix: block disabled users with active sessions
yun-zhi-ztl Mar 16, 2026
024a872
fix: add my skills preview to dashboard
yun-zhi-ztl Mar 16, 2026
40ce13d
fix: align dashboard my skills layout
yun-zhi-ztl Mar 16, 2026
bbd4ad2
fix: refine dashboard my skills preview
yun-zhi-ztl Mar 16, 2026
8068590
fix: adjust dashboard my skills grid
yun-zhi-ztl Mar 16, 2026
43b71e9
fix: keep dashboard more tile visible
yun-zhi-ztl Mar 16, 2026
484eec4
fix: refine dashboard copy tone
yun-zhi-ztl Mar 16, 2026
5a05fe5
fix: return skill detail back button to search
yun-zhi-ztl Mar 17, 2026
a4fa23b
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 17, 2026
75af47f
fix: paginate personal skills and stars
yun-zhi-ztl Mar 17, 2026
8c319fd
fix: handle numeric skill search queries
yun-zhi-ztl Mar 17, 2026
5e4b63b
fix: restrict namespace creation to admins
yun-zhi-ztl Mar 17, 2026
b9e55a0
fix: show review failure reasons
yun-zhi-ztl Mar 17, 2026
af11784
fix: allow republish after review withdrawal
yun-zhi-ztl Mar 17, 2026
f6f964e
fix: wrap long report review text
yun-zhi-ztl Mar 17, 2026
829e4ce
fix: hide account merge entry
yun-zhi-ztl Mar 17, 2026
74d164b
fix: relax skill frontmatter parsing
yun-zhi-ztl Mar 17, 2026
27ac96e
fix: handle role downgrade on protected pages
yun-zhi-ztl Mar 17, 2026
315c5b2
fix: sync namespace member role updates
yun-zhi-ztl Mar 17, 2026
5e3838d
fix: show download error on skill detail
yun-zhi-ztl Mar 17, 2026
e19c0b4
fix: stack dashboard skills and tokens
yun-zhi-ztl Mar 17, 2026
947cc59
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 17, 2026
416ae94
test: restore passing unit suites
yun-zhi-ztl Mar 17, 2026
a3856d2
test: cover formatting and api error helpers
yun-zhi-ztl Mar 17, 2026
14b3c59
test: cover skill query helpers
yun-zhi-ztl Mar 17, 2026
ecdeb12
fix: degrade gracefully for missing skill storage assets
yun-zhi-ztl Mar 17, 2026
a00ddf4
fix: hide self-report action on skill detail
yun-zhi-ztl Mar 17, 2026
b88ce0a
fix: fallback to rebuilding skill bundles for downloads
yun-zhi-ztl Mar 17, 2026
6939b18
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.service.MySkillAppService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping({"/api/v1/me", "/api/web/me"})
public class MeController extends BaseApiController {
Expand All @@ -26,22 +26,26 @@ public MeController(MySkillAppService mySkillAppService, ApiResponseFactory resp
}

@GetMapping("/skills")
public ApiResponse<List<SkillSummaryResponse>> listMySkills(
public ApiResponse<PageResponse<SkillSummaryResponse>> listMySkills(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}

return ok("response.success.read", mySkillAppService.listMySkills(principal.userId()));
return ok("response.success.read", mySkillAppService.listMySkills(principal.userId(), page, size));
}

@GetMapping("/stars")
public ApiResponse<List<SkillSummaryResponse>> listMyStars(
public ApiResponse<PageResponse<SkillSummaryResponse>> listMyStars(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}

return ok("response.success.read", mySkillAppService.listMyStars(principal.userId()));
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.*;
import com.iflytek.skillhub.dto.*;
import com.iflytek.skillhub.exception.ForbiddenException;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
Expand Down Expand Up @@ -79,6 +81,13 @@ public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
public ApiResponse<NamespaceResponse> createNamespace(
@Valid @RequestBody NamespaceRequest request,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
if (!canCreateNamespace(principal)) {
throw new ForbiddenException("error.namespace.create.platformAdminRequired");
}

Namespace namespace = namespaceService.createNamespace(
request.slug(),
request.displayName(),
Expand All @@ -88,6 +97,11 @@ public ApiResponse<NamespaceResponse> createNamespace(
return ok("response.success.created", NamespaceResponse.from(namespace));
}

private boolean canCreateNamespace(PlatformPrincipal principal) {
return principal.platformRoles().contains("SKILL_ADMIN")
|| principal.platformRoles().contains("SUPER_ADMIN");
}

@PutMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> updateNamespace(
@PathVariable String slug,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ public ApiResponse<SkillDetailResponse> getSkillDetail(
detail.canManageLifecycle(),
detail.canSubmitPromotion(),
detail.viewingVersionStatus(),
detail.canInteract()
detail.canInteract(),
detail.canReport()
);

return ok("response.success.read", response);
Expand Down Expand Up @@ -104,7 +105,8 @@ public ApiResponse<PageResponse<SkillVersionResponse>> listVersions(
v.getChangelog(),
v.getFileCount(),
v.getTotalSize(),
v.getPublishedAt()
v.getPublishedAt(),
skillQueryService.isDownloadAvailable(v)
)));

return ok("response.success.read", response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public record SkillDetailResponse(
boolean canManageLifecycle,
boolean canSubmitPromotion,
String viewingVersionStatus,
boolean canInteract
boolean canInteract,
boolean canReport
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public record SkillVersionResponse(
String changelog,
int fileCount,
long totalSize,
LocalDateTime publishedAt
LocalDateTime publishedAt,
boolean downloadAvailable
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand All @@ -25,8 +25,6 @@

@Service
public class MySkillAppService {
private static final int STAR_PAGE_SIZE = 200;

private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
Expand All @@ -46,10 +44,9 @@ public MySkillAppService(
this.promotionRequestRepository = promotionRequestRepository;
}

public List<SkillSummaryResponse> listMySkills(String userId) {
List<Skill> skills = skillRepository.findByOwnerId(userId).stream()
.sorted(Comparator.comparing(Skill::getUpdatedAt).reversed())
.toList();
public PageResponse<SkillSummaryResponse> listMySkills(String userId, int page, int size) {
Page<Skill> skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size));
List<Skill> skills = skillPage.getContent();

Map<Long, SkillVersion> versionsBySkillId = loadLatestRelevantVersions(skills);

Expand All @@ -62,13 +59,19 @@ public List<SkillSummaryResponse> listMySkills(String userId) {
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));

return skills.stream()
List<SkillSummaryResponse> items = skills.stream()
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.toList();

return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize());
}

public List<SkillSummaryResponse> listMyStars(String userId) {
List<com.iflytek.skillhub.domain.social.SkillStar> stars = loadAllStars(userId);
public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, int size) {
Page<com.iflytek.skillhub.domain.social.SkillStar> starPage = skillStarRepository.findByUserId(
userId,
PageRequest.of(page, size)
);
List<com.iflytek.skillhub.domain.social.SkillStar> stars = starPage.getContent();

List<Long> skillIds = stars.stream()
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
Expand All @@ -90,30 +93,13 @@ public List<SkillSummaryResponse> listMyStars(String userId) {
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity()));

return stars.stream()
.sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed())
List<SkillSummaryResponse> items = stars.stream()
.map(star -> skillsById.get(star.getSkillId()))
.filter(java.util.Objects::nonNull)
.map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById))
.toList();
}

private List<com.iflytek.skillhub.domain.social.SkillStar> loadAllStars(String userId) {
List<com.iflytek.skillhub.domain.social.SkillStar> stars = new java.util.ArrayList<>();
int pageNumber = 0;

while (true) {
Page<com.iflytek.skillhub.domain.social.SkillStar> page = skillStarRepository.findByUserId(
userId,
PageRequest.of(pageNumber, STAR_PAGE_SIZE)
);
stars.addAll(page.getContent());

if (!page.hasNext()) {
return stars;
}
pageNumber++;
}
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
}

private SkillSummaryResponse toSummaryResponse(
Expand Down
1 change: 1 addition & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ error.namespace.id.notFound=Namespace not found: {0}
error.namespace.slug.notFound=Namespace not found: {0}
error.namespace.membership.required=Namespace membership required
error.namespace.admin.required=Namespace owner or admin role required
error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces
error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly
error.namespace.member.alreadyExists=User is already a namespace member
error.namespace.member.notFound=Member not found
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ error.namespace.id.notFound=未找到命名空间:{0}
error.namespace.slug.notFound=未找到命名空间:{0}
error.namespace.membership.required=需要先加入该命名空间
error.namespace.admin.required=需要命名空间管理员或所有者权限
error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间
error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色
error.namespace.member.alreadyExists=用户已经是该命名空间成员
error.namespace.member.notFound=未找到命名空间成员
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.iflytek.skillhub.controller;

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.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.MySkillAppService;
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.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.LocalDateTime;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class MeControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private NamespaceMemberRepository namespaceMemberRepository;

@MockBean
private DeviceAuthService deviceAuthService;

@MockBean
private MySkillAppService mySkillAppService;

@Test
void listMySkills_returns_paginated_items() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);

given(mySkillAppService.listMySkills("user-42", 1, 5))
.willReturn(new PageResponse<>(
List.of(new SkillSummaryResponse(
7L,
"copilot",
"Copilot",
"Assist with code review",
"ACTIVE",
12L,
3,
null,
0,
"1.0.0",
11L,
"PUBLISHED",
"team-ai",
LocalDateTime.of(2026, 3, 17, 12, 0),
false
)),
9,
1,
5
));

mockMvc.perform(get("/api/v1/me/skills")
.with(authentication(auth))
.param("page", "1")
.param("size", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].slug").value("copilot"))
.andExpect(jsonPath("$.data.total").value(9))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(5));
}

@Test
void listMyStars_returns_paginated_items() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);

given(mySkillAppService.listMyStars("user-42", 0, 12))
.willReturn(new PageResponse<>(List.of(), 0, 0, 12));

mockMvc.perform(get("/api/v1/me/stars").with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(12));
}
}
Loading
Loading