Files
gochat/backend/internal/service/captain_skill_service.go
T
Rogeeandrogee 0f2b8d7857 H-289: add Captain Skill management API (#46)
* H-289: add Captain Skill management API

* H-289: guard Captain Skill updates with CAS

* H-289: version all Captain Skill updates

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-18 14:29:10 +08:00

229 lines
8.7 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
)
const (
CaptainSkillMaxInstructionsBytes = 32 * 1024
CaptainSkillMaxReferences = 20
CaptainSkillMaxReferenceBytes = 64 * 1024
)
var (
ErrCaptainSkillValidation = errors.New("captain skill validation failed")
ErrCaptainSkillConflict = errors.New("captain skill state conflict")
referenceKeyPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
)
type CaptainSkillReferenceRequest struct {
ReferenceKey string `json:"reference_key"`
ContentMD string `json:"content_md"`
}
type CaptainSkillRequest struct {
Name string `json:"name"`
Description string `json:"description"`
InstructionsMD string `json:"instructions_md"`
Status model.CaptainSkillStatus `json:"status"`
References []CaptainSkillReferenceRequest `json:"references"`
ExpectedVersion uint `json:"expected_version"`
}
type CaptainSkillListItem struct {
Skill model.CaptainSkill
ReferenceCount int64
Bound bool
BoundAssistantCount int64
}
type CaptainSkillService struct{ repo *repository.CaptainSkillRepo }
func NewCaptainSkillService(repo *repository.CaptainSkillRepo) *CaptainSkillService {
return &CaptainSkillService{repo: repo}
}
func (s *CaptainSkillService) Create(ctx context.Context, accountID uint, req *CaptainSkillRequest) (*model.CaptainSkill, error) {
if req.Status == "" {
req.Status = model.CaptainSkillStatusDraft
}
if err := validateCaptainSkillRequest(req); err != nil {
return nil, err
}
exists, err := s.repo.NameExists(ctx, accountID, strings.TrimSpace(req.Name), 0)
if err != nil {
return nil, err
}
if exists {
return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation)
}
skill := captainSkillFromRequest(accountID, 0, 1, req)
if err := s.repo.Create(ctx, skill); err != nil {
if exists, lookupErr := s.repo.NameExists(ctx, accountID, skill.Name, 0); lookupErr == nil && exists {
return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation)
}
return nil, err
}
return skill, nil
}
func (s *CaptainSkillService) Get(ctx context.Context, accountID, skillID uint) (*model.CaptainSkill, error) {
return s.repo.Get(ctx, accountID, skillID)
}
func (s *CaptainSkillService) List(ctx context.Context, accountID uint, assistantID *uint) ([]CaptainSkillListItem, error) {
if assistantID != nil {
if err := s.repo.AssistantExists(ctx, accountID, *assistantID); err != nil {
return nil, err
}
}
rows, err := s.repo.List(ctx, accountID, assistantID)
if err != nil {
return nil, err
}
result := make([]CaptainSkillListItem, len(rows))
for i := range rows {
result[i] = CaptainSkillListItem{rows[i].Skill, rows[i].ReferenceCount, rows[i].Bound, rows[i].BoundAssistantCount}
}
return result, nil
}
func (s *CaptainSkillService) Update(ctx context.Context, accountID, skillID uint, req *CaptainSkillRequest) (*model.CaptainSkill, error) {
if err := validateCaptainSkillRequest(req); err != nil {
return nil, err
}
if req.ExpectedVersion == 0 {
return nil, fmt.Errorf("%w: expected_version is required", ErrCaptainSkillValidation)
}
current, err := s.repo.Get(ctx, accountID, skillID)
if err != nil {
return nil, err
}
if current.Version != req.ExpectedVersion {
return nil, fmt.Errorf("%w: expected version %d, got %d", ErrCaptainSkillConflict, req.ExpectedVersion, current.Version)
}
exists, err := s.repo.NameExists(ctx, accountID, strings.TrimSpace(req.Name), skillID)
if err != nil {
return nil, err
}
if exists {
return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation)
}
next := captainSkillFromRequest(accountID, skillID, current.Version, req)
next.CreatedAt = current.CreatedAt
if captainSkillsEqual(current, next) {
return current, nil
}
next.Version++
referencesChanged := !captainSkillReferencesEqual(current, next)
enforceActivationLimit := current.Status != model.CaptainSkillStatusActive && req.Status == model.CaptainSkillStatusActive
if err := s.repo.Update(ctx, next, req.ExpectedVersion, referencesChanged, enforceActivationLimit); err != nil {
if errors.Is(err, repository.ErrCaptainSkillVersionConflict) {
return nil, fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err)
}
if errors.Is(err, repository.ErrCaptainAssistantSkillLimit) {
return nil, fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err)
}
if exists, lookupErr := s.repo.NameExists(ctx, accountID, next.Name, skillID); lookupErr == nil && exists {
return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation)
}
return nil, err
}
return s.repo.Get(ctx, accountID, skillID)
}
func (s *CaptainSkillService) Delete(ctx context.Context, accountID, skillID uint) error {
err := s.repo.Delete(ctx, accountID, skillID)
if errors.Is(err, repository.ErrCaptainSkillCannotDelete) {
return fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err)
}
return err
}
func (s *CaptainSkillService) Bind(ctx context.Context, accountID, assistantID, skillID uint) error {
err := s.repo.Bind(ctx, accountID, assistantID, skillID)
if errors.Is(err, repository.ErrCaptainSkillNotActive) || errors.Is(err, repository.ErrCaptainAssistantSkillLimit) {
return fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err)
}
return err
}
func (s *CaptainSkillService) Unbind(ctx context.Context, accountID, assistantID, skillID uint) error {
return s.repo.Unbind(ctx, accountID, assistantID, skillID)
}
func validateCaptainSkillRequest(req *CaptainSkillRequest) error {
if req == nil {
return fmt.Errorf("%w: skill is required", ErrCaptainSkillValidation)
}
name, description := strings.TrimSpace(req.Name), strings.TrimSpace(req.Description)
if name == "" || len(name) > 255 {
return fmt.Errorf("%w: name is required and must be at most 255 bytes", ErrCaptainSkillValidation)
}
if description == "" || len(description) > 1024 {
return fmt.Errorf("%w: description is required and must be at most 1024 bytes", ErrCaptainSkillValidation)
}
if req.InstructionsMD == "" || len(req.InstructionsMD) > CaptainSkillMaxInstructionsBytes {
return fmt.Errorf("%w: instructions_md is required and must be at most %d bytes", ErrCaptainSkillValidation, CaptainSkillMaxInstructionsBytes)
}
if req.Status != model.CaptainSkillStatusDraft && req.Status != model.CaptainSkillStatusActive && req.Status != model.CaptainSkillStatusArchived {
return fmt.Errorf("%w: invalid status", ErrCaptainSkillValidation)
}
if len(req.References) > CaptainSkillMaxReferences {
return fmt.Errorf("%w: at most %d references are allowed", ErrCaptainSkillValidation, CaptainSkillMaxReferences)
}
seen := make(map[string]struct{}, len(req.References))
for _, reference := range req.References {
if len(reference.ReferenceKey) > 255 || !referenceKeyPattern.MatchString(reference.ReferenceKey) {
return fmt.Errorf("%w: invalid reference_key", ErrCaptainSkillValidation)
}
if _, exists := seen[reference.ReferenceKey]; exists {
return fmt.Errorf("%w: duplicate reference_key", ErrCaptainSkillValidation)
}
seen[reference.ReferenceKey] = struct{}{}
if reference.ContentMD == "" || len(reference.ContentMD) > CaptainSkillMaxReferenceBytes {
return fmt.Errorf("%w: reference content_md is required and must be at most %d bytes", ErrCaptainSkillValidation, CaptainSkillMaxReferenceBytes)
}
}
return nil
}
func captainSkillFromRequest(accountID, skillID, version uint, req *CaptainSkillRequest) *model.CaptainSkill {
skill := &model.CaptainSkill{
ID: skillID, AccountID: accountID, Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description),
InstructionsMD: req.InstructionsMD, Status: req.Status, Version: version,
References: make([]model.CaptainSkillReference, len(req.References)),
}
for i, reference := range req.References {
skill.References[i] = model.CaptainSkillReference{SkillID: skillID, ReferenceKey: reference.ReferenceKey, ContentMD: reference.ContentMD, Position: i}
}
return skill
}
func captainSkillsEqual(a, b *model.CaptainSkill) bool {
return a.Status == b.Status && captainSkillContentEqual(a, b)
}
func captainSkillContentEqual(a, b *model.CaptainSkill) bool {
return a.Name == b.Name && a.Description == b.Description && a.InstructionsMD == b.InstructionsMD && captainSkillReferencesEqual(a, b)
}
func captainSkillReferencesEqual(a, b *model.CaptainSkill) bool {
if len(a.References) != len(b.References) {
return false
}
for i := range a.References {
if a.References[i].ReferenceKey != b.References[i].ReferenceKey || a.References[i].ContentMD != b.References[i].ContentMD || a.References[i].Position != b.References[i].Position {
return false
}
}
return true
}