@@ -1030,6 +1052,7 @@ export function SettingsPage() {
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: },
{ mod: '超级个体名片', ctrl: '审核模式', icon: },
{ mod: '分享排行榜', ctrl: '分享排行榜开关', icon: },
+ { mod: '动态广场(超级个体)', ctrl: '超级个体动态审核开关', icon: },
{ mod: '首页获客入口', ctrl: '已移除', icon: },
].map((r) => (
diff --git a/soul-admin/src/pages/super-articles-audit/SuperArticlesAuditPage.tsx b/soul-admin/src/pages/super-articles-audit/SuperArticlesAuditPage.tsx
new file mode 100644
index 00000000..f2bccaf4
--- /dev/null
+++ b/soul-admin/src/pages/super-articles-audit/SuperArticlesAuditPage.tsx
@@ -0,0 +1,270 @@
+import { useCallback, useEffect, useState } from 'react'
+import { get, put } from '@/api/client'
+import toast from '@/utils/toast'
+
+type AuditRow = {
+ id?: number
+ userId?: string
+ title?: string
+ content?: string
+ auditStatus?: string
+ rejectReason?: string
+ authorNickname?: string
+ createdAt?: string
+}
+
+const STATUS_TABS: { key: 'pending' | 'approved' | 'rejected' | 'all'; label: string }[] = [
+ { key: 'pending', label: '待审核' },
+ { key: 'approved', label: '已通过' },
+ { key: 'rejected', label: '已驳回' },
+ { key: 'all', label: '全部' },
+]
+
+export function SuperArticlesAuditPage() {
+ const [status, setStatus] = useState<(typeof STATUS_TABS)[number]['key']>('pending')
+ const [page, setPage] = useState(1)
+ const [loading, setLoading] = useState(true)
+ const [total, setTotal] = useState(0)
+ const [list, setList] = useState
([])
+ const [rejectId, setRejectId] = useState(null)
+ const [rejectReason, setRejectReason] = useState('')
+ /** 与系统设置「超级个体动态审核」同步:关闭后新发即为已通过 */
+ const [superArticleAuditEnabled, setSuperArticleAuditEnabled] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const res = await get<{
+ success?: boolean
+ featureConfig?: { superArticleAuditEnabled?: unknown }
+ }>('/api/admin/settings')
+ if ((res as { success?: boolean }).success === false) return
+ const raw = res?.featureConfig?.superArticleAuditEnabled
+ const enabled =
+ raw === undefined ||
+ raw === null ||
+ raw === true ||
+ raw === 'true' ||
+ raw === 1 ||
+ raw === '1'
+ if (!cancelled) setSuperArticleAuditEnabled(!!enabled)
+ } catch (_) {}
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const q = new URLSearchParams({ status, page: String(page), pageSize: '20' })
+ const res = await get<{
+ success?: boolean
+ list?: AuditRow[]
+ total?: number
+ error?: string
+ }>(`/api/admin/super-articles?${q}`)
+ if (res && res.success === false) throw new Error(res.error || '加载失败')
+ setList(Array.isArray(res?.list) ? res.list : [])
+ setTotal(typeof res?.total === 'number' ? res.total : 0)
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '加载失败')
+ setList([])
+ setTotal(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [status, page])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const review = async (id: number, action: 'approve' | 'reject', reason?: string) => {
+ try {
+ const res = await put<{ success?: boolean; error?: string }>(`/api/admin/super-articles/${id}/review`, {
+ action,
+ rejectReason: reason || '',
+ })
+ if (res && res.success === false) throw new Error(res.error || '操作失败')
+ toast.success(action === 'approve' ? '已通过' : '已驳回')
+ setRejectId(null)
+ setRejectReason('')
+ load()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '操作失败')
+ }
+ }
+
+ const pageSize = 20
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
+
+ return (
+
+
动态管理
+
超级个体动态审核:通过后出现在小程序动态广场;驳回后用户可修改重新提交。
+
+ {!superArticleAuditEnabled ? (
+
+ 当前已在系统设置 → 功能开关
+ 关闭「超级个体动态审核」,用户新发内容将直接通过并上架广场;此处列表仍可处理历史待审记录。
+
+ ) : null}
+
+
+ {STATUS_TABS.map((t) => (
+
+ ))}
+
+
+ {loading ? (
+
加载中...
+ ) : list.length === 0 ? (
+
暂无记录
+ ) : (
+
+ {list.map((row) => {
+ const id = Number(row.id)
+ const nick = String(row.authorNickname || '').trim() || row.userId || '-'
+ const title = String(row.title || '').trim() || '(无标题)'
+ const preview = String(row.content || '').trim().replace(/\s+/g, ' ')
+ const short = preview.length > 160 ? `${preview.slice(0, 160)}…` : preview
+ const st = String(row.auditStatus || '').trim()
+ return (
+
+
+
+ #{id}
+
+ {row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : ''}
+
+ {st}
+
+ {(st === 'pending' || st === 'rejected') && (
+
+
+
+
+ )}
+
+
+ 作者:{nick}
+ {row.userId ? (
+ {row.userId}
+ ) : null}
+
+
{title}
+
{short}
+ {st === 'rejected' && row.rejectReason ? (
+
+ 驳回原因:{String(row.rejectReason)}
+
+ ) : null}
+
+ )
+ })}
+
+ )}
+
+ {total > pageSize ? (
+
+
+
+ {page} / {totalPages}(共 {total} 条)
+
+
+
+ ) : null}
+
+ {rejectId != null ? (
+
+
+
驳回动态 #{rejectId}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/soul-admin/tsconfig.tsbuildinfo b/soul-admin/tsconfig.tsbuildinfo
index ff18d555..edc440cb 100644
--- a/soul-admin/tsconfig.tsbuildinfo
+++ b/soul-admin/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/leads/ckbleadspanel.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/ckbleadtypelabels.ts","./src/lib/ckbworkbench.ts","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordsbytype.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/homeentryconfigtab.tsx","./src/pages/users/superindividualtab.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/leads/ckbleadspanel.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/ckbleadtypelabels.ts","./src/lib/ckbworkbench.ts","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordsbytype.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/open-platform/openplatformdocstab.tsx","./src/pages/open-platform/openplatformkeystab.tsx","./src/pages/open-platform/openplatformlogstab.tsx","./src/pages/open-platform/openplatformpage.tsx","./src/pages/open-platform/openplatformdocdefinitions.ts","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/super-articles-audit/superarticlesauditpage.tsx","./src/pages/users/homeentryconfigtab.tsx","./src/pages/users/superindividualtab.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
\ No newline at end of file
diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go
index 29c553e7..26ea8793 100644
--- a/soul-api/internal/database/database.go
+++ b/soul-api/internal/database/database.go
@@ -66,6 +66,7 @@ func Init(dsn string) error {
ensureUserPasswordHashColumn(db)
ensureSuperArticlesTableRaw(db)
ensureSuperArticleImagesColumn(db)
+ ensureSuperArticleAuditColumns(db)
log.Println("database: connected")
return nil
}
@@ -158,6 +159,7 @@ func Init(dsn string) error {
ensureUserPasswordHashColumn(db)
ensureSuperArticlesTableRaw(db)
ensureSuperArticleImagesColumn(db)
+ ensureSuperArticleAuditColumns(db)
log.Println("database: connected")
return nil
}
@@ -252,10 +254,13 @@ CREATE TABLE IF NOT EXISTS super_articles (
title VARCHAR(200) NOT NULL DEFAULT '',
content LONGTEXT COMMENT '正文',
images LONGTEXT NULL COMMENT '配图 URL JSON 数组',
+ audit_status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected',
+ reject_reason LONGTEXT NULL COMMENT '驳回原因',
created_at DATETIME(3) NULL,
updated_at DATETIME(3) NULL,
PRIMARY KEY (id),
- KEY idx_super_articles_user_time (user_id, created_at)
+ KEY idx_super_articles_user_time (user_id, created_at),
+ KEY idx_super_articles_audit_status (audit_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='超级个体发文(小程序 /s/:id H5)'`
if err := db.Exec(sqlStmt).Error; err != nil {
@@ -281,6 +286,39 @@ func ensureSuperArticleImagesColumn(db *gorm.DB) {
}
}
+// ensureSuperArticleAuditColumns 旧库补审核字段;历史数据一律视为已通过,避免线上动态消失
+func ensureSuperArticleAuditColumns(db *gorm.DB) {
+ m := db.Migrator()
+ if !m.HasTable(&model.SuperArticle{}) {
+ return
+ }
+ if !m.HasColumn(&model.SuperArticle{}, "AuditStatus") {
+ if err := db.Exec("ALTER TABLE super_articles ADD COLUMN audit_status VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT 'pending/approved/rejected'").Error; err != nil {
+ msg := strings.ToLower(err.Error())
+ if !strings.Contains(msg, "duplicate column") {
+ log.Printf("database: super_articles add audit_status warning: %v", err)
+ }
+ }
+ }
+ if !m.HasColumn(&model.SuperArticle{}, "RejectReason") {
+ if err := db.Exec("ALTER TABLE super_articles ADD COLUMN reject_reason LONGTEXT NULL COMMENT '驳回原因'").Error; err != nil {
+ msg := strings.ToLower(err.Error())
+ if !strings.Contains(msg, "duplicate column") {
+ log.Printf("database: super_articles add reject_reason warning: %v", err)
+ }
+ }
+ }
+ _ = db.Exec("UPDATE super_articles SET audit_status = 'approved' WHERE audit_status IS NULL OR TRIM(audit_status) = ''").Error
+ if !m.HasIndex(&model.SuperArticle{}, "idx_super_articles_audit_status") {
+ if err := db.Exec("CREATE INDEX idx_super_articles_audit_status ON super_articles(audit_status)").Error; err != nil {
+ msg := strings.ToLower(err.Error())
+ if !strings.Contains(msg, "duplicate") {
+ log.Printf("database: super_articles idx_super_articles_audit_status warning: %v", err)
+ }
+ }
+ }
+}
+
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {
diff --git a/soul-api/internal/handler/admin_super_articles.go b/soul-api/internal/handler/admin_super_articles.go
new file mode 100644
index 00000000..f934aa11
--- /dev/null
+++ b/soul-api/internal/handler/admin_super_articles.go
@@ -0,0 +1,129 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "soul-api/internal/database"
+ "soul-api/internal/model"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AdminSuperArticlesList GET /api/admin/super-articles?status=pending|approved|rejected|all&page=&pageSize=
+func AdminSuperArticlesList(c *gin.Context) {
+ status := strings.TrimSpace(strings.ToLower(c.Query("status")))
+ if status == "" {
+ status = "pending"
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 100 {
+ pageSize = 20
+ }
+
+ db := database.DB()
+ q := db.Model(&model.SuperArticle{})
+ if status != "all" {
+ q = q.Where("audit_status = ?", status)
+ }
+ 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 := q.Order("updated_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 uids []string
+ for _, r := range rows {
+ u := strings.TrimSpace(r.UserID)
+ if u == "" || idSet[u] {
+ continue
+ }
+ idSet[u] = true
+ uids = append(uids, u)
+ }
+ authorMap := loadSuperArticleAuthorMap(uids)
+ list := make([]gin.H, 0, len(rows))
+ for _, r := range rows {
+ a := authorMap[strings.TrimSpace(r.UserID)]
+ list = append(list, gin.H{
+ "id": r.ID,
+ "userId": r.UserID,
+ "title": r.Title,
+ "content": r.Content,
+ "images": parseSuperArticleImagesJSON(r.Images),
+ "auditStatus": strings.TrimSpace(r.AuditStatus),
+ "rejectReason": strings.TrimSpace(r.RejectReason),
+ "authorNickname": strings.TrimSpace(a.Nickname),
+ "authorAvatar": strings.TrimSpace(a.Avatar),
+ "createdAt": r.CreatedAt,
+ "updatedAt": r.UpdatedAt,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "list": list,
+ "total": total,
+ "page": page,
+ "pageSize": pageSize,
+ })
+}
+
+// AdminSuperArticleReview PUT /api/admin/super-articles/:id/review
+// body: { "action": "approve" | "reject", "rejectReason": "..." }
+func AdminSuperArticleReview(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 {
+ Action string `json:"action"`
+ RejectReason string `json:"rejectReason"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
+ return
+ }
+ act := strings.TrimSpace(strings.ToLower(req.Action))
+ if act != "approve" && act != "reject" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
+ return
+ }
+
+ 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 act == "approve" {
+ row.AuditStatus = model.SuperArticleAuditApproved
+ row.RejectReason = ""
+ } else {
+ row.AuditStatus = model.SuperArticleAuditRejected
+ row.RejectReason = strings.TrimSpace(req.RejectReason)
+ if row.RejectReason == "" {
+ 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})
+}
diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go
index a65f032b..f2381595 100644
--- a/soul-api/internal/handler/db.go
+++ b/soul-api/internal/handler/db.go
@@ -50,6 +50,22 @@ func parseConfigBool(v interface{}) bool {
}
}
+// SuperArticleAuditEnabled 超级个体动态是否需后台审核(feature_config.superArticleAuditEnabled)。默认 true;为 false 时发表直接为已通过。
+func SuperArticleAuditEnabled() bool {
+ var row model.SystemConfig
+ if err := database.DB().Where("config_key = ?", "feature_config").First(&row).Error; err != nil {
+ return true
+ }
+ var m map[string]interface{}
+ if err := json.Unmarshal(row.ConfigValue, &m); err != nil || len(m) == 0 {
+ return true
+ }
+ if v, ok := m["superArticleAuditEnabled"]; ok {
+ return parseConfigBool(v)
+ }
+ return true
+}
+
// shouldReplaceOssAccessKeySecret 为 true 表示此次提交应写入新密钥;false 则合并库中原值(脱敏/空不覆盖)
func shouldReplaceOssAccessKeySecret(sec string) bool {
sec = strings.TrimSpace(sec)
@@ -287,7 +303,7 @@ func deepMergeMpUi(base gin.H, overRaw interface{}) gin.H {
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
func buildMiniprogramConfig() gin.H {
defaultPrices := gin.H{"section": float64(1), "fullbook": float64(365)}
- defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
+ defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "superArticleAuditEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
@@ -362,6 +378,11 @@ func buildMiniprogramConfig() gin.H {
}
}
}
+ if feat, ok := out["features"].(gin.H); ok {
+ if _, has := feat["superArticleAuditEnabled"]; !has {
+ feat["superArticleAuditEnabled"] = true
+ }
+ }
// 价格:以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
// 同时提取 bookFreePreviewPercent(免费展示比例 0~100,默认 100 即全部免费展示目录)
out["bookFreePreviewPercent"] = 100
@@ -526,7 +547,7 @@ func GetCoreConfig(c *gin.Context) {
out["prices"] = gin.H{"section": float64(1), "fullbook": float64(365)}
}
if out["features"] == nil {
- out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
+ out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "superArticleAuditEnabled": true}
}
if out["userDiscount"] == nil {
out["userDiscount"] = float64(5)
@@ -615,7 +636,7 @@ func WarmConfigCache() {
core["prices"] = gin.H{"section": float64(1), "fullbook": float64(365)}
}
if core["features"] == nil {
- core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
+ core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "superArticleAuditEnabled": true}
}
if core["userDiscount"] == nil {
core["userDiscount"] = float64(5)
@@ -692,7 +713,7 @@ func AdminSettingsGet(c *gin.Context) {
}
out := gin.H{
"success": true,
- "featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
+ "featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true, "superArticleAuditEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": float64(365), "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
@@ -751,6 +772,11 @@ func AdminSettingsGet(c *gin.Context) {
}
}
}
+ if fc, ok := out["featureConfig"].(map[string]interface{}); ok {
+ if _, has := fc["superArticleAuditEnabled"]; !has {
+ fc["superArticleAuditEnabled"] = true
+ }
+ }
c.JSON(http.StatusOK, out)
}
diff --git a/soul-api/internal/handler/h5_super_article.go b/soul-api/internal/handler/h5_super_article.go
index 76e48e53..9c3f8e14 100644
--- a/soul-api/internal/handler/h5_super_article.go
+++ b/soul-api/internal/handler/h5_super_article.go
@@ -92,6 +92,10 @@ func H5SuperArticlePage(c *gin.Context) {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不可见")))
return
}
+ if !model.SuperArticlePublicApproved(row.AuditStatus) {
+ c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不存在或未通过审核")))
+ return
+ }
authorMap := loadSuperArticleAuthorMap([]string{row.UserID})
author := authorMap[row.UserID]
diff --git a/soul-api/internal/handler/miniprogram_super_articles.go b/soul-api/internal/handler/miniprogram_super_articles.go
index 3cdef75b..6cce95ef 100644
--- a/soul-api/internal/handler/miniprogram_super_articles.go
+++ b/soul-api/internal/handler/miniprogram_super_articles.go
@@ -64,7 +64,10 @@ func parseSuperArticleImagesJSON(raw string) []string {
// superArticleFeedAllowedAuthorIDs 文章中出现过、且当前仍为超级个体的用户 ID(用于动态广场)
func superArticleFeedAllowedAuthorIDs(db *gorm.DB) []string {
var raw []string
- _ = db.Model(&model.SuperArticle{}).Distinct("user_id").Pluck("user_id", &raw).Error
+ _ = 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 {
@@ -99,7 +102,9 @@ func MiniprogramSuperArticleFeed(c *gin.Context) {
return
}
- q := db.Model(&model.SuperArticle{}).Where("user_id IN ?", allowed)
+ 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()})
@@ -108,6 +113,7 @@ func MiniprogramSuperArticleFeed(c *gin.Context) {
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).
@@ -233,10 +239,16 @@ func MiniprogramSuperArticleCreate(c *gin.Context) {
}
row := model.SuperArticle{
- UserID: req.UserID,
- Title: req.Title,
- Content: req.Content,
- Images: string(imgJSONBytes),
+ 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()})
@@ -268,9 +280,13 @@ func MiniprogramSuperArticleList(c *gin.Context) {
}
var total int64
- db.Model(&model.SuperArticle{}).Where("user_id = ?", authorUserID).Count(&total)
+ 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).
@@ -310,6 +326,78 @@ func MiniprogramSuperArticleList(c *gin.Context) {
})
}
+// 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"))
@@ -318,6 +406,7 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
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 {
@@ -328,6 +417,16 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
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{
@@ -341,6 +440,94 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
"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 == "" || req.Title == "" || req.Content == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题和正文不能为空"})
+ return
+ }
+ if len([]rune(req.Title)) > 40 {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "标题最多 40 字"})
+ return
+ }
+ if len([]rune(req.Content)) > 5000 {
+ c.JSON(http.StatusOK, gin.H{"success": false, "error": "正文最多 5000 字"})
+ 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
+ }
+
+ 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})
+}
diff --git a/soul-api/internal/model/super_article.go b/soul-api/internal/model/super_article.go
index 7dba3482..ef08068c 100644
--- a/soul-api/internal/model/super_article.go
+++ b/soul-api/internal/model/super_article.go
@@ -1,16 +1,34 @@
package model
-import "time"
+import (
+ "strings"
+ "time"
+)
+
+// 超级个体动态审核状态(管理端通过后出现在广场)
+const (
+ SuperArticleAuditPending = "pending"
+ SuperArticleAuditApproved = "approved"
+ SuperArticleAuditRejected = "rejected"
+)
// SuperArticle 超级个体发布的文章(小程序端)
type SuperArticle struct {
- ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
- UserID string `gorm:"column:user_id;size:50;index:idx_super_articles_user_time" json:"userId"`
- Title string `gorm:"column:title;size:200" json:"title"`
- Content string `gorm:"column:content;type:text" json:"content"`
- Images string `gorm:"column:images;type:text" json:"-"` // JSON 数组 ["https://..."],最多 9 条
- CreatedAt time.Time `gorm:"column:created_at;index:idx_super_articles_user_time" json:"createdAt"`
- UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
+ ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
+ UserID string `gorm:"column:user_id;size:50;index:idx_super_articles_user_time" json:"userId"`
+ Title string `gorm:"column:title;size:200" json:"title"`
+ Content string `gorm:"column:content;type:text" json:"content"`
+ Images string `gorm:"column:images;type:text" json:"-"` // JSON ["url"...]
+ AuditStatus string `gorm:"column:audit_status;size:32;index:idx_super_articles_audit_status" json:"auditStatus"`
+ RejectReason string `gorm:"column:reject_reason;type:text" json:"rejectReason"`
+ CreatedAt time.Time `gorm:"column:created_at;index:idx_super_articles_user_time" json:"createdAt"`
+ UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (SuperArticle) TableName() string { return "super_articles" }
+
+// SuperArticlePublicApproved 是否对访客可见(广场/嘉宾主页/H5)
+func SuperArticlePublicApproved(status string) bool {
+ s := strings.TrimSpace(strings.ToLower(status))
+ return s == "" || s == SuperArticleAuditApproved
+}
diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go
index 8013876a..adfedf31 100644
--- a/soul-api/internal/router/router.go
+++ b/soul-api/internal/router/router.go
@@ -124,6 +124,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/orders", handler.OrdersList)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/super-individual/stats", handler.AdminSuperIndividualStats)
+ admin.GET("/super-articles", handler.AdminSuperArticlesList)
+ admin.PUT("/super-articles/:id/review", handler.AdminSuperArticleReview)
admin.GET("/user/track", handler.UserTrackGet)
admin.GET("/track/stats", handler.AdminTrackStats)
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
@@ -456,9 +458,11 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats)
miniprogram.GET("/my/super-leads", handler.MiniprogramMySuperLeads)
miniprogram.GET("/my/super-lead-tracks", handler.MiniprogramMySuperLeadTracks)
+ miniprogram.GET("/super/articles/mine", handler.MiniprogramSuperArticleMine)
miniprogram.GET("/super/articles/feed", handler.MiniprogramSuperArticleFeed)
miniprogram.GET("/super/articles", handler.MiniprogramSuperArticleList)
miniprogram.GET("/super/articles/:id", handler.MiniprogramSuperArticleDetail)
+ miniprogram.PUT("/super/articles/:id", handler.MiniprogramSuperArticleUpdate)
miniprogram.POST("/super/articles", handler.MiniprogramSuperArticleCreate)
miniprogram.POST("/super/articles/generate", handler.MiniprogramSuperArticleGenerate)
}
diff --git a/soul-api/scripts/create_super_articles.sql b/soul-api/scripts/create_super_articles.sql
index e40cbd42..511fe45f 100644
--- a/soul-api/scripts/create_super_articles.sql
+++ b/soul-api/scripts/create_super_articles.sql
@@ -1,8 +1,9 @@
--- 超级个体文章表(与 soul-api/internal/model/super_article.go、AutoMigrate 一致)
+-- 超级个体文章表(与 soul-api/internal/model/super_article.go、ensureSuperArticlesTableRaw 一致)
-- 缺表时报:Error 1146 (42S02): Table 'xxx.super_articles' doesn't exist(如 souldev、soul_miniprogram)
-- 在目标库执行:mysql -u... -p souldev < create_super_articles.sql
-- 或使用 Navicat 选中对应库后运行本脚本。
-- 若 soul-api 已含 ensureSuperArticlesTableRaw:重启 API(SKIP_AUTO_MIGRATE=1 也会自动建表)即可。
+-- 审核:audit_status pending=待审 approved=已通过 rejected=已驳回;旧库补列见 database.ensureSuperArticleAuditColumns
CREATE TABLE IF NOT EXISTS `super_articles` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
@@ -10,12 +11,18 @@ CREATE TABLE IF NOT EXISTS `super_articles` (
`title` varchar(200) NOT NULL DEFAULT '',
`content` longtext COMMENT '正文',
`images` longtext COMMENT '配图 URL JSON 数组,最多 9 张',
+ `audit_status` varchar(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/approved/rejected',
+ `reject_reason` longtext COMMENT '驳回原因',
`created_at` datetime(3) DEFAULT NULL,
`updated_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
- KEY `idx_super_articles_user_time` (`user_id`,`created_at`)
+ KEY `idx_super_articles_user_time` (`user_id`,`created_at`),
+ KEY `idx_super_articles_audit_status` (`audit_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='超级个体发文(小程序 /s/:id H5)';
-- 已有表缺列时执行:
-- ALTER TABLE super_articles ADD COLUMN images LONGTEXT NULL COMMENT '配图 URL JSON 数组,最多 9 张';
+-- ALTER TABLE super_articles ADD COLUMN audit_status VARCHAR(32) NOT NULL DEFAULT 'approved' COMMENT 'pending/approved/rejected';
+-- ALTER TABLE super_articles ADD COLUMN reject_reason LONGTEXT NULL COMMENT '驳回原因';
+-- UPDATE super_articles SET audit_status='approved' WHERE audit_status IS NULL OR TRIM(audit_status)='';