* 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>
229 lines
8.7 KiB
Go
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
|
|
}
|