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{" 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 }) }