2026-06-26 08:37:04 +00:00
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"haixun-backend/internal/library/clock"
|
2026-06-28 08:28:42 +00:00
|
|
|
libcopy "haixun-backend/internal/library/copymission"
|
2026-06-26 08:37:04 +00:00
|
|
|
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"
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
goredis "github.com/redis/go-redis/v9"
|
2026-06-26 08:37:04 +00:00
|
|
|
"github.com/google/uuid"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type copyDraftUseCase struct {
|
2026-06-28 08:28:42 +00:00
|
|
|
repo domrepo.Repository
|
|
|
|
|
redis *goredis.Client
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 08:28:42 +00:00
|
|
|
func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase {
|
|
|
|
|
return ©DraftUseCase{repo: repo, redis: redis}
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
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("內容矩陣正在寫入中,請稍後再試")
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-06-28 08:28:42 +00:00
|
|
|
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
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|