353 lines
10 KiB
Go
353 lines
10 KiB
Go
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(§ions)
|
||
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})
|
||
}
|
||
}
|