package usecase import ( "context" "strings" "haixun-backend/internal/library/clock" libcopy "haixun-backend/internal/library/copymission" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/model/copy_draft/domain/entity" domrepo "haixun-backend/internal/model/copy_draft/domain/repository" domusecase "haixun-backend/internal/model/copy_draft/domain/usecase" "github.com/google/uuid" goredis "github.com/redis/go-redis/v9" ) type copyDraftUseCase struct { repo domrepo.Repository redis *goredis.Client } func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase { return ©DraftUseCase{repo: repo, redis: redis} } func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } text := strings.TrimSpace(req.Text) if text == "" { return nil, app.For(code.Persona).InputMissingRequired("draft text is required") } draftType := strings.TrimSpace(req.DraftType) if draftType == "" { draftType = entity.DraftTypeViralReplica } item := &entity.CopyDraft{ ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, CopyMissionID: strings.TrimSpace(req.CopyMissionID), ScanPostID: strings.TrimSpace(req.ScanPostID), DraftType: draftType, SortOrder: req.SortOrder, Text: text, Angle: strings.TrimSpace(req.Angle), Hook: strings.TrimSpace(req.Hook), Rationale: strings.TrimSpace(req.Rationale), ReferenceNotes: strings.TrimSpace(req.ReferenceNotes), Sources: req.Sources, Status: "pending", CreateAt: clock.NowUnixNano(), } if err := u.repo.Create(ctx, item); err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.CreateManyRequest) ([]domusecase.CopyDraftSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } if len(req.Drafts) == 0 { return nil, app.For(code.Persona).InputMissingRequired("drafts is required") } items := make([]*entity.CopyDraft, 0, len(req.Drafts)) for idx, draftReq := range req.Drafts { text := strings.TrimSpace(draftReq.Text) if text == "" { continue } draftType := strings.TrimSpace(draftReq.DraftType) if draftType == "" { draftType = entity.DraftTypeMatrix } sortOrder := draftReq.SortOrder if sortOrder == 0 { sortOrder = idx + 1 } items = append(items, &entity.CopyDraft{ ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID), ScanPostID: strings.TrimSpace(draftReq.ScanPostID), DraftType: draftType, SortOrder: sortOrder, Text: text, Angle: strings.TrimSpace(draftReq.Angle), Hook: strings.TrimSpace(draftReq.Hook), Rationale: strings.TrimSpace(draftReq.Rationale), ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), Sources: draftReq.Sources, Status: "pending", CreateAt: clock.NowUnixNano(), }) } if len(items) == 0 { return nil, app.For(code.Persona).InputMissingRequired("draft text is required") } if err := u.repo.CreateMany(ctx, items); err != nil { return nil, err } out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(*item)) } return out, nil } func (u *copyDraftUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*domusecase.CopyDraftSummary, error) { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return nil, err } item, err := u.repo.Get(ctx, tenantID, ownerUID, personaID, draftID) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CopyDraftSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } draftID := strings.TrimSpace(req.DraftID) if draftID == "" { return nil, app.For(code.Persona).InputMissingRequired("draft_id is required") } patch := map[string]interface{}{} if req.Patch.Text != nil { text := strings.TrimSpace(*req.Patch.Text) if text == "" { return nil, app.For(code.Persona).InputMissingRequired("draft text cannot be empty") } patch["text"] = text } if req.Patch.Hook != nil { patch["hook"] = strings.TrimSpace(*req.Patch.Hook) } if req.Patch.Angle != nil { patch["angle"] = strings.TrimSpace(*req.Patch.Angle) } if req.Patch.Status != nil { status := strings.TrimSpace(*req.Patch.Status) if status != "" && status != "pending" && status != "ready" { return nil, app.For(code.Persona).InputMissingRequired("status must be pending or ready") } if status != "" { patch["status"] = status } } if len(patch) == 0 { return nil, app.For(code.Persona).InputMissingRequired("no fields to update") } item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, patch) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.MarkPublishedRequest) (*domusecase.CopyDraftSummary, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } draftID := strings.TrimSpace(req.DraftID) mediaID := strings.TrimSpace(req.MediaID) if draftID == "" || mediaID == "" { return nil, app.For(code.Persona).InputMissingRequired("draft_id and media_id are required") } now := clock.NowUnixNano() patch := map[string]interface{}{ "status": "published", "published_media_id": mediaID, "published_permalink": strings.TrimSpace(req.Permalink), "published_at": now, } item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, patch) if err != nil { return nil, err } summary := toSummary(*item) return &summary, nil } func (u *copyDraftUseCase) ReplaceMissionMatrix( ctx context.Context, tenantID, ownerUID, personaID, missionID string, drafts []domusecase.CreateRequest, ) ([]domusecase.CopyDraftSummary, error) { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return nil, err } missionID = strings.TrimSpace(missionID) if missionID == "" { return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") } var out []domusecase.CopyDraftSummary err := libcopy.WithMatrixReplaceLock(ctx, u.redis, tenantID, missionID, ownerUID, func() error { items, buildErr := buildMatrixDraftEntities(tenantID, ownerUID, personaID, missionID, drafts) if buildErr != nil { return buildErr } if replaceErr := u.repo.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, items); replaceErr != nil { return replaceErr } out = summariesFromEntities(items) return nil }) if err != nil { if strings.Contains(err.Error(), "already in progress") { return nil, app.For(code.Persona).ResInvalidState("內容矩陣正在寫入中,請稍後再試") } return nil, err } return out, nil } func buildMatrixDraftEntities( tenantID, ownerUID, personaID, missionID string, drafts []domusecase.CreateRequest, ) ([]*entity.CopyDraft, error) { if len(drafts) == 0 { return nil, nil } batchID := uuid.NewString() now := clock.NowUnixNano() items := make([]*entity.CopyDraft, 0, len(drafts)) for idx, draftReq := range drafts { text := strings.TrimSpace(draftReq.Text) if text == "" { continue } sortOrder := draftReq.SortOrder if sortOrder == 0 { sortOrder = idx + 1 } items = append(items, &entity.CopyDraft{ ID: uuid.NewString(), TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, CopyMissionID: missionID, ScanPostID: strings.TrimSpace(draftReq.ScanPostID), DraftType: entity.DraftTypeMatrix, MatrixBatchID: batchID, SortOrder: sortOrder, Text: text, Angle: strings.TrimSpace(draftReq.Angle), Hook: strings.TrimSpace(draftReq.Hook), Rationale: strings.TrimSpace(draftReq.Rationale), ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), Sources: draftReq.Sources, Status: "pending", CreateAt: now, }) } if len(items) == 0 { return nil, app.For(code.Persona).InputMissingRequired("draft text is required") } return items, nil } func summariesFromEntities(items []*entity.CopyDraft) []domusecase.CopyDraftSummary { out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { if item == nil { continue } out = append(out, toSummary(*item)) } return out } func (u *copyDraftUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return err } draftID = strings.TrimSpace(draftID) if draftID == "" { return app.For(code.Persona).InputMissingRequired("draft_id is required") } return u.repo.Delete(ctx, tenantID, ownerUID, personaID, draftID) } func (u *copyDraftUseCase) DeleteMissionMatrixDrafts( ctx context.Context, req domusecase.DeleteMissionMatrixDraftsRequest, ) (int, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return 0, err } missionID := strings.TrimSpace(req.MissionID) if missionID == "" { return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") } count, err := u.repo.DeleteMany( ctx, req.TenantID, req.OwnerUID, req.PersonaID, missionID, entity.DraftTypeMatrix, req.DraftIDs, ) if err != nil { return 0, err } if len(req.DraftIDs) > 0 && count == 0 { return 0, app.For(code.Persona).ResNotFound("找不到要刪除的矩陣草稿") } return int(count), nil } func (u *copyDraftUseCase) ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return err } missionID = strings.TrimSpace(missionID) if missionID == "" { return app.For(code.Persona).InputMissingRequired("copy_mission_id is required") } return u.repo.DeleteByMission(ctx, tenantID, ownerUID, personaID, missionID) } func (u *copyDraftUseCase) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]domusecase.CopyDraftSummary, error) { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return nil, err } items, err := u.repo.ListByMission(ctx, tenantID, ownerUID, personaID, missionID, limit) if err != nil { return nil, err } out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(item)) } return out, nil } func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.CopyDraftSummary, error) { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return nil, err } items, err := u.repo.List(ctx, tenantID, ownerUID, personaID, limit) if err != nil { return nil, err } out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(item)) } return out, nil } func requireActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Auth).AuthUnauthorized("missing actor") } if strings.TrimSpace(personaID) == "" { return app.For(code.Persona).InputMissingRequired("persona_id is required") } return nil } func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary { return domusecase.CopyDraftSummary{ ID: item.ID, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, DraftType: item.DraftType, SortOrder: item.SortOrder, Text: item.Text, Angle: item.Angle, Hook: item.Hook, Rationale: item.Rationale, ReferenceNotes: item.ReferenceNotes, Sources: item.Sources, Status: item.Status, PublishedMediaID: item.PublishedMediaID, PublishedPermalink: item.PublishedPermalink, PublishedAt: item.PublishedAt, CreateAt: item.CreateAt, } }