Files
2026-04-13 14:32:32 +08:00

353 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var defaultBookSeeds = []model.Book{
{BookKey: "main", Title: "一场Soul的创业实验", Subtitle: "来自派对房的真实故事", IconEmoji: "📖", SortOrder: 0, Status: "published"},
{BookKey: "ip", Title: "卡若的IP财富旅程", Subtitle: "从0到1的IP变现实录", IconEmoji: "💰", SortOrder: 1, Status: "published"},
{BookKey: "daily2026", Title: "2026每日派对干货", Subtitle: "每一场都是真金白银", IconEmoji: "🔥", SortOrder: 2, Status: "published"},
{BookKey: "gps", Title: "游戏明星GPS导航", Subtitle: "教你在游戏领域成为明星", IconEmoji: "🎮", SortOrder: 3, Status: "published"},
}
// part_id → bookKey 映射(含旧 key 兼容),用于自动迁移章节到对应书
var partToBookKey = map[string]string{
"part-new-17755513182": "ip",
"part-2026-daily": "daily2026",
}
// bookKeyAliases 旧 bookKey → 播种 bookKey兼容已有数据库中的别名
var bookKeyAliases = map[string]string{
"ip-journey": "ip",
"daily-party": "daily2026",
}
var ensureBooksOnce sync.Once
func ensureDefaultBooks(db *gorm.DB) {
ensureBooksOnce.Do(func() {
var count int64
db.Model(&model.Book{}).Count(&count)
if count == 0 {
for _, seed := range defaultBookSeeds {
db.Create(&seed)
}
log.Printf("[Books] 已播种 %d 本默认书", len(defaultBookSeeds))
}
fixBookTitlesAndEmoji(db)
migrateChaptersToBooks(db)
restructureBookParts(db)
})
}
// fixBookTitlesAndEmoji 自动修正书名和 emoji标题去掉「场」补全 emoji
func fixBookTitlesAndEmoji(db *gorm.DB) {
seedByKey := make(map[string]model.Book)
for _, s := range defaultBookSeeds {
seedByKey[s.BookKey] = s
}
var books []model.Book
db.Find(&books)
for _, b := range books {
key := b.BookKey
if alias, ok := bookKeyAliases[key]; ok {
key = alias
}
seed, ok := seedByKey[key]
if !ok {
continue
}
updates := map[string]interface{}{}
if b.Title != seed.Title {
updates["title"] = seed.Title
}
if b.IconEmoji == "" && seed.IconEmoji != "" {
updates["icon_emoji"] = seed.IconEmoji
}
if b.Subtitle == "" && seed.Subtitle != "" {
updates["subtitle"] = seed.Subtitle
}
if len(updates) > 0 {
db.Model(&model.Book{}).Where("id = ?", b.ID).Updates(updates)
log.Printf("[Books] 自动修正 book_id=%d: %v", b.ID, updates)
}
}
}
func migrateChaptersToBooks(db *gorm.DB) {
var books []model.Book
db.Find(&books)
keyToID := make(map[string]int)
for _, b := range books {
keyToID[b.BookKey] = b.ID
if alias, ok := bookKeyAliases[b.BookKey]; ok {
keyToID[alias] = b.ID
}
}
for partID, bookKey := range partToBookKey {
targetBookID, ok := keyToID[bookKey]
if !ok {
continue
}
res := db.Model(&model.Chapter{}).
Where("part_id = ? AND book_id != ?", partID, targetBookID).
Update("book_id", targetBookID)
if res.RowsAffected > 0 {
log.Printf("[Books] 已将 %d 篇 part_id=%s 的章节迁移到 book_id=%d (%s)", res.RowsAffected, partID, targetBookID, bookKey)
}
}
}
// restructureBookParts 按 bookKey 拆分篇章IP 按 PART1-5、2026 按月份
func restructureBookParts(db *gorm.DB) {
var books []model.Book
db.Find(&books)
for _, b := range books {
key := b.BookKey
if alias, ok := bookKeyAliases[key]; ok {
key = alias
}
switch key {
case "ip":
restructureIPBookParts(db, b.ID)
case "daily2026":
restructure2026BookMonthly(db, b.ID)
}
}
}
// restructureIPBookParts IP 书:把单篇拆成 PART1-5 独立篇
func restructureIPBookParts(db *gorm.DB, bookID int) {
var count int64
db.Model(&model.Chapter{}).Where("book_id = ? AND part_id = ?", bookID, "part-new-17755513182").Count(&count)
if count == 0 {
return
}
res := db.Exec(
`UPDATE chapters SET part_id = REPLACE(chapter_id, 'chapter-', 'part-'), part_title = chapter_title WHERE book_id = ? AND part_id = ?`,
bookID, "part-new-17755513182",
)
if res.RowsAffected > 0 {
log.Printf("[Books] IP书: 已将 %d 节按 PART 拆为独立篇", res.RowsAffected)
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
}
}
var sessionNumRe = regexp.MustCompile(`第(\d+)场`)
// restructure2026BookMonthly 2026 干货书:按月份拆分篇
func restructure2026BookMonthly(db *gorm.DB, bookID int) {
var count int64
db.Model(&model.Chapter{}).Where("book_id = ? AND part_id = ?", bookID, "part-2026-daily").Count(&count)
if count == 0 {
return
}
var sections []model.Chapter
db.Select("id, section_title, created_at").Where("book_id = ? AND part_id = ?", bookID, "part-2026-daily").Find(&sections)
for _, s := range sections {
month := determine2026Month(s)
partID := fmt.Sprintf("part-2026-%02d", month)
partTitle := fmt.Sprintf("%d月", month)
chapterID := fmt.Sprintf("chapter-2026-%02d", month)
db.Model(&model.Chapter{}).Where("id = ?", s.ID).Updates(map[string]interface{}{
"part_id": partID,
"part_title": partTitle,
"chapter_id": chapterID,
"chapter_title": partTitle,
})
}
if len(sections) > 0 {
log.Printf("[Books] 2026书: 已将 %d 节按月份拆分", len(sections))
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
}
}
func determine2026Month(s model.Chapter) int {
if s.CreatedAt.Year() >= 2025 {
return int(s.CreatedAt.Month())
}
m := sessionNumRe.FindStringSubmatch(s.SectionTitle)
if len(m) < 2 {
return 2
}
num, _ := strconv.Atoi(m[1])
switch {
case num < 113:
return 2
case num < 139:
return 3
default:
return 4
}
}
type bookWithSectionCount struct {
model.Book
SectionCount int64 `json:"sectionCount"`
}
// BooksList GET /api/miniprogram/book/books — 小程序获取所有已发布的书籍列表(含章节数)
func BooksList(c *gin.Context) {
db := database.DB()
ensureDefaultBooks(db)
var books []model.Book
if err := db.Where("status = ?", "published").Order("sort_order ASC, id ASC").Find(&books).Error; err != nil || len(books) == 0 {
books = []model.Book{defaultBookSeeds[0]}
}
out := make([]bookWithSectionCount, 0, len(books))
for _, b := range books {
var cnt int64
db.Model(&model.Chapter{}).Where("book_id = ?", b.ID).Count(&cnt)
out = append(out, bookWithSectionCount{Book: b, SectionCount: cnt})
}
c.JSON(http.StatusOK, gin.H{"success": true, "books": out})
}
// DBBooksAction GET/POST/PUT/DELETE /api/db/books — 管理端书籍 CRUD
func DBBooksAction(c *gin.Context) {
db := database.DB()
switch c.Request.Method {
case http.MethodGet:
var books []model.Book
if err := db.Order("sort_order ASC, id ASC").Find(&books).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
type bookWithCount struct {
model.Book
SectionCount int64 `json:"sectionCount"`
}
out := make([]bookWithCount, 0, len(books))
for _, b := range books {
var cnt int64
db.Model(&model.Chapter{}).Where("book_id = ?", b.ID).Count(&cnt)
out = append(out, bookWithCount{Book: b, SectionCount: cnt})
}
c.JSON(http.StatusOK, gin.H{"success": true, "books": out})
case http.MethodPost:
var body struct {
BookKey string `json:"bookKey"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Icon string `json:"icon"`
IconEmoji string `json:"iconEmoji"`
SortOrder int `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
if strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "书名不能为空"})
return
}
if body.BookKey == "" {
body.BookKey = "book-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
book := model.Book{
BookKey: body.BookKey,
Title: body.Title,
Subtitle: body.Subtitle,
Icon: body.Icon,
IconEmoji: body.IconEmoji,
SortOrder: body.SortOrder,
Status: "published",
}
if err := db.Create(&book).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "book": book})
case http.MethodPut:
action := c.Query("action")
if action == "reorder" {
var body struct {
Items []struct {
ID int `json:"id"`
SortOrder int `json:"sortOrder"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
for _, item := range body.Items {
db.Model(&model.Book{}).Where("id = ?", item.ID).Update("sort_order", item.SortOrder)
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
var body struct {
ID int `json:"id"`
BookKey string `json:"bookKey"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Icon string `json:"icon"`
IconEmoji string `json:"iconEmoji"`
SortOrder int `json:"sortOrder"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
return
}
updates := map[string]interface{}{
"title": body.Title,
"subtitle": body.Subtitle,
"icon": body.Icon,
"icon_emoji": body.IconEmoji,
"sort_order": body.SortOrder,
}
if body.Status != "" {
updates["status"] = body.Status
}
if body.BookKey != "" {
updates["book_key"] = body.BookKey
}
if err := db.Model(&model.Book{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
case http.MethodDelete:
idStr := c.Query("id")
id, err := strconv.Atoi(idStr)
if err != nil || id == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
if id == 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "主书不可删除"})
return
}
var cnt int64
db.Model(&model.Chapter{}).Where("book_id = ?", id).Count(&cnt)
if cnt > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该书下还有内容,请先移除或转移"})
return
}
db.Where("id = ?", id).Delete(&model.Book{})
c.JSON(http.StatusOK, gin.H{"success": true})
}
}