fix(skill): harden ckjia skill binding and virtual dedup

This commit is contained in:
matevip 2026-05-03 20:32:23 +08:00
parent 6fe9b6e5f7
commit 68e1c6f50d
4 changed files with 60 additions and 16 deletions

View File

@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 技能管理接口
@ -65,6 +66,11 @@ public class SkillController {
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String scanStatus) {
IPage<SkillEntity> dbPage = skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus);
boolean mergeMcpVirtuals = page == 1
&& (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType));
boolean mergeAcpVirtuals = page == 1
&& (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType));
Set<String> realNames = (mergeMcpVirtuals || mergeAcpVirtuals) ? realSkillNames() : Set.of();
// RFC-090 §3.2 surface MCP servers as virtual skills on the
// first page so users see GitHub / Filesystem / etc. as cards
@ -72,14 +78,14 @@ public class SkillController {
// because virtual rows are unpaginated; a follow-up can push
// them through proper SQL UNION ALL when MCP server count gets
// into the dozens.
if (page == 1 && (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType))) {
if (mergeMcpVirtuals) {
try {
List<SkillEntity> mcpSkills = mcpSkillBridge.listMcpDerivedSkillEntities();
if (!mcpSkills.isEmpty()) {
// In-memory filter mirrors the DB filters so the
// user's filter chips still apply to virtual rows.
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<SkillEntity> filtered = mcpSkills.stream()
List<SkillEntity> filtered = filterShadowedVirtualSkills(mcpSkills, realNames).stream()
.filter(s -> kw.isEmpty()
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
@ -97,12 +103,12 @@ public class SkillController {
}
}
// RFC-090 §3.2 (parallel) same auto-bridge for ACP endpoints.
if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) {
if (mergeAcpVirtuals) {
try {
List<SkillEntity> acpSkills = acpSkillBridge.listAcpDerivedSkillEntities();
if (!acpSkills.isEmpty()) {
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<SkillEntity> filtered = acpSkills.stream()
List<SkillEntity> filtered = filterShadowedVirtualSkills(acpSkills, realNames).stream()
.filter(s -> kw.isEmpty()
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
@ -126,11 +132,13 @@ public class SkillController {
@GetMapping("/counts")
public R<Map<String, Long>> counts() {
Map<String, Long> result = skillService.countByType();
Set<String> realNames = realSkillNames();
// RFC-090 §3.2 virtual MCP-derived skills aren't in mate_skill,
// so countByType() misses them. Fold in the live count so the
// "MCP" and "all" tab badges match what the list endpoint shows.
try {
long virtualMcp = mcpSkillBridge.listMcpDerivedSkillEntities().size();
long virtualMcp = countUnshadowedVirtualSkills(
mcpSkillBridge.listMcpDerivedSkillEntities(), realNames);
if (virtualMcp > 0) {
result.merge("mcp", virtualMcp, Long::sum);
result.merge("all", virtualMcp, Long::sum);
@ -139,7 +147,8 @@ public class SkillController {
// Bridge failure must not break the badge fetch.
}
try {
long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size();
long virtualAcp = countUnshadowedVirtualSkills(
acpSkillBridge.listAcpDerivedSkillEntities(), realNames);
if (virtualAcp > 0) {
result.merge("acp", virtualAcp, Long::sum);
result.merge("all", virtualAcp, Long::sum);
@ -150,6 +159,32 @@ public class SkillController {
return R.ok(result);
}
/**
* Real skill rows own their slug. Same-name MCP/ACP virtual rows are
* shadowed even when the real row is disabled, matching the runtime status
* view and avoiding duplicate cards for the same capability. The comparison
* is name-only because {@code mate_skill.name} is the unique skill slug.
*/
static List<SkillEntity> filterShadowedVirtualSkills(List<SkillEntity> virtualSkills,
Set<String> realSkillNames) {
if (virtualSkills == null || virtualSkills.isEmpty()) return List.of();
Set<String> realNames = realSkillNames == null ? Set.of() : realSkillNames;
return virtualSkills.stream()
.filter(s -> s != null && !realNames.contains(s.getName()))
.toList();
}
static long countUnshadowedVirtualSkills(List<SkillEntity> virtualSkills,
Set<String> realSkillNames) {
return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size();
}
private Set<String> realSkillNames() {
return skillService.listSkills().stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
public R<SkillEntity> rescan(@PathVariable Long id) {

View File

@ -1,6 +1,8 @@
-- Seed default ckjia-shopping MCP server config (disabled by default).
-- Admin enables in Settings > MCP Connections after pointing url to their
-- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization.
-- Seed ckjia-shopping MCP server config (disabled by default).
-- The localhost URL below is a dev/test placeholder only. Production admins
-- must replace it in Settings > MCP Connections with the official CKJIA SaaS
-- domain or their private CKJIA deployment URL before enabling the server,
-- then configure CKJIA_MCP_KEY for authorization.
--
-- Column name is `url` (not `endpoint`) per McpServerEntity.
-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key
@ -22,7 +24,7 @@ VALUES (
'http://localhost:8085/sse',
'{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}',
FALSE,
'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).',
'CKJIA price comparison MCP server. Disabled by default; replace the dev/test localhost URL with the production CKJIA domain before enabling.',
30, 30, TRUE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
);

View File

@ -1,6 +1,8 @@
-- Seed default ckjia-shopping MCP server config (disabled by default).
-- Admin enables in Settings > MCP Connections after pointing url to their
-- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization.
-- Seed ckjia-shopping MCP server config (disabled by default).
-- The localhost URL below is a dev/test placeholder only. Production admins
-- must replace it in Settings > MCP Connections with the official CKJIA SaaS
-- domain or their private CKJIA deployment URL before enabling the server,
-- then configure CKJIA_MCP_KEY for authorization.
--
-- Column name is `url` (not `endpoint`) per McpServerEntity.
-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key
@ -20,7 +22,7 @@ SELECT 1000000903,
'http://localhost:8085/sse',
'{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}',
FALSE,
'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).',
'CKJIA price comparison MCP server. Disabled by default; replace the dev/test localhost URL with the production CKJIA domain before enabling.',
30, 30, TRUE,
NOW(), NOW(), 0
FROM dual

View File

@ -2,11 +2,15 @@
name: ckjia-shopping
nameZh: 参考价 - 比价购物
nameEn: CKJIA Shopping
version: "1.0.0"
version: "1.0.1"
icon: /skill-assets/ckjia-shopping/assets/ckjia_app_icon.png
description: "跨平台比价与购物推荐 / Cross-platform price comparison. 淘宝 / 京东 / 天猫 / 拼多多商品聚合搜索 + 拍图识物。需要先启用 ckjia-shopping MCP server 并配置 CKJIA_MCP_KEY 才能用。"
category: data
type: mcp
allowed-tools:
- ckjia_shopping_recommend
- ckjia_image_recognize
- ckjia_ping
tags:
- shopping
- price
@ -93,6 +97,7 @@ tags:
- 同一个 query 不要在一次对话里反复调用 —— ckjia 侧已有缓存,重复调用浪费配额
- 用户未登录时不必填 `user_id`;当前 Phase 1 所有调用以 API key owner 身份执行
- `mate_mcp_server` 里预置的 localhost URL 只用于本地开发/测试;生产启用前必须在 `Settings ▸ MCP Connections` 改成 ckjia 官方 SaaS 域名或私有部署域名
- API key 由管理员在 ckjia 控制台申请后填入 mateclaw `Settings ▸ MCP Connections``headers_json`,使用 `${CKJIA_MCP_KEY}` 环境变量占位符避免明文落库
- 触发 429 `rate_limited` 时按 `Retry-After` 等待一次,再失败就汇总现有结果而不是无限重试
@ -102,4 +107,4 @@ tags:
2. 选 `free` / `standard` tier 与勾选所需 scopes
3. 一次性获得明文 key形如 `ckjia_mcp_live_5fK8j2nQ…`
4. 在 mateclaw 部署环境配 `CKJIA_MCP_KEY=ckjia_mcp_live_xxx`
5. Settings ▸ MCP Connections 启用 `ckjia-shopping` 即可
5. Settings ▸ MCP Connections `ckjia-shopping` 的 URL 改为生产域名后再启用