package handler import ( "encoding/json" "net/http" "strconv" "strings" "soul-api/internal/database" "soul-api/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type superArticleAuthorRow struct { ID string `gorm:"column:id"` Nickname string `gorm:"column:nickname"` Avatar string `gorm:"column:avatar"` } func loadSuperArticleAuthorMap(ids []string) map[string]superArticleAuthorRow { out := map[string]superArticleAuthorRow{} if len(ids) == 0 { return out } var rows []superArticleAuthorRow db := database.DB() _ = db.Table("users").Select("id", "nickname", "avatar").Where("id IN ?", ids).Find(&rows).Error for _, r := range rows { key := strings.TrimSpace(r.ID) if key == "" { continue } out[key] = r } return out } const ( superArticleMaxImages = 9 superArticleMaxImageURLLen = 2048 ) func parseSuperArticleImagesJSON(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } var arr []string if err := json.Unmarshal([]byte(raw), &arr); err != nil { return nil } out := make([]string, 0, len(arr)) for _, u := range arr { u = strings.TrimSpace(u) if u != "" { out = append(out, u) } } return out } // superArticleFeedAllowedAuthorIDs 文章中出现过、且当前仍为超级个体的用户 ID(用于动态广场) func superArticleFeedAllowedAuthorIDs(db *gorm.DB) []string { var raw []string _ = db.Model(&model.SuperArticle{}). Where("audit_status = ?", model.SuperArticleAuditApproved). Distinct("user_id"). Pluck("user_id", &raw).Error out := make([]string, 0, len(raw)) seen := map[string]bool{} for _, uid := range raw { uid = strings.TrimSpace(uid) if uid == "" || seen[uid] { continue } seen[uid] = true if _, ok := miniprogramSuperIndividualPerson(db, uid); ok { out = append(out, uid) } } return out } // MiniprogramSuperArticleFeed GET /api/miniprogram/super/articles/feed?page=&pageSize= // 动态广场:当前仍为超级个体的作者所发文章,按创建时间倒序分页。 func MiniprogramSuperArticleFeed(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 30 { pageSize = 10 } db := database.DB() allowed := superArticleFeedAllowedAuthorIDs(db) if len(allowed) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "list": []gin.H{}, "total": int64(0), "page": page, "pageSize": pageSize}) return } q := db.Model(&model.SuperArticle{}). Where("user_id IN ?", allowed). Where("audit_status = ?", model.SuperArticleAuditApproved) var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } var rows []model.SuperArticle if err := db.Where("user_id IN ?", allowed). Where("audit_status = ?", model.SuperArticleAuditApproved). Order("created_at DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&rows).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } idSet := map[string]bool{} var authorIDs []string for _, r := range rows { uid := strings.TrimSpace(r.UserID) if uid == "" || idSet[uid] { continue } idSet[uid] = true authorIDs = append(authorIDs, uid) } authorMap := loadSuperArticleAuthorMap(authorIDs) list := make([]gin.H, 0, len(rows)) for _, r := range rows { content := strings.TrimSpace(r.Content) preview := content if len([]rune(preview)) > 120 { preview = string([]rune(preview)[:120]) + "..." } a := authorMap[strings.TrimSpace(r.UserID)] list = append(list, gin.H{ "id": r.ID, "userId": r.UserID, "title": r.Title, "content": r.Content, "preview": preview, "images": parseSuperArticleImagesJSON(r.Images), "authorNickname": strings.TrimSpace(a.Nickname), "authorAvatar": strings.TrimSpace(a.Avatar), "createdAt": r.CreatedAt, }) } c.JSON(http.StatusOK, gin.H{ "success": true, "list": list, "total": total, "page": page, "pageSize": pageSize, }) } func deriveSuperArticleTitleFromContent(content string) string { content = strings.TrimSpace(content) if content == "" { return "" } parts := strings.SplitN(content, "\n", 2) line := strings.TrimSpace(parts[0]) line = strings.TrimRight(line, "\r") if line == "" { return "" } rs := []rune(line) if len(rs) > 40 { return string(rs[:40]) } return line } // finalizeSuperArticleTitleAndBody 发动态:标题可省略(由正文首行或默认文案补齐);正文与配图至少一项。 func finalizeSuperArticleTitleAndBody(title, content string, imageCount int) (outTitle, outBody, errMsg string) { title = strings.TrimSpace(title) content = strings.TrimSpace(content) if content == "" && imageCount == 0 { return "", "", "请填写文字或添加配图" } if len([]rune(content)) > 5000 { return "", "", "正文最多 5000 字" } outTitle = title if outTitle != "" && len([]rune(outTitle)) > 40 { outTitle = string([]rune(outTitle)[:40]) } if outTitle == "" { outTitle = deriveSuperArticleTitleFromContent(content) } if outTitle == "" && imageCount > 0 { outTitle = "分享图片" } if outTitle == "" { outTitle = "动态" } if len([]rune(outTitle)) > 40 { outTitle = string([]rune(outTitle)[:40]) } return outTitle, content, "" } func normalizeSuperArticleImageURLs(in []string) ([]string, string) { if len(in) == 0 { return []string{}, "" } if len(in) > superArticleMaxImages { return nil, "配图最多 9 张" } out := make([]string, 0, len(in)) for _, u := range in { u = strings.TrimSpace(u) if u == "" { continue } if len(u) > superArticleMaxImageURLLen { return nil, "图片链接过长" } low := strings.ToLower(u) if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { return nil, "图片链接须以 http(s):// 开头" } out = append(out, u) } if len(out) > superArticleMaxImages { return nil, "配图最多 9 张" } return out, "" } // MiniprogramSuperArticleCreate POST /api/miniprogram/super/articles // 仅超级个体本人可发文章。 func MiniprogramSuperArticleCreate(c *gin.Context) { var req struct { UserID string `json:"userId"` Title string `json:"title"` Content string `json:"content"` Images []string `json:"images"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } req.UserID = strings.TrimSpace(req.UserID) req.Title = strings.TrimSpace(req.Title) req.Content = strings.TrimSpace(req.Content) if req.UserID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } imgURLs, imgErr := normalizeSuperArticleImageURLs(req.Images) if imgErr != "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": imgErr}) return } imgJSONBytes, jerr := json.Marshal(imgURLs) if jerr != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "配图数据无效"}) return } title, body, verr := finalizeSuperArticleTitleAndBody(req.Title, req.Content, len(imgURLs)) if verr != "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": verr}) return } req.Title, req.Content = title, body db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可发文章", "forbidden": true}) return } row := model.SuperArticle{ UserID: req.UserID, Title: req.Title, Content: req.Content, Images: string(imgJSONBytes), AuditStatus: model.SuperArticleAuditPending, RejectReason: "", } if !SuperArticleAuditEnabled() { row.AuditStatus = model.SuperArticleAuditApproved row.RejectReason = "" } if err := db.Create(&row).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"id": row.ID}}) } // MiniprogramSuperArticleList GET /api/miniprogram/super/articles?authorUserId=&page=&pageSize= func MiniprogramSuperArticleList(c *gin.Context) { authorUserID := strings.TrimSpace(c.Query("authorUserId")) if authorUserID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 authorUserId"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 30 { pageSize = 10 } db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, authorUserID); !ok { c.JSON(http.StatusOK, gin.H{"success": true, "list": []gin.H{}, "total": 0, "page": page, "pageSize": pageSize}) return } var total int64 db.Model(&model.SuperArticle{}). Where("user_id = ?", authorUserID). Where("audit_status = ?", model.SuperArticleAuditApproved). Count(&total) var rows []model.SuperArticle if err := db.Where("user_id = ?", authorUserID). Where("audit_status = ?", model.SuperArticleAuditApproved). Order("created_at DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&rows).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } authorMap := loadSuperArticleAuthorMap([]string{authorUserID}) author := authorMap[authorUserID] list := make([]gin.H, 0, len(rows)) for _, r := range rows { content := strings.TrimSpace(r.Content) preview := content if len([]rune(preview)) > 88 { preview = string([]rune(preview)[:88]) + "..." } list = append(list, gin.H{ "id": r.ID, "userId": r.UserID, "title": r.Title, "content": r.Content, "preview": preview, "images": parseSuperArticleImagesJSON(r.Images), "authorNickname": strings.TrimSpace(author.Nickname), "authorAvatar": strings.TrimSpace(author.Avatar), "createdAt": r.CreatedAt, }) } c.JSON(http.StatusOK, gin.H{ "success": true, "list": list, "total": total, "page": page, "pageSize": pageSize, }) } // MiniprogramSuperArticleMine GET /api/miniprogram/super/articles/mine?userId=&page=&pageSize= // 本人全部动态(含待审/通过/驳回),仅超级个体可查。 func MiniprogramSuperArticleMine(c *gin.Context) { userID := strings.TrimSpace(c.Query("userId")) if userID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 30 { pageSize = 10 } db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, userID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可查看", "forbidden": true}) return } var total int64 db.Model(&model.SuperArticle{}).Where("user_id = ?", userID).Count(&total) var rows []model.SuperArticle if err := db.Where("user_id = ?", userID). Order("created_at DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&rows).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } authorMap := loadSuperArticleAuthorMap([]string{userID}) author := authorMap[userID] list := make([]gin.H, 0, len(rows)) for _, r := range rows { content := strings.TrimSpace(r.Content) preview := content if len([]rune(preview)) > 88 { preview = string([]rune(preview)[:88]) + "..." } ast := strings.TrimSpace(strings.ToLower(r.AuditStatus)) if ast == "" { ast = model.SuperArticleAuditApproved } list = append(list, gin.H{ "id": r.ID, "userId": r.UserID, "title": r.Title, "content": r.Content, "preview": preview, "images": parseSuperArticleImagesJSON(r.Images), "authorNickname": strings.TrimSpace(author.Nickname), "authorAvatar": strings.TrimSpace(author.Avatar), "createdAt": r.CreatedAt, "auditStatus": ast, "rejectReason": strings.TrimSpace(r.RejectReason), }) } c.JSON(http.StatusOK, gin.H{ "success": true, "list": list, "total": total, "page": page, "pageSize": pageSize, }) } // MiniprogramSuperArticleDetail GET /api/miniprogram/super/articles/:id func MiniprogramSuperArticleDetail(c *gin.Context) { idRaw := strings.TrimSpace(c.Param("id")) id, err := strconv.ParseUint(idRaw, 10, 64) if err != nil || id == 0 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } viewer := strings.TrimSpace(c.Query("viewerUserId")) db := database.DB() var row model.SuperArticle if err := db.First(&row, id).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "文章不存在"}) return } if _, ok := miniprogramSuperIndividualPerson(db, row.UserID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "文章不可见"}) return } if !model.SuperArticlePublicApproved(row.AuditStatus) { if viewer == "" || viewer != strings.TrimSpace(row.UserID) { c.JSON(http.StatusOK, gin.H{"success": false, "error": "文章审核中或不可见"}) return } } ast := strings.TrimSpace(strings.ToLower(row.AuditStatus)) if ast == "" { ast = model.SuperArticleAuditApproved } authorMap := loadSuperArticleAuthorMap([]string{row.UserID}) author := authorMap[row.UserID] c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "id": row.ID, "userId": row.UserID, "title": row.Title, "content": row.Content, "images": parseSuperArticleImagesJSON(row.Images), "authorNickname": strings.TrimSpace(author.Nickname), "authorAvatar": strings.TrimSpace(author.Avatar), "createdAt": row.CreatedAt, "auditStatus": ast, "rejectReason": strings.TrimSpace(row.RejectReason), }, }) } // MiniprogramSuperArticleUpdate PUT /api/miniprogram/super/articles/:id // 仅作者本人;状态须为待审核或已驳回;提交后重置为待审核。 func MiniprogramSuperArticleUpdate(c *gin.Context) { idRaw := strings.TrimSpace(c.Param("id")) id, err := strconv.ParseUint(idRaw, 10, 64) if err != nil || id == 0 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } var req struct { UserID string `json:"userId"` Title string `json:"title"` Content string `json:"content"` Images []string `json:"images"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } req.UserID = strings.TrimSpace(req.UserID) req.Title = strings.TrimSpace(req.Title) req.Content = strings.TrimSpace(req.Content) if req.UserID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } imgURLs, imgErr := normalizeSuperArticleImageURLs(req.Images) if imgErr != "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": imgErr}) return } imgJSONBytes, jerr := json.Marshal(imgURLs) if jerr != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "配图数据无效"}) return } title, body, verr := finalizeSuperArticleTitleAndBody(req.Title, req.Content, len(imgURLs)) if verr != "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": verr}) return } req.Title, req.Content = title, body db := database.DB() var row model.SuperArticle if err := db.First(&row, id).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "文章不存在"}) return } if strings.TrimSpace(row.UserID) != req.UserID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权修改"}) return } if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可修改", "forbidden": true}) return } st := strings.TrimSpace(strings.ToLower(row.AuditStatus)) if st == "" { st = model.SuperArticleAuditApproved } if st != model.SuperArticleAuditPending && st != model.SuperArticleAuditRejected { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅待审核或已驳回的动态可修改"}) return } row.Title = req.Title row.Content = req.Content row.Images = string(imgJSONBytes) if SuperArticleAuditEnabled() { row.AuditStatus = model.SuperArticleAuditPending row.RejectReason = "" } else { row.AuditStatus = model.SuperArticleAuditApproved row.RejectReason = "" } if err := db.Save(&row).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // MiniprogramSuperArticleDelete 删除动态(作者本人 + 超级个体)。 // DELETE /api/miniprogram/super/articles/:id?userId= // POST /api/miniprogram/super/articles/:id/delete?userId= (与 DELETE 等价,便于不支持 DELETE 的客户端调试) func MiniprogramSuperArticleDelete(c *gin.Context) { idRaw := strings.TrimSpace(c.Param("id")) id, err := strconv.ParseUint(idRaw, 10, 64) if err != nil || id == 0 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } userID := strings.TrimSpace(c.Query("userId")) if userID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) return } db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, userID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可操作", "forbidden": true}) return } var row model.SuperArticle if err := db.First(&row, id).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "动态不存在"}) return } if strings.TrimSpace(row.UserID) != userID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权删除"}) return } if err := db.Delete(&row).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) }