fix(skill): merge partial PUT into existing row so SKILL.md saves stop 500ing (#93)

This commit is contained in:
matevip 2026-05-11 07:47:06 +08:00
parent 9cac7a31d1
commit b7f69dbefe

View File

@ -300,6 +300,29 @@ public class SkillService {
* </ul>
* 仍不允许name / version / author / skillType / builtin 这些是身份字段
* 改动会破坏绑定与解析
*
* <p>The UI sends a partial body that only contains the fields the
* user edited (Identity edit {@code nameZh/nameEn/description/tags/icon};
* Body edit {@code skillContent}, plus optional {@code sourceCode}).
* Every other field on the deserialized entity is {@code null}.
*
* <p>{@link SkillEntity} declares several
* {@code @TableField(updateStrategy = FieldStrategy.ALWAYS)} columns
* {@code name_zh}, {@code name_en}, {@code config_json},
* {@code source_code}, {@code skill_content}, {@code manifest_json},
* {@code security_scan_result}. Calling
* {@code skillMapper.updateById(partial)} would tell MyBatis Plus to
* write {@code NULL} into every ALWAYS column missing from the
* partial, wiping perfectly valid content on every save. The earlier
* #45 fix only protected the resolver's scan write-back; this path
* was still exposed (and surfaced as issue #93 when a partial PUT
* also took the workspace-sync branch with a {@code null} name and
* NPE'd inside {@code sanitizeName}).
*
* <p>Fix: merge non-null fields from the partial onto a copy of the
* existing row, then persist the merged entity. Same shape as the
* builtin branch above, just with a wider whitelist for dynamic
* skills.
*/
public SkillEntity updateSkill(SkillEntity skill) {
SkillEntity existing = getSkill(skill.getId());
@ -307,7 +330,7 @@ public class SkillService {
if (Boolean.TRUE.equals(existing.getBuiltin())) {
// Functional fields
existing.setEnabled(skill.getEnabled() != null ? skill.getEnabled() : existing.getEnabled());
existing.setConfigJson(skill.getConfigJson());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
existing.setDescription(skill.getDescription() != null ? skill.getDescription() : existing.getDescription());
if (skill.getSkillContent() != null) {
existing.setSkillContent(skill.getSkillContent());
@ -335,20 +358,36 @@ public class SkillService {
return existing;
}
// 非内置技能允许修改所有字段但不允许改为 builtin
skill.setBuiltin(false);
skillMapper.updateById(skill);
log.info("Updated skill: {}", skill.getName());
// 非内置技能merge non-null fields from the partial onto the
// existing row. name / skillType / builtin stay locked because
// they're identity fields whose change would orphan bindings
// and break the resolver.
if (skill.getDescription() != null) existing.setDescription(skill.getDescription());
if (skill.getIcon() != null) existing.setIcon(skill.getIcon());
if (skill.getVersion() != null && !skill.getVersion().isBlank()) existing.setVersion(skill.getVersion());
if (skill.getAuthor() != null) existing.setAuthor(skill.getAuthor());
if (skill.getEnabled() != null) existing.setEnabled(skill.getEnabled());
if (skill.getTags() != null) existing.setTags(skill.getTags());
if (skill.getNameZh() != null) existing.setNameZh(skill.getNameZh());
if (skill.getNameEn() != null) existing.setNameEn(skill.getNameEn());
if (skill.getConfigJson() != null) existing.setConfigJson(skill.getConfigJson());
if (skill.getSourceCode() != null) existing.setSourceCode(skill.getSourceCode());
if (skill.getSkillContent() != null) existing.setSkillContent(skill.getSkillContent());
if (skill.getManifestJson() != null) existing.setManifestJson(skill.getManifestJson());
existing.setBuiltin(false);
skillMapper.updateById(existing);
log.info("Updated skill: {}", existing.getName());
// skillContent 变更且约定工作区存在同步 SKILL.md
syncSkillContentToWorkspace(skill);
syncSkillContentToWorkspace(existing);
// 刷新 runtime cache
if (runtimeService != null) {
runtimeService.refreshActiveSkills();
}
return skill;
return existing;
}
/**