加强了多个组件的预览百分比处理。
在 API 中新增了章节专属的预览百分比,并更新了相关模型和处理器。 改进了阅读场景下确定有效预览百分比的逻辑。 更新了小程序配置,加入了新的阅读页面入口。
This commit is contained in:
@@ -39,12 +39,17 @@ function getContentParseConfig() {
|
||||
}
|
||||
|
||||
/** 补全 mentionDisplay,避免旧数据无字段;昵称去空白防「@ 名」 */
|
||||
/** 付费墙「已阅读 x%」:与章节接口 previewPercent 一致(未全文解锁时),缺省 20 */
|
||||
/** 试读比例:优先 data.previewPercent(章节私有),否则顶层 previewPercent(全局 unpaid_preview_percent),缺省 20 */
|
||||
function normalizePreviewPercent(res) {
|
||||
const p = res && res.previewPercent
|
||||
const n = typeof p === 'number' ? Math.round(p) : parseInt(String(p), 10)
|
||||
if (!isNaN(n) && n >= 1 && n <= 100) return n
|
||||
return 20
|
||||
if (!res) return 20
|
||||
const tryNum = (v) => {
|
||||
const n = typeof v === 'number' ? Math.round(v) : parseInt(String(v), 10)
|
||||
if (!isNaN(n) && n >= 1 && n <= 100) return n
|
||||
return undefined
|
||||
}
|
||||
const inner = res.data && res.data.previewPercent
|
||||
const outer = res.previewPercent
|
||||
return tryNum(inner) ?? tryNum(outer) ?? 20
|
||||
}
|
||||
|
||||
function normalizeMentionSegments(segments) {
|
||||
@@ -93,7 +98,7 @@ Page({
|
||||
|
||||
// 阅读进度
|
||||
readingProgress: 0,
|
||||
/** 未解锁时付费墙展示:与 /api/miniprogram/book/chapter/* 的 previewPercent 对齐 */
|
||||
/** 未解锁付费墙:合并 data.previewPercent(章节)与顶层 previewPercent(全局) */
|
||||
previewPercent: 20,
|
||||
showPaywall: false,
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
"condition": {
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "88888888",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=219",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
},
|
||||
{
|
||||
"name": "开发登录",
|
||||
"pathName": "pages/dev-login/dev-login",
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SectionItem {
|
||||
payCount?: number
|
||||
hotScore?: number
|
||||
hotRank?: number
|
||||
/** 章节试读%覆盖(与列表接口 previewPercent 一致) */
|
||||
previewPercent?: number | null
|
||||
}
|
||||
|
||||
export interface ChapterItem {
|
||||
|
||||
@@ -74,6 +74,8 @@ interface SectionListItem {
|
||||
payCount?: number
|
||||
hotScore?: number
|
||||
hotRank?: number
|
||||
/** 章节级试读比例覆盖,未设则走全局「未付费预览比例」 */
|
||||
previewPercent?: number | null
|
||||
}
|
||||
|
||||
interface Section {
|
||||
@@ -88,6 +90,7 @@ interface Section {
|
||||
payCount?: number
|
||||
hotScore?: number
|
||||
hotRank?: number
|
||||
previewPercent?: number | null
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
@@ -114,6 +117,17 @@ interface SectionOrder {
|
||||
payTime?: string
|
||||
}
|
||||
|
||||
/** 解析接口/列表中的试读比例(兼容数字、字符串、部分代理下划线字段) */
|
||||
function parsePreviewPercentInput(raw: unknown): number | undefined {
|
||||
if (raw === null || raw === undefined) return undefined
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.round(raw)
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw.trim().replace(/%/g, ''))
|
||||
if (Number.isFinite(n)) return Math.round(n)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface EditingSection {
|
||||
id: string
|
||||
originalId?: string
|
||||
@@ -125,7 +139,8 @@ interface EditingSection {
|
||||
isNew?: boolean
|
||||
isPinned?: boolean
|
||||
hotScore?: number
|
||||
previewPercent?: number
|
||||
/** undefined=未改不写库;null=清空用全局;数字=章节覆盖 */
|
||||
previewPercent?: number | null
|
||||
editionStandard?: boolean
|
||||
editionPremium?: boolean
|
||||
}
|
||||
@@ -159,6 +174,7 @@ function buildTree(sections: SectionListItem[], hotRankMap: Map<string, number>,
|
||||
payCount: s.payCount ?? 0,
|
||||
hotScore: s.hotScore ?? 0,
|
||||
hotRank: hotRankMap.get(s.id) ?? 0,
|
||||
previewPercent: parsePreviewPercentInput(s.previewPercent),
|
||||
})
|
||||
}
|
||||
const parts = Array.from(partMap.values()).map((p) => ({
|
||||
@@ -823,12 +839,24 @@ export function ContentPage() {
|
||||
section.mid != null && section.mid > 0
|
||||
? `/api/db/book?action=read&mid=${section.mid}`
|
||||
: `/api/db/book?action=read&id=${encodeURIComponent(section.id)}`
|
||||
const data = await get<{ success?: boolean; section?: { title?: string; price?: number; content?: string; editionStandard?: boolean; editionPremium?: boolean }; error?: string }>(
|
||||
url,
|
||||
)
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
section?: {
|
||||
title?: string
|
||||
price?: number
|
||||
content?: string
|
||||
editionStandard?: boolean
|
||||
editionPremium?: boolean
|
||||
previewPercent?: number | null
|
||||
}
|
||||
error?: string
|
||||
}>(url)
|
||||
if (data?.success && data.section) {
|
||||
const sec = data.section as { isNew?: boolean; editionStandard?: boolean; editionPremium?: boolean }
|
||||
const sec = data.section as Record<string, unknown>
|
||||
const isPremium = sec.editionPremium === true
|
||||
const fromRead =
|
||||
parsePreviewPercentInput(sec.previewPercent) ?? parsePreviewPercentInput(sec.preview_percent)
|
||||
const fromTree = parsePreviewPercentInput(section.previewPercent)
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
originalId: section.id,
|
||||
@@ -837,10 +865,11 @@ export function ContentPage() {
|
||||
content: data.section.content ?? '',
|
||||
filePath: section.filePath,
|
||||
isFree: section.isFree || section.price === 0,
|
||||
isNew: sec.isNew ?? section.isNew,
|
||||
isNew: (sec.isNew as boolean | undefined) ?? section.isNew,
|
||||
isPinned: pinnedSectionIds.includes(section.id),
|
||||
hotScore: section.hotScore ?? 0,
|
||||
editionStandard: isPremium ? false : (sec.editionStandard ?? true),
|
||||
previewPercent: fromRead ?? fromTree ?? undefined,
|
||||
editionStandard: isPremium ? false : ((sec.editionStandard as boolean | undefined) ?? true),
|
||||
editionPremium: isPremium,
|
||||
})
|
||||
} else {
|
||||
@@ -855,6 +884,7 @@ export function ContentPage() {
|
||||
isNew: section.isNew,
|
||||
isPinned: pinnedSectionIds.includes(section.id),
|
||||
hotScore: section.hotScore ?? 0,
|
||||
previewPercent: parsePreviewPercentInput(section.previewPercent),
|
||||
editionStandard: true,
|
||||
editionPremium: false,
|
||||
})
|
||||
@@ -871,6 +901,7 @@ export function ContentPage() {
|
||||
content: '',
|
||||
filePath: section.filePath,
|
||||
isFree: section.isFree,
|
||||
previewPercent: parsePreviewPercentInput(section.previewPercent),
|
||||
})
|
||||
} finally {
|
||||
setIsLoadingContent(false)
|
||||
@@ -892,22 +923,27 @@ export function ContentPage() {
|
||||
|
||||
const originalId = editingSection.originalId || editingSection.id
|
||||
const idChanged = editingSection.id !== originalId
|
||||
const saveBody: Record<string, unknown> = {
|
||||
id: originalId,
|
||||
...(idChanged ? { newId: editingSection.id } : {}),
|
||||
title: editingSection.title,
|
||||
price: editingSection.isFree ? 0 : editingSection.price,
|
||||
content,
|
||||
isFree: editingSection.isFree || editingSection.price === 0,
|
||||
isNew: editingSection.isNew,
|
||||
hotScore: editingSection.hotScore,
|
||||
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
|
||||
editionPremium: editingSection.editionPremium ?? false,
|
||||
saveToFile: true,
|
||||
}
|
||||
if (editingSection.previewPercent === null) {
|
||||
saveBody.previewPercent = null
|
||||
} else if (typeof editingSection.previewPercent === 'number' && Number.isFinite(editingSection.previewPercent)) {
|
||||
saveBody.previewPercent = editingSection.previewPercent
|
||||
}
|
||||
const res = await put<{ success?: boolean; error?: string }>(
|
||||
'/api/db/book',
|
||||
{
|
||||
id: originalId,
|
||||
...(idChanged ? { newId: editingSection.id } : {}),
|
||||
title: editingSection.title,
|
||||
price: editingSection.isFree ? 0 : editingSection.price,
|
||||
content,
|
||||
isFree: editingSection.isFree || editingSection.price === 0,
|
||||
isNew: editingSection.isNew,
|
||||
hotScore: editingSection.hotScore,
|
||||
previewPercent: editingSection.previewPercent ?? null,
|
||||
editionStandard: editingSection.editionPremium ? false : (editingSection.editionStandard ?? true),
|
||||
editionPremium: editingSection.editionPremium ?? false,
|
||||
saveToFile: true,
|
||||
},
|
||||
saveBody,
|
||||
{ timeout: SAVE_REQUEST_TIMEOUT },
|
||||
)
|
||||
const effectiveId = idChanged ? editingSection.id : originalId
|
||||
@@ -2107,10 +2143,19 @@ export function ContentPage() {
|
||||
max={100}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder={`全局 ${previewPercent}%`}
|
||||
value={editingSection.previewPercent ?? ''}
|
||||
value={editingSection.previewPercent != null ? String(editingSection.previewPercent) : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === '' ? undefined : Math.min(100, Math.max(0, Number(e.target.value)))
|
||||
setEditingSection({ ...editingSection, previewPercent: v })
|
||||
const raw = e.target.value.trim()
|
||||
if (raw === '') {
|
||||
setEditingSection({ ...editingSection, previewPercent: null })
|
||||
return
|
||||
}
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
previewPercent: Math.min(100, Math.max(1, Math.round(n))),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -2404,15 +2449,17 @@ export function ContentPage() {
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
const row = sectionsList.find((x) => x.id === result.id)
|
||||
handleReadSection({
|
||||
id: result.id,
|
||||
mid: result.mid,
|
||||
title: result.title,
|
||||
price: result.price ?? 1,
|
||||
filePath: '',
|
||||
previewPercent: row?.previewPercent,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2564,7 +2611,16 @@ export function ContentPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 hover:text-[#38bdac] h-6 px-1"
|
||||
onClick={() => handleReadSection({ id: s.id, mid: s.mid, title: s.title, price: s.price, filePath: '' })}
|
||||
onClick={() =>
|
||||
handleReadSection({
|
||||
id: s.id,
|
||||
mid: s.mid,
|
||||
title: s.title,
|
||||
price: s.price,
|
||||
filePath: '',
|
||||
previewPercent: s.previewPercent,
|
||||
})
|
||||
}
|
||||
title="编辑文章"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
|
||||
@@ -58,14 +58,14 @@ func sortChaptersByNaturalID(list []model.Chapter) {
|
||||
var allChaptersSelectCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "preview_percent", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// chapterMetaCols 章节详情元数据(不含 content),用于 content 缓存命中时的轻量查询
|
||||
var chapterMetaCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "preview_percent", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// allChaptersCache 内存缓存,减轻 DB 压力,30 秒 TTL
|
||||
@@ -643,6 +643,17 @@ func getUnpaidPreviewPercent(db *gorm.DB) int {
|
||||
return 20
|
||||
}
|
||||
|
||||
// effectivePreviewPercent 章节 preview_percent 优先(1~100),否则用全局 unpaid_preview_percent
|
||||
func effectivePreviewPercent(db *gorm.DB, ch *model.Chapter) int {
|
||||
if ch != nil && ch.PreviewPercent != nil {
|
||||
p := *ch.PreviewPercent
|
||||
if p >= 1 && p <= 100 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return getUnpaidPreviewPercent(db)
|
||||
}
|
||||
|
||||
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
|
||||
func previewContent(content string, percent int) string {
|
||||
total := utf8.RuneCountInString(content)
|
||||
@@ -712,12 +723,13 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
|
||||
hasFullAccess := isFree || checkUserChapterAccess(db, userID, ch.ID, isPremium)
|
||||
var returnContent string
|
||||
var unpaidPreviewPercent int // 未解锁时试读比例(system_config.unpaid_preview_percent);已解锁时不写入响应
|
||||
// 未解锁:正文截取用「章节覆盖 ∪ 全局」;响应里顶层 previewPercent 仅表示全局默认,data.previewPercent 表示章节私有(model omitempty)
|
||||
var effectiveUnpaidPreviewPercent int
|
||||
if hasFullAccess {
|
||||
returnContent = ch.Content
|
||||
} else {
|
||||
unpaidPreviewPercent = getUnpaidPreviewPercent(db)
|
||||
returnContent = previewContent(ch.Content, unpaidPreviewPercent)
|
||||
effectiveUnpaidPreviewPercent = effectivePreviewPercent(db, &ch)
|
||||
returnContent = previewContent(ch.Content, effectiveUnpaidPreviewPercent)
|
||||
}
|
||||
|
||||
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
|
||||
@@ -740,7 +752,7 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
"hasFullAccess": hasFullAccess,
|
||||
}
|
||||
if !hasFullAccess {
|
||||
out["previewPercent"] = unpaidPreviewPercent
|
||||
out["previewPercent"] = getUnpaidPreviewPercent(db)
|
||||
}
|
||||
// 文章详情内直接输出上一篇/下一篇,省去单独请求
|
||||
if list := getOrderedChapterList(); len(list) > 0 {
|
||||
|
||||
@@ -41,7 +41,7 @@ func naturalLessSectionID(a, b string) bool {
|
||||
var listSelectCols = []string{
|
||||
"id", "mid", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
"hot_score", "updated_at",
|
||||
"hot_score", "preview_percent", "updated_at",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
@@ -60,7 +60,8 @@ type sectionListItem struct {
|
||||
ClickCount int64 `json:"clickCount"` // 阅读次数(reading_progress)
|
||||
PayCount int64 `json:"payCount"` // 付款笔数(orders.product_type=section)
|
||||
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
|
||||
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
|
||||
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
|
||||
PreviewPercent *int `json:"previewPercent,omitempty"`
|
||||
}
|
||||
|
||||
// computeSectionListWithHotScore 计算章节列表(含 hotScore),保持 sort_order 顺序,供 章节管理 树使用
|
||||
@@ -264,19 +265,20 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
|
||||
hot = float64(r.HotScore)
|
||||
}
|
||||
item := sectionListItem{
|
||||
ID: r.ID,
|
||||
MID: r.MID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readCnt,
|
||||
PayCount: payCnt,
|
||||
HotScore: hot,
|
||||
ID: r.ID,
|
||||
MID: r.MID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readCnt,
|
||||
PayCount: payCnt,
|
||||
HotScore: hot,
|
||||
PreviewPercent: r.PreviewPercent,
|
||||
}
|
||||
if setPinned {
|
||||
item.IsPinned = pinnedSet[r.ID]
|
||||
@@ -286,6 +288,42 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// dbBookReadSectionOut 管理端 read 详情:previewPercent 必须始终出现在 JSON(null=走全局),避免 gin.H+nil 被序列化省略
|
||||
type dbBookReadSectionOut struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
Content string `json:"content"`
|
||||
IsNew *bool `json:"isNew,omitempty"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
PreviewPercent *int `json:"previewPercent"` // 禁止 omitempty,与 chapters 表 preview_percent 对齐
|
||||
}
|
||||
|
||||
func chapterToReadSectionOut(ch *model.Chapter, price float64) dbBookReadSectionOut {
|
||||
if ch == nil {
|
||||
return dbBookReadSectionOut{Price: price}
|
||||
}
|
||||
return dbBookReadSectionOut{
|
||||
ID: ch.ID,
|
||||
Title: ch.SectionTitle,
|
||||
Price: price,
|
||||
Content: ch.Content,
|
||||
IsNew: ch.IsNew,
|
||||
PartID: ch.PartID,
|
||||
PartTitle: ch.PartTitle,
|
||||
ChapterID: ch.ChapterID,
|
||||
ChapterTitle: ch.ChapterTitle,
|
||||
EditionStandard: ch.EditionStandard,
|
||||
EditionPremium: ch.EditionPremium,
|
||||
PreviewPercent: ch.PreviewPercent,
|
||||
}
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
func DBBookAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -336,19 +374,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
"section": chapterToReadSectionOut(&ch, price),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -371,19 +397,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"isNew": ch.IsNew,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
"section": chapterToReadSectionOut(&ch, price),
|
||||
})
|
||||
return
|
||||
case "section-orders":
|
||||
@@ -536,6 +550,7 @@ func DBBookAction(c *gin.Context) {
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
HotScore *float64 `json:"hotScore"`
|
||||
PreviewPercent nullablePreviewPercentJSON `json:"previewPercent"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -703,6 +718,20 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.ChapterTitle != "" {
|
||||
updates["chapter_title"] = body.ChapterTitle
|
||||
}
|
||||
if body.PreviewPercent.Set {
|
||||
if body.PreviewPercent.Val == nil {
|
||||
updates["preview_percent"] = nil
|
||||
} else {
|
||||
p := *body.PreviewPercent.Val
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
updates["preview_percent"] = p
|
||||
}
|
||||
}
|
||||
var existing model.Chapter
|
||||
err = db.Where("id = ?", body.ID).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -748,6 +777,16 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.IsNew != nil {
|
||||
ch.IsNew = body.IsNew
|
||||
}
|
||||
if body.PreviewPercent.Set && body.PreviewPercent.Val != nil {
|
||||
p := *body.PreviewPercent.Val
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
ch.PreviewPercent = &p
|
||||
}
|
||||
if err := db.Create(&ch).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
@@ -812,6 +851,26 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
// nullablePreviewPercentJSON 区分:JSON 未传 key(不改 preview_percent)、null(清空用全局)、数字(章节覆盖)
|
||||
type nullablePreviewPercentJSON struct {
|
||||
Set bool
|
||||
Val *int
|
||||
}
|
||||
|
||||
func (n *nullablePreviewPercentJSON) UnmarshalJSON(data []byte) error {
|
||||
n.Set = true
|
||||
if string(data) == "null" {
|
||||
n.Val = nil
|
||||
return nil
|
||||
}
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
n.Val = &v
|
||||
return nil
|
||||
}
|
||||
|
||||
type reorderItem struct {
|
||||
ID string `json:"id"`
|
||||
PartID string `json:"partId"`
|
||||
|
||||
@@ -37,7 +37,7 @@ func H5ReadPage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
percent := getUnpaidPreviewPercent(db)
|
||||
percent := effectivePreviewPercent(db, &ch)
|
||||
preview := h5PreviewContent(ch.Content, percent)
|
||||
|
||||
title := ch.SectionTitle
|
||||
|
||||
@@ -22,7 +22,7 @@ type Chapter struct {
|
||||
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"` // 是否属于普通版
|
||||
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,omitempty"` // 是否属于增值版
|
||||
HotScore float64 `gorm:"column:hot_score;type:decimal(10,2);default:0" json:"hotScore"` // 热度分(加权计算),用于排名算法
|
||||
PreviewPercent *int `gorm:"column:preview_percent" json:"previewPercent,omitempty"` // 章节级预览比例(%),nil 表示使用全局设置
|
||||
PreviewPercent *int `gorm:"column:preview_percent" json:"previewPercent,omitempty"` // 章节私有试读%;小程序章节接口写在 data 内;nil=未覆盖,用响应顶层 previewPercent(全局)
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -136,3 +136,13 @@
|
||||
{"level":"debug","timestamp":"2026-03-22T07:50:38+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sat, 21 Mar 2026 23:50:38 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08CEDDFCCD0610AD0118D681ECAE0120CEAF33288A71-0\r\nServer: nginx\r\nWechatpay-Nonce: e895de036317f70f8a5ef1490b5884ee\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: X1OSk9wn/q6XdK6FjA5v1YeLnyzenGPiSs4pMMRgFrNWJInZBG4vmUkq6O06sb7BYJSsTW92d2IE3BLxEC4pvUKKcwmtwB/2viZT6tD7Pb17rsJSCvnDFjp9Y/TGo96wSHO6DOWtDB0xrMJdDTlV0eTWj/HbdFS7SK6aPNn6XEVJ0C7vMfVrVHKyPzPzDpIqr/VSLeDAcVwHm4vF902+5eadg1aHRxUhfgb3SFWhO/isdo52xvEhdzBAdQErz+6ys0D1YKGEOZSG+qQLG4dfG8bCJHeN3SOWbzioWVSVVDyZZdKE4yP7myFefRZbuxlNtQLPkOlBWpL2d0nmXpunxg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1774137038\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260322071744757265\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-22T07:50:38+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260322071754905280?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"JbRwjKfwQUYuQnb5ETPS70hKiLxrv7X8\",timestamp=\"1774137038\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"TGLUEBAA4Wz5DEkvYeSFtVhAP1Mq0UiJF/g2wHNU985N++AEoASPmeSr2Q9k9XohoqUBVywm1TgL2hWWP4Nl6Okwi2Y8wm8sJTKeXZdf19MkFqiGk6IuCROV4pv0FBxEAJsqFE46tEwJVYh5mFTId6f4K9ois1IMDE0LtLdJGnZkHSLtILmkQ/8OvyWI1JMOCyJUWwAAoS7kpw9rkOYAZBUHOHZdfT3Za0upxGOGWIXwhHpciFg4biboKTaa0Ez7imOvRzkMopsPWvrjp+HwABMyNv654TvVfivrWT9T8prSUp26Ts8yycyzaR6SxxP8FeuqAJzWMUBm4FVe/i2FhA==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-22T07:50:38+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 252\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Sat, 21 Mar 2026 23:50:38 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08CEDDFCCD0610F40418C681ECAE0120F4D60E28C1AD03-0\r\nServer: nginx\r\nWechatpay-Nonce: 1b75baf8b8fc4289a9f71eec8fb6657a\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: bmr7I8PgmS6OrdYsik56QOvqPnLMD8RMFQb7YpN2ocUsdQhwpYhtWsZWmU1SRoKuQy50vLIgZz5LNWqxOoP9wRChiOKOrTHWpUNtG8ClDLb8EyfM9JH9iP7vF0pcIOu9iWOzdHCo7rYUSTcO0Q75uqaRLdnlmgniaanOqULPvpVQFvgVIfemiGO9ZPljrlxbebwkRo1MBBPxG0ktTqgRpaw6K1/iw/EceuGfXVp2X75t7KC+3SY4HVMMUyTn5LsVZvBPmsCF1z+l72vd8PtSnwIB9GjXsaMnDjaZ3tjx/FrKv1AdKYM6TRYImGtbqdsgBqGOc83tkBOmweh86rfHFw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1774137038\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":198000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260322071754905280\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:01+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:01+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Wed, 25 Mar 2026 08:30:02 GMT\r\n\r\n{\"access_token\":\"102_1nVe9nouwUQVRneYKgpBAthfO7lK8j0pU5MWSww9YXeSYQZJhdAuT9rFG0RVjeUFOW4WM3NkgKn7IgR0MutKQQGkTayM1LKAa4RSY7eHCSF2FmaSNPZRgNd1Lz0LWMaAGAALB\",\"expires_in\":7200}"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:01+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_1nVe9nouwUQVRneYKgpBAthfO7lK8j0pU5MWSww9YXeSYQZJhdAuT9rFG0RVjeUFOW4WM3NkgKn7IgR0MutKQQGkTayM1LKAa4RSY7eHCSF2FmaSNPZRgNd1Lz0LWMaAGAALB&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0f3cMtFa1bTPpL0w9wHa1IoZul2cMtF6&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:01+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Wed, 25 Mar 2026 08:30:02 GMT\r\n\r\n{\"session_key\":\"FdEpG3ungTOgwwMCuHAHlw==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:02+08:00","caller":"kernel/baseClient.go:457","content":"POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"PQPUqlvtqZBJa7f4OEm2rJigUWQBsMFH\",timestamp=\"1774427402\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"AqC8kRxYySQmpJBMFtsVH8bwRdpCoajalW7v4vBbmhkFoZT7w+Bk+aXv6AEN9rNMM+qVmxezzZiC8EnIGMDpIij5aweAvBW5uVAhMiRon7YzC/WiWeTUq3LAiJTuVx33GGsNmuKHaQeNe2nlA0q0Wra6ICXPQnyBPBW0ZDxFzSYhFZTukqAupeU7YHjtFaWtytlbogCyQ8E6FHgML/F0qKgQnpqe3T0TALYpeVJig2MlVnN8Y+l+OpTZ3wlyVHGGZSDNyaifdHoiQtd0k2aanjqV2uC3yRxAdd2o3PF+fUKU48wgnHwpKYyu7rEJzFaARkYGoJ2+niGV6dl2h2ZNHQ==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:30:02+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 52\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 25 Mar 2026 08:30:03 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 088BBA8ECE0610EC0218DAD28C5820AEE20728A3D802-0\r\nServer: nginx\r\nWechatpay-Nonce: ce934611f13cac0d6d90aa0d3bb0781d\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: w/tqQQPfl4fd1mOKZh1NOuYa5Qg2e1Pn5mMxttHdfRHebaBGdZNkIK/qOoVGEuJj7rMbZtESR6V+HuuBWY0pVFkMjzC+y5s55SEKGGWjC2y31fCMCYUdOT3gATuLaWKiiUDNnko9mSOy9T65wOIWwlI6WsXQTrJ/yDElAquzzp3Ba5QnlA1wveC5346omiorCViWd9vW4SKQylWpqk3jb7JA5CHzlX0U+i2Ug3gY/t+ZJgtyQbumHoFgtPo4dZo6nSzjvgfxc8gRtdvcTktPlCcTe/TQuEvEAn2H6gSXL8U/3rggkiT7vm9dcBL80V7gBKZZl1OUUoe8LdWoI3qAGw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1774427403\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"prepay_id\":\"wx25163003573437b216d90c6158f23e0001\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:33:45+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260325163002440000?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"ajWu7GhGuFo0buylmPOJSLtfgnCe9XGe\",timestamp=\"1774427625\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"Ql6/s2BujcCIWPE910c8Hzxp+KHSKvMFFVUxW5IAHeAGS9XJDfouSAH0Gwbvi5fQaQ+vZiLxD6Z2Rd6TQpqzT3iaugmu81tpCyhqIFX2wajXCOw6RhDXRa07slXLS9uO1ZtGsnwEeC+ok4QQeP8rzPlCZi8E3ehcB/agu070M86PuZ2GpL2Rl8qtLCYW2dlxEJEQSgKWVQsE/MdcwaAdsxqND1Ul83vpr9H1ixvm9WyzqJN33xeAegl85ydOh6WF5RBfcBFcNReaIzO6+pSXP1/+fc3IhNLKmwliGWFI5Dr+KgL5aN7OpblJU/27XvDVry598sCqkYkhN2iROV3EUA==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:33:45+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 25 Mar 2026 08:33:46 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08EABB8ECE06109F04189082F8AF0120D8D90328CF49-0\r\nServer: nginx\r\nWechatpay-Nonce: b8048d342513c2da6196bba1b71a40ee\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: QJ5LpLXHxdHU7s9+ui0+yNZwe3hiYYlia0Phc8t4wHjbA5fxu5HuC6EB7KsfswfL67B+yORhspup6wI23H3IuUK6BP9JZ24zUIs2eoo5ONNmrQIO9vppAA3Q8je/zPKjIlmIkVibiXo4k/OL3Ub1aSRaR4gglKKjpMXZH943y374Z1fY4zyOPwGrg3fMHVtHLHvsJf/7QOfRE1VGlqqEkgvAdE/+hYA0xGFPjauXFTUqmTUnwL9vLMiv9V1HETbhsTfkphZ7dtJTIe087frV8lP/C2mQU4UALc3J2EqNpOE4scMByijkT9dayev5SsaC+DVxkg4NtSHUA2sb9nkMwQ==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1774427626\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":3000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260325163002440000\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:38:46+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260325163002440000?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"hkXSXB4Qzr7zDbF1hxMm726B7x8CxPR2\",timestamp=\"1774427925\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"LxjtaHJLKqRGnoNqcpkjeGBHaCUs9RUXRp/EQcP4nhDep65UoYVOpFDEWEKMiQtUKL0hPB33NSU9hQeH6WjMvquCbsszExcnIX8INNuXcA3HfKWKqZaZug3GCICCjF8Qz7brY/raeaTT4W5rkgA9Js7G3llTgGVLmHntOdegVIXtDIEjq5a134MmwhjImi5acXp6gfET88Zes+RPIhiuk13Vj+FQH1k4LlqfVh5O68RVB7zEwx1CPbhrwCNiOVNVVYhLIE+kvMIgUmLmod30qfjjW3BW0KKoD53GpdYIvuh1E/iXqeDzB9i0JN1NgGblmxTgtRr4nQnVEZUmyFoKWA==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-25T16:38:46+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 25 Mar 2026 08:38:46 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0896BE8ECE0610B90418CBC0C05520E0CF1928F0F302-0\r\nServer: nginx\r\nWechatpay-Nonce: b8dc2cedcb145dc63bb4fd1d7944e28a\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: IHfBE6FO4cZZk+X1oulmGu+PauZ5BK4Ai7x9TviyzKrZpGGgcV6RE5z1BdI4AGJGiBv0RiecWCdJ1jUSwXAi9QPVgag8qepWaQCD+SYvHyRbU8bQJhRXYfhWOdwpI2qgUx2/mIcTX/kpE+DJVyz/2CpMKnIPy1J1W//37zv0vuUwZJ+OdVZRcOIL6UiuUB34KKWcUtF6g9GahSDsAKziSSu6Qe9Os9Ja/3dc9Xpxc7VbH/Qj1ovvjciiq/KWuh/+eJjoRBaiXC9mIm9AxckxxYSumYmR2jAWXomolrOiTaAAJ7Jc/wYiNa0AXwyonN/RyhggsPwBeqeOW6ShQlCahQ==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1774427926\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":3000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260325163002440000\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
|
||||
Reference in New Issue
Block a user