package usecase import ( "archive/zip" "bytes" "context" "encoding/base64" "encoding/xml" "math" "path/filepath" "regexp" "strings" "haixun-backend/internal/library/clock" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/model/content_ops/domain/entity" domrepo "haixun-backend/internal/model/content_ops/domain/repository" domusecase "haixun-backend/internal/model/content_ops/domain/usecase" "github.com/google/uuid" ) type useCase struct{ repo domrepo.Repository } func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} } func requireActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" { return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required") } return nil } func clampScore(n int) int { if n < 0 { return 0 } if n > 100 { return 100 } return n } func finalScore(heat, fit, interaction, extend, risk int) float64 { score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20 return math.Round(score*10) / 10 } func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } name := strings.TrimSpace(req.Name) if name == "" { return nil, app.For(code.Persona).InputMissingRequired("topic name is required") } now := clock.NowUnixNano() item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now} item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore) if err := u.repo.CreateTopicCandidate(ctx, item); err != nil { return nil, err } out := topicSummary(*item) return &out, nil } func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) { items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.TopicCandidateSummary, 0, len(items)) for _, item := range items { out = append(out, topicSummary(item)) } return out, nil } func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" { return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required") } now := clock.NowUnixNano() item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now} if err := u.repo.CreateContentPlan(ctx, item); err != nil { return nil, err } out := planSummary(*item) return &out, nil } func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) { m := map[string]any{"update_at": clock.NowUnixNano()} setString := func(key string, value *string) { if value != nil { m[key] = strings.TrimSpace(*value) } } setString("topic", patch.Topic) setString("mission", patch.Mission) setString("target_audience", patch.TargetAudience) setString("angle", patch.Angle) setString("opening_type", patch.OpeningType) setString("body_type", patch.BodyType) setString("emotion", patch.Emotion) setString("ending_type", patch.EndingType) setString("cta_type", patch.CtaType) setString("status", patch.Status) if patch.RiskLevel != nil { m["risk_level"] = normalizeRisk(*patch.RiskLevel) } if patch.RequiresHumanReview != nil { m["requires_human_review"] = *patch.RequiresHumanReview } if patch.Avoid != nil { m["avoid"] = patch.Avoid } if patch.SelectedKnowledge != nil { m["selected_knowledge"] = patch.SelectedKnowledge } item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m) if err != nil { return nil, err } out := planSummary(*item) return &out, nil } func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) { item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id) if err != nil { return nil, err } out := planSummary(*item) return &out, nil } func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) { items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.ContentPlanSummary, 0, len(items)) for _, item := range items { out = append(out, planSummary(item)) } return out, nil } func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } decision := strings.TrimSpace(req.Decision) if decision == "" { return nil, app.For(code.Persona).InputMissingRequired("decision is required") } item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()} if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil { return nil, err } out := feedbackSummary(*item) return &out, nil } func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) { items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.FeedbackEventSummary, 0, len(items)) for _, item := range items { out = append(out, feedbackSummary(item)) } return out, nil } func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } raw, err := decodeSourceContent(req) if err != nil { return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) } chunksText := splitChunks(raw, 900) now := clock.NowUnixNano() sourceID := uuid.NewString() item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now} chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText)) for _, text := range chunksText { chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now}) } if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil { return nil, err } out := sourceSummary(*item) return &out, nil } func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) { items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.KnowledgeSourceSummary, 0, len(items)) for _, item := range items { out = append(out, sourceSummary(item)) } return out, nil } func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) { items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.KnowledgeChunkSummary, 0, len(items)) for _, item := range items { out = append(out, chunkSummary(item)) } return out, nil } func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" { return nil, app.For(code.Persona).InputMissingRequired("type and name are required") } now := clock.NowUnixNano() weight := req.Weight if weight <= 0 { weight = 50 } item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now} if err := u.repo.CreateFormulaPool(ctx, item); err != nil { return nil, err } out := formulaSummary(*item) return &out, nil } func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) { items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } if len(items) == 0 { _ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID) items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) } out := make([]domusecase.FormulaPoolSummary, 0, len(items)) for _, item := range items { out = append(out, formulaSummary(item)) } return out, nil } func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error { defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}} for _, def := range defs { def.TenantID = tenantID def.OwnerUID = ownerUID def.PersonaID = personaID if _, err := u.CreateFormulaPool(ctx, def); err != nil { return err } } return nil } func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) { if strings.TrimSpace(req.ContentBase64) != "" { data, err := base64.StdEncoding.DecodeString(req.ContentBase64) if err != nil { return "", err } if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") { return parseXLSX(data) } return string(data), nil } return strings.TrimSpace(req.Content), nil } type xlsxText struct { T string `xml:",chardata"` } func parseXLSX(data []byte) (string, error) { zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return "", err } var b strings.Builder for _, f := range zr.File { if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") { continue } rc, err := f.Open() if err != nil { continue } var texts []xlsxText _ = xml.NewDecoder(rc).Decode(&texts) _ = rc.Close() for _, t := range texts { if s := strings.TrimSpace(t.T); s != "" { b.WriteString(s) b.WriteString("\n") } } } text := strings.TrimSpace(b.String()) if text == "" { return "", nil } return text, nil } func splitChunks(raw string, max int) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } paras := strings.Split(raw, "\n") var out []string var b strings.Builder for _, p := range paras { p = strings.TrimSpace(p) if p == "" { continue } if b.Len()+len([]rune(p)) > max && b.Len() > 0 { out = append(out, b.String()) b.Reset() } if b.Len() > 0 { b.WriteString("\n") } b.WriteString(p) } if b.Len() > 0 { out = append(out, b.String()) } return out } var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`) func inferTopics(text string) []string { seen := map[string]bool{} var out []string for _, m := range topicRE.FindAllString(text, 20) { if !seen[m] && len(out) < 8 { seen[m] = true out = append(out, m) } } return out } func inferStyleTags(text string) []string { var out []string if strings.Contains(text, "我") { out = append(out, "第一人稱") } if strings.Contains(text, "?") || strings.Contains(text, "?") { out = append(out, "提問") } if strings.Contains(text, "。") { out = append(out, "自然標點") } return out } func inferRisk(text string) string { lower := strings.ToLower(text) for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} { if strings.Contains(lower, k) { return "medium" } } return "low" } func normalizeRisk(raw string) string { raw = strings.ToLower(strings.TrimSpace(raw)) if raw == "high" || raw == "low" { return raw } return "medium" } func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary { return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} } func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary { return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} } func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary { return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt} } func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary { return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt} } func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary { return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt} } func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary { return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} }