Files
JuYou/API/internal/service/drama_import.go
T
2026-08-25 17:59:42 +08:00

545 lines
18 KiB
Go

package service
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"unicode/utf16"
"unicode/utf8"
dramapkg "juhe-factory/api/internal/drama"
"juhe-factory/api/internal/model"
"github.com/google/uuid"
"github.com/richardlehane/mscfb"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const maxDramaImportBytes = 3 * 1024 * 1024
var chapterTitlePattern = regexp.MustCompile(`(?im)^\s*(?:#+\s*|[*+-]\s*)?((?:第[零〇一二三四五六七八九十百千万两\d]+[章节回卷篇部集幕].*)|(?:chapter\s+\d+.*))\s*$`)
type DramaChapter struct {
Title string `json:"title"`
Content string `json:"content,omitempty"`
CharCount int `json:"char_count"`
}
type DramaImportPreview struct {
ImportToken uuid.UUID `json:"import_token"`
HasChapters bool `json:"has_chapters"`
TotalCharacters int `json:"total_characters"`
Chapters []DramaChapter `json:"chapters"`
SingleEpisodeConfirmationNeeded bool `json:"single_episode_confirmation_required"`
}
func (s *Creative) PreviewDramaImport(userID, projectID uuid.UUID, filename string, data []byte, rawText string) (*DramaImportPreview, error) {
if err := s.requirePremiumProject(userID, projectID); err != nil {
return nil, err
}
if len(data) > maxDramaImportBytes || len([]byte(rawText)) > maxDramaImportBytes {
return nil, errors.New("小说文件或文本不能超过3MB")
}
text, sourceType, err := parseDramaSource(filename, data, rawText)
if err != nil {
return nil, err
}
text = normalizeDramaText(text)
if text == "" {
return nil, errors.New("未读取到有效小说正文")
}
chapters := splitDramaChapters(text)
metadata := make([]DramaChapter, 0, len(chapters))
for _, chapter := range chapters {
metadata = append(metadata, DramaChapter{Title: chapter.Title, CharCount: len([]rune(chapter.Content))})
}
previewJSON, _ := json.Marshal(map[string]any{"has_chapters": len(chapters) > 0, "chapters": metadata})
hash := sha256.Sum256([]byte(text))
session := model.DramaImportSession{
ID: uuid.New(), UserID: userID, ProjectID: projectID, SourceType: sourceType,
SourceFilename: cleanDramaFilename(filename), RawContent: text, ContentSHA256: hex.EncodeToString(hash[:]),
PreviewData: previewJSON, ExpiresAt: time.Now().Add(30 * time.Minute),
}
if err := s.DB.Create(&session).Error; err != nil {
return nil, err
}
return &DramaImportPreview{
ImportToken: session.ID, HasChapters: len(chapters) > 0, TotalCharacters: len([]rune(text)),
Chapters: metadata, SingleEpisodeConfirmationNeeded: len(chapters) == 0,
}, nil
}
func (s *Creative) ConfirmDramaImport(userID, projectID, token uuid.UUID, chaptersPerEpisode, startEpisodeNo int, treatAsSingle bool) ([]model.ProjectEpisode, error) {
if chaptersPerEpisode < 1 {
chaptersPerEpisode = 1
}
if startEpisodeNo < 1 {
return nil, errors.New("起始集数无效")
}
created := make([]model.ProjectEpisode, 0)
err := s.DB.Transaction(func(tx *gorm.DB) error {
var project model.CreativeProject
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
return gorm.ErrRecordNotFound
}
var session model.DramaImportSession
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND user_id=? AND project_id=?", token, userID, projectID).Take(&session).Error; err != nil {
return gorm.ErrRecordNotFound
}
if session.ConsumedAt != nil || time.Now().After(session.ExpiresAt) {
return errors.New("导入预览已失效,请重新上传")
}
chapters := splitDramaChapters(session.RawContent)
if len(chapters) == 0 {
if !treatAsSingle {
return errors.New("需要确认将全部内容作为一集导入")
}
chapters = []DramaChapter{{Title: fmt.Sprintf("第%d集", startEpisodeNo), Content: session.RawContent, CharCount: len([]rune(session.RawContent))}}
chaptersPerEpisode = 1
}
for offset, index := 0, 0; index < len(chapters); offset, index = offset+1, index+chaptersPerEpisode {
end := index + chaptersPerEpisode
if end > len(chapters) {
end = len(chapters)
}
group := chapters[index:end]
episodeNo := startEpisodeNo + offset
name := fmt.Sprintf("第%d集", episodeNo)
if len(group) == 1 && strings.TrimSpace(group[0].Title) != "" {
name = group[0].Title
}
name = truncateRunes(strings.TrimSpace(name), 160)
exists, err := episodeNameExists(tx, projectID, nil, name)
if err != nil {
return err
}
if exists {
return errors.New("同一项目下不可有同名剧集")
}
parts := make([]string, 0, len(group))
for _, chapter := range group {
parts = append(parts, strings.TrimSpace(chapter.Content))
}
content := strings.TrimSpace(strings.Join(parts, "\n\n"))
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
return fmt.Errorf("第%d集:%w", episodeNo, err)
}
episode := model.ProjectEpisode{ID: uuid.New(), ProjectID: projectID, EpisodeNo: episodeNo, Name: name, AudioSource: "video_audio", Status: "draft"}
if err := tx.Create(&episode).Error; err != nil {
return err
}
hash := sha256.Sum256([]byte(content))
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episode.ID, Title: episode.Name, RawContent: content, CharCount: len([]rune(content)), SourceType: session.SourceType, SourceFilename: session.SourceFilename, ContentSHA256: hex.EncodeToString(hash[:])}
if err := tx.Create(&source).Error; err != nil {
return err
}
created = append(created, episode)
}
now := time.Now()
return tx.Model(&session).Updates(map[string]any{"consumed_at": now, "raw_content": ""}).Error
})
return created, err
}
func (s *Creative) EpisodeSource(userID, projectID, episodeID uuid.UUID) (*model.EpisodeSource, error) {
var source model.EpisodeSource
result := s.DB.Table("episode_sources source").Joins("JOIN project_episodes episode ON episode.id=source.episode_id AND episode.deleted_at IS NULL").Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.deleted_at IS NULL").Where("source.episode_id=? AND episode.project_id=? AND project.user_id=?", episodeID, projectID, userID).Limit(1).Find(&source)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
return &source, nil
}
func (s *Creative) SaveEpisodeSource(userID, projectID, episodeID uuid.UUID, content, title string) (*model.EpisodeSource, error) {
content = normalizeDramaText(content)
if err := dramapkg.ValidateEpisodeContent(content); err != nil {
return nil, err
}
if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
return nil, err
}
hash := sha256.Sum256([]byte(content))
source := model.EpisodeSource{ID: uuid.New(), EpisodeID: episodeID, Title: truncateRunes(strings.TrimSpace(title), 200), RawContent: content, CharCount: len([]rune(content)), SourceType: "pasted_text", ContentSHA256: hex.EncodeToString(hash[:])}
err := s.DB.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "episode_id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "raw_content", "char_count", "source_type", "source_filename", "content_sha256", "updated_at"})}).Create(&source).Error
return &source, err
}
func (s *Creative) requirePremiumProject(userID, projectID uuid.UUID) error {
var count int64
err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND project_type='premium_drama' AND deleted_at IS NULL", projectID, userID).Count(&count).Error
if err != nil {
return err
}
if count == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *Creative) requirePremiumEpisode(userID, projectID, episodeID uuid.UUID) error {
var count int64
err := s.DB.Table("project_episodes episode").Joins("JOIN creative_projects project ON project.id=episode.project_id").Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL AND project.user_id=? AND project.project_type='premium_drama' AND project.deleted_at IS NULL", episodeID, projectID, userID).Count(&count).Error
if err != nil {
return err
}
if count == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func parseDramaSource(filename string, data []byte, rawText string) (string, string, error) {
if strings.TrimSpace(rawText) != "" {
if len(data) > 0 {
return "", "", errors.New("文件和粘贴文本只能选择一种")
}
if looksLikeBinary([]byte(rawText)) {
return "", "", errors.New("粘贴内容包含二进制控制字符")
}
if looksLikeHTMLOrScript([]byte(rawText)) {
return "", "", errors.New("不支持HTML或脚本内容")
}
return rawText, "pasted_text", nil
}
if len(data) == 0 {
return "", "", errors.New("请选择小说文件或粘贴文本")
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".txt":
text, err := decodePlainText(data)
return text, "txt", err
case ".docx":
text, err := extractDOCX(data)
return text, "docx", err
case ".doc":
text, err := extractLegacyDOC(data)
return text, "doc", err
default:
return "", "", errors.New("仅支持TXT、DOC和DOCX文件")
}
}
func decodePlainText(data []byte) (string, error) {
if hasDisallowedBinarySignature(data) || bytes.IndexByte(data, 0) >= 0 || looksLikeBinary(data) {
return "", errors.New("TXT文件包含二进制内容")
}
if looksLikeHTMLOrScript(data) {
return "", errors.New("不支持HTML或脚本文件")
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
if utf8.Valid(data) {
return string(data), nil
}
decoded, err := io.ReadAll(transform.NewReader(bytes.NewReader(data), simplifiedchinese.GB18030.NewDecoder()))
if err != nil || !utf8.Valid(decoded) {
return "", errors.New("TXT文件编码不支持,请使用UTF-8或GB18030")
}
return string(decoded), nil
}
func extractDOCX(data []byte) (string, error) {
if len(data) < 4 || !bytes.Equal(data[:4], []byte{'P', 'K', 3, 4}) {
return "", errors.New("DOCX文件结构无效")
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", errors.New("DOCX文件结构无效")
}
if len(reader.File) > 1000 {
return "", errors.New("DOCX文件包含过多内容")
}
var document, contentTypes []byte
var expanded uint64
for _, file := range reader.File {
expanded += file.UncompressedSize64
if expanded > 20*1024*1024 {
return "", errors.New("DOCX展开后内容过大")
}
name := strings.ToLower(strings.ReplaceAll(file.Name, "\\", "/"))
if strings.Contains(name, "vbaproject") || strings.Contains(name, "activex") || strings.Contains(name, "embeddings/") {
return "", errors.New("DOCX包含宏、ActiveX或嵌入对象")
}
if name == "[content_types].xml" {
stream, openErr := file.Open()
if openErr != nil {
return "", openErr
}
contentTypes, err = io.ReadAll(io.LimitReader(stream, 1024*1024))
stream.Close()
if err != nil {
return "", err
}
}
if name == "word/document.xml" {
stream, openErr := file.Open()
if openErr != nil {
return "", openErr
}
document, err = io.ReadAll(io.LimitReader(stream, 12*1024*1024))
stream.Close()
if err != nil {
return "", err
}
}
}
contentTypeText := strings.ToLower(string(contentTypes))
if len(document) == 0 || !strings.Contains(contentTypeText, "wordprocessingml.document.main+xml") || strings.Contains(contentTypeText, "macroenabled") {
return "", errors.New("DOCX缺少Word正文结构")
}
decoder := xml.NewDecoder(bytes.NewReader(document))
var output strings.Builder
for {
token, tokenErr := decoder.Token()
if tokenErr == io.EOF {
break
}
if tokenErr != nil {
return "", errors.New("DOCX正文XML无效")
}
switch value := token.(type) {
case xml.CharData:
output.Write([]byte(value))
case xml.EndElement:
if value.Name.Local == "p" {
output.WriteString("\n")
} else if value.Name.Local == "tab" {
output.WriteString("\t")
}
}
}
return output.String(), nil
}
func extractLegacyDOC(data []byte) (string, error) {
magic := []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
if len(data) < len(magic) || !bytes.Equal(data[:len(magic)], magic) {
return "", errors.New("DOC文件结构无效")
}
reader, err := mscfb.New(bytes.NewReader(data))
if err != nil {
return "", errors.New("DOC文件结构无效")
}
streams := map[string][]byte{}
for entry, nextErr := reader.Next(); nextErr == nil; entry, nextErr = reader.Next() {
name := strings.ToLower(entry.Name)
if strings.Contains(name, "vba") || strings.Contains(name, "macros") || strings.Contains(name, "objectpool") {
return "", errors.New("DOC包含宏或嵌入对象")
}
if name == "worddocument" || name == "0table" || name == "1table" {
content, readErr := io.ReadAll(io.LimitReader(entry, 12*1024*1024))
if readErr != nil {
return "", readErr
}
streams[name] = content
}
}
word := streams["worddocument"]
if len(word) < 0x1AA || binary.LittleEndian.Uint16(word[:2]) != 0xA5EC {
return "", errors.New("DOC缺少有效WordDocument流")
}
flags := binary.LittleEndian.Uint16(word[0x0A:0x0C])
tableName := "0table"
if flags&0x0200 != 0 {
tableName = "1table"
}
table := streams[tableName]
fcClx := int(binary.LittleEndian.Uint32(word[0x1A2:0x1A6]))
lcbClx := int(binary.LittleEndian.Uint32(word[0x1A6:0x1AA]))
text, parseErr := extractDOCPieces(word, table, fcClx, lcbClx)
if parseErr != nil || strings.TrimSpace(text) == "" {
return "", errors.New("DOC正文无法安全解析")
}
return text, nil
}
func extractDOCPieces(word, table []byte, offset, length int) (string, error) {
if offset < 0 || length <= 0 || offset+length > len(table) {
return "", errors.New("invalid CLX")
}
clx := table[offset : offset+length]
pos := 0
for pos < len(clx) && clx[pos] == 0x01 {
if pos+3 > len(clx) {
return "", io.ErrUnexpectedEOF
}
size := int(binary.LittleEndian.Uint16(clx[pos+1 : pos+3]))
pos += 3 + size
}
if pos+5 > len(clx) || clx[pos] != 0x02 {
return "", errors.New("missing Pcdt")
}
plcSize := int(binary.LittleEndian.Uint32(clx[pos+1 : pos+5]))
pos += 5
if plcSize < 4 || pos+plcSize > len(clx) {
return "", errors.New("invalid PlcPcd")
}
plc := clx[pos : pos+plcSize]
pieces := (plcSize - 4) / 12
if pieces <= 0 || pieces > 100000 {
return "", errors.New("invalid piece count")
}
cpBytes := (pieces + 1) * 4
if cpBytes+pieces*8 > len(plc) {
return "", io.ErrUnexpectedEOF
}
var output strings.Builder
for i := 0; i < pieces; i++ {
cpStart := int(binary.LittleEndian.Uint32(plc[i*4 : i*4+4]))
cpEnd := int(binary.LittleEndian.Uint32(plc[(i+1)*4 : (i+1)*4+4]))
if cpEnd <= cpStart {
continue
}
pcd := cpBytes + i*8
rawFC := binary.LittleEndian.Uint32(plc[pcd+2 : pcd+6])
compressed := rawFC&0x40000000 != 0
fc := int(rawFC & 0x3FFFFFFF)
chars := cpEnd - cpStart
if compressed {
fc /= 2
if fc < 0 || fc+chars > len(word) {
continue
}
decoded, _ := io.ReadAll(transform.NewReader(bytes.NewReader(word[fc:fc+chars]), simplifiedchinese.GB18030.NewDecoder()))
output.Write(decoded)
} else {
byteLen := chars * 2
if fc < 0 || fc+byteLen > len(word) {
continue
}
units := make([]uint16, chars)
for j := 0; j < chars; j++ {
units[j] = binary.LittleEndian.Uint16(word[fc+j*2 : fc+j*2+2])
}
output.WriteString(string(utf16.Decode(units)))
}
}
return output.String(), nil
}
func splitDramaChapters(content string) []DramaChapter {
matches := chapterTitlePattern.FindAllStringSubmatchIndex(content, -1)
if len(matches) == 0 {
return nil
}
chapters := make([]DramaChapter, 0, len(matches))
for i, match := range matches {
start := match[0]
if i == 0 {
start = 0
}
end := len(content)
if i+1 < len(matches) {
end = matches[i+1][0]
}
body := strings.TrimSpace(content[start:end])
if body == "" {
continue
}
title := strings.TrimSpace(content[match[2]:match[3]])
chapters = append(chapters, DramaChapter{Title: title, Content: body, CharCount: len([]rune(body))})
}
return chapters
}
func normalizeDramaText(value string) string {
value = strings.ReplaceAll(value, "\x00", "")
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
lines := strings.Split(value, "\n")
cleaned := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if strings.TrimSpace(line) == "" {
if !blank {
cleaned = append(cleaned, "")
blank = true
}
continue
}
cleaned = append(cleaned, line)
blank = false
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
func looksLikeBinary(data []byte) bool {
if len(data) == 0 {
return false
}
controls := 0
for _, b := range data {
if b < 0x09 || (b > 0x0D && b < 0x20) {
controls++
}
}
return controls*100/len(data) > 2
}
func looksLikeHTMLOrScript(data []byte) bool {
sample := strings.ToLower(string(data))
markers := []string{"<!doctype html", "<html", "<head", "<body", "<script", "</script", "<iframe", "<object", "<embed", "javascript:", "vbscript:", "onerror=", "onload="}
for _, marker := range markers {
if strings.Contains(sample, marker) {
return true
}
}
return false
}
func hasDisallowedBinarySignature(data []byte) bool {
signatures := [][]byte{
{0x25, 0x50, 0x44, 0x46, 0x2D},
{0x50, 0x4B, 0x03, 0x04},
{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1},
{0x89, 0x50, 0x4E, 0x47},
{0xFF, 0xD8, 0xFF},
{0x47, 0x49, 0x46, 0x38},
{0x4D, 0x5A},
{0x7F, 0x45, 0x4C, 0x46},
{0x52, 0x61, 0x72, 0x21},
{0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C},
{0x1F, 0x8B},
}
data = bytes.TrimSpace(data)
for _, signature := range signatures {
if bytes.HasPrefix(data, signature) {
return true
}
}
return false
}
func cleanDramaFilename(value string) string {
value = filepath.Base(strings.TrimSpace(value))
return truncateRunes(value, 255)
}
func truncateRunes(value string, limit int) string {
runes := []rune(value)
if len(runes) > limit {
return string(runes[:limit])
}
return value
}
// Stable chapter ordering is useful in tests and when import previews are reconstructed.
func sortEpisodesByNumber(items []model.ProjectEpisode) {
sort.SliceStable(items, func(i, j int) bool { return items[i].EpisodeNo < items[j].EpisodeNo })
}