package model import ( "encoding/json" "net/url" "regexp" "strings" "time" "unicode/utf8" "git.sundynix.cn/Blizzard/Veyra/internal/apperror" ) type Candidate struct { ID string `db:"id" json:"id"` LatestRevision int `db:"latest_revision" json:"latest_revision"` PublishedRevisionID *string `db:"published_revision_id" json:"published_revision_id"` CreatedAt time.Time `db:"created_at" json:"created_at"` } func (Candidate) TableName() string { return "veyra_candidate" } type CandidateEvidence struct { SourceURL string `json:"source_url"` Locator string `json:"locator"` Summary string `json:"summary"` } type CandidateReferencePlan struct { EntryLow string `json:"entry_low"` EntryHigh string `json:"entry_high"` StopLoss string `json:"stop_loss"` TargetPrice string `json:"target_price"` } type CandidateContent struct { Symbol string `json:"symbol"` Exchange string `json:"exchange"` Name string `json:"name"` StrategyCode string `json:"strategy_code"` Title string `json:"title"` Thesis string `json:"thesis"` Evidence []CandidateEvidence `json:"evidence"` CounterEvidence string `json:"counter_evidence"` Invalidation string `json:"invalidation"` ReferencePlan CandidateReferencePlan `json:"reference_plan"` } type CandidateRevision struct { AIAnalysis *CandidateAnalysis `json:"ai_analysis,omitempty"` Screening *StrategyScreening `json:"screening,omitempty"` ID string `db:"id" json:"id"` CandidateID string `db:"candidate_id" json:"candidate_id"` Revision int `db:"revision" json:"revision"` Version int64 `db:"version" json:"version"` Status string `db:"status" json:"status"` DataMode string `db:"data_mode" json:"data_mode"` Snapshot CandidateContent `db:"snapshot" json:"content"` AuthorID string `db:"author_id" json:"author_id"` ObservedAt time.Time `db:"observed_at" json:"observed_at"` ValidUntil time.Time `db:"valid_until" json:"valid_until"` PublishedAt *time.Time `db:"published_at" json:"published_at"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` IsLatest bool `json:"is_latest"` WithdrawalReason string `json:"withdrawal_reason"` IsActivePublication bool `json:"is_active_publication"` } func (CandidateRevision) TableName() string { return "veyra_candidate_revision" } type CandidateReview struct { ID string `db:"id" json:"id"` RevisionID string `db:"revision_id" json:"revision_id"` ReviewerID string `db:"reviewer_id" json:"reviewer_id"` Decision string `db:"decision" json:"decision"` InputVersion int64 `db:"input_version" json:"input_version"` Reason string `db:"reason" json:"reason"` CreatedAt time.Time `db:"created_at" json:"created_at"` } func (CandidateReview) TableName() string { return "veyra_candidate_review" } type CandidateDraftInput struct { Content CandidateContent `json:"content"` ObservedAt time.Time `json:"observed_at"` ValidUntil time.Time `json:"valid_until"` } type CandidateRevisionInput struct { CandidateDraftInput ExpectedVersion int64 `json:"expected_version"` } type CandidateActionInput struct { ExpectedVersion int64 `json:"expected_version"` Reason string `json:"reason"` } type CandidateDetail struct { CandidateRevision Reviews []CandidateReview `json:"reviews"` } var symbolPattern = regexp.MustCompile(`^[0-9]{6}$`) func textValid(s string, max int) bool { return strings.TrimSpace(s) != "" && utf8.RuneCountInString(s) <= max } func ValidateDraft(input CandidateDraftInput, now time.Time) error { c := input.Content if !symbolPattern.MatchString(c.Symbol) || c.Exchange != "SSE" && c.Exchange != "SZSE" || !textValid(c.Name, 80) || !textValid(c.Title, 120) || !textValid(c.Thesis, 2000) || !textValid(c.CounterEvidence, 1000) || !textValid(c.Invalidation, 1000) { return apperror.ErrInvalidInput } switch c.StrategyCode { case "trend_following", "pullback", "event_driven": default: return apperror.ErrInvalidInput } if len(c.Evidence) < 1 || len(c.Evidence) > 8 { return apperror.ErrInvalidInput } for _, e := range c.Evidence { u, err := url.Parse(e.SourceURL) if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(e.SourceURL) > 2048 || !textValid(e.Locator, 200) || !textValid(e.Summary, 1000) { return apperror.ErrInvalidInput } } low, e1 := ParseMoney(c.ReferencePlan.EntryLow) high, e2 := ParseMoney(c.ReferencePlan.EntryHigh) stop, e3 := ParseMoney(c.ReferencePlan.StopLoss) target, e4 := ParseMoney(c.ReferencePlan.TargetPrice) if e1 != nil || e2 != nil || e3 != nil || e4 != nil || stop <= 0 || stop >= low || low > high || target <= high { return apperror.ErrInvalidInput } if input.ObservedAt.IsZero() || input.ObservedAt.After(now.Add(time.Minute)) || input.ObservedAt.Before(now.Add(-24*time.Hour)) || !input.ValidUntil.After(now) || input.ValidUntil.After(now.Add(7*24*time.Hour)) || !input.ValidUntil.After(input.ObservedAt) { return apperror.ErrInvalidInput } encoded, err := json.Marshal(c) if err != nil || len(encoded) > 12<<10 { return apperror.ErrInvalidInput } return nil } // NextStatus is the shared state rule; repositories recheck it under the aggregate lock. func NextStatus(r CandidateRevision, action string, now time.Time) (string, error) { if action == "WITHDRAW" { if r.Status != "PUBLISHED" || !r.IsActivePublication { return "", apperror.ErrConflict } return "WITHDRAWN", nil } if !r.IsLatest { return "", apperror.ErrConflict } switch action { case "SUBMIT": if r.Status != "DRAFT" { return "", apperror.ErrConflict } if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil { return "", apperror.ErrInvalidInput } return "PENDING_REVIEW", nil case "PUBLISH": if r.Status != "PENDING_REVIEW" { return "", apperror.ErrConflict } if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil { return "", apperror.ErrInvalidInput } return "PUBLISHED", nil case "RETURN": if r.Status != "PENDING_REVIEW" { return "", apperror.ErrConflict } return "RETURNED", nil default: return "", apperror.ErrInvalidInput } } func ValidateReason(reason string) error { if !textValid(reason, 1000) { return apperror.ErrInvalidInput } return nil }