]*>/gi, '').replace(/<\/p>/gi, '').trim()
+ if (!cleaned) return '\n'
+ if (olDepth > 0) {
+ olCounter++
+ return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
+ }
+ return '\n__LI_U__ ' + cleaned + '\n'
+ })
+
+ // 4. 块级标签换行
+ text = text.replace(/<\/p>\s*
]*>/gi, '')
+ text = text.replace(/<\/div>/gi, '\n')
+ text = text.replace(/
/gi, '\n')
+ text = text.replace(/
/gi, '\n')
+
+ // 5. 逐段解析
+ var blocks = text.split(/\n+/)
+ for (var bi = 0; bi < blocks.length; bi++) {
+ var block = blocks[bi]
+ if (!block.trim()) continue
+
+ // table
+ var tblM = block.trim().match(/^__TABLE_(\d+)__$/)
+ if (tblM) {
+ var tbl = tables[parseInt(tblM[1], 10)]
+ if (tbl) {
+ lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n'))
+ segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }])
+ }
+ continue
+ }
+
+ // video(整块占位)
+ var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
+ if (vidM) {
+ var v = videos[parseInt(vidM[1], 10)]
+ if (v && v.src) {
+ lines.push('')
+ segments.push([{ type: 'video', src: v.src }])
+ }
+ continue
+ }
+
+ // heading
+ var hM = block.trim().match(/^__H_(\d+)__$/)
+ if (hM) {
+ var h = headings[parseInt(hM[1], 10)]
+ if (h) {
+ var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim())
+ lines.push(hText)
+ segments.push([{ type: 'heading', level: h.level, text: hText }])
+ }
+ continue
+ }
+
+ // quote
+ var qM = block.trim().match(/^__Q_(\d+)__$/)
+ if (qM) {
+ var qHtml = quotes[parseInt(qM[1], 10)]
+ if (qHtml) {
+ var qText = decodeEntities(
+ qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim()
+ ).replace(/\n{2,}/g, '\n')
+ lines.push(qText)
+ segments.push([{ type: 'quote', text: qText }])
+ }
+ continue
+ }
+
+ // ordered list item
+ var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/)
+ if (liOM) {
+ var liNum = parseInt(liOM[1], 10)
+ var liContent = liOM[2].trim()
+ var liSegs = parseBlockToSegments(liContent, config)
+ var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim())
+ lines.push(liNum + '. ' + liText)
+ segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }])
+ continue
+ }
+
+ // unordered list item
+ var liUM = block.match(/^__LI_U__\s*(.*)/)
+ if (liUM) {
+ var liContent2 = liUM[1].trim()
+ var liSegs2 = parseBlockToSegments(liContent2, config)
+ var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim())
+ lines.push(liText2)
+ segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }])
+ continue
+ }
+
+ // 普通段落
+ var blockSegs = parseBlockToSegments(block, config)
+ if (!blockSegs.length) continue
+
+ if (
+ blockSegs.length === 1 &&
+ (blockSegs[0].type === 'image' || blockSegs[0].type === 'video')
+ ) {
+ lines.push('')
+ segments.push(blockSegs)
+ continue
+ }
+
+ if (config && (config.persons?.length || config.linkTags?.length)) {
+ var expanded = []
+ for (var si = 0; si < blockSegs.length; si++) {
+ var seg = blockSegs[si]
+ if (seg.type === 'text' && seg.text) {
+ expanded.push.apply(expanded, matchLineToSegments(seg.text, config))
+ } else {
+ expanded.push(seg)
+ }
+ }
+ blockSegs = expanded
+ }
+
+ var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
+ lines.push(lineText)
+ segments.push(blockSegs)
+ }
+
+ return { lines, segments }
+}
+
+/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
+function stripMarkdownFormatting(text) {
+ if (!text) return text
+ let s = text
+ s = s.replace(/^#{1,6}\s+/gm, '')
+ s = s.replace(/\*\*(.+?)\*\*/g, '$1')
+ s = s.replace(/__(.+?)__/g, '$1')
+ s = s.replace(/(?\s+/gm, '')
+ s = s.replace(/^---$/gm, '')
+ s = s.replace(/^\* /gm, '• ')
+ s = s.replace(/^- /gm, '• ')
+ s = s.replace(/^\d+\.\s/gm, '')
+ return s
+}
+
+/**
+ * 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
+ * config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
+ * 点击加好友时须传 persons.token(与 CKB lead 的 targetUserId 一致),不能用 personId。
+ */
+function matchLineToSegments(line, config) {
+ if (!config || (!config.persons?.length && !config.linkTags?.length)) {
+ return [{ type: 'text', text: line }]
+ }
+ // 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
+ line = line.replace(/([@@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
+ const normalize = s => (s || '').trim().toLowerCase()
+ const personMap = {}
+ const tagMap = {}
+ for (const p of (config.persons || [])) {
+ const token = (p.token || '').trim()
+ if (!token) continue
+ const display = (p.name || p.label || '').trim()
+ const aliasStr = p.aliases != null ? String(p.aliases) : ''
+ const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
+ .map((x) => (x != null ? String(x) : '').trim())
+ .filter(Boolean)
+ .map(normalize)
+ .filter(Boolean)
+ for (const k of keys) {
+ if (!personMap[k]) personMap[k] = p
+ }
+ }
+ for (const t of (config.linkTags || [])) {
+ const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
+ for (const k of keys) { if (!tagMap[k]) tagMap[k] = t }
+ }
+ const esc = n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const personNames = Object.keys(personMap).sort((a, b) => b.length - a.length).map(esc)
+ const tagLabels = Object.keys(tagMap).sort((a, b) => b.length - a.length).map(esc)
+ if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
+
+ const parts = []
+ if (personNames.length) parts.push('[@@]\\s*(' + personNames.join('|') + ')')
+ if (tagLabels.length) parts.push('[##]\\s*(' + tagLabels.join('|') + ')')
+ const pattern = new RegExp(parts.join('|'), 'gi')
+
+ const segs = []
+ let lastEnd = 0
+ let m
+ while ((m = pattern.exec(line)) !== null) {
+ if (m.index > lastEnd) {
+ segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
+ }
+ const full = m[0]
+ if (/^[@@]/u.test(full)) {
+ const body = full.replace(/^[@@]\s*/u, '')
+ const person = personMap[normalize(body)]
+ if (person) {
+ const nick = cleanSingleLineField(person.name || person.label || body)
+ const uid = (person.token || '').trim()
+ if (uid) {
+ segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
+ } else {
+ segs.push({ type: 'text', text: full })
+ }
+ } else {
+ segs.push({ type: 'text', text: full })
+ }
+ } else {
+ const body = full.replace(/^[##]\s*/u, '')
+ const tag = tagMap[normalize(body)]
+ if (tag) {
+ segs.push({
+ type: 'linkTag',
+ label: tag.label || body,
+ url: tag.url || '',
+ tagType: tag.type || 'url',
+ pagePath: tag.pagePath || '',
+ tagId: tag.tagId || '',
+ appId: tag.appId || '',
+ mpKey: tag.mpKey || '',
+ passPhone: !!tag.passPhone,
+ phoneParamName: tag.phoneParamName || 'phone'
+ })
+ } else {
+ segs.push({ type: 'text', text: full })
+ }
+ }
+ lastEnd = m.index + full.length
+ }
+ if (lastEnd < line.length) {
+ segs.push({ type: 'text', text: line.slice(lastEnd) })
+ }
+ return segs.length ? segs : [{ type: 'text', text: line }]
+}
+
+/** 纯文本/Markdown 按行解析 */
+function parsePlainTextToSegments(text, config) {
+ const cleaned = stripMarkdownFormatting(text)
+ const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
+ const segments = lines.map(line => matchLineToSegments(line, config))
+ return { lines, segments }
+}
+
+/** 清理残留的 Markdown 图片引用文本(如 "image.png" ) */
+function stripOrphanImageRefs(text) {
+ if (!text) return text
+ text = text.replace(/[^\s]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp)!\[[^\]]*\]\([^)]*\)/gi, '')
+ text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+ return text
+}
+
+/**
+ * 将原始内容解析为 contentSegments(用于阅读页展示)
+ * @param {string} rawContent
+ * @param {object} [config] - { persons: [], linkTags: [] }
+ * @returns {{ lines: string[], segments: Array
> }}
+ */
+function parseContent(rawContent, config) {
+ if (!rawContent || typeof rawContent !== 'string') {
+ return { lines: [], segments: [] }
+ }
+ let content = stripOrphanImageRefs(rawContent)
+ if (isHtmlContent(content)) {
+ return parseHtmlToSegments(content, config)
+ }
+ return parsePlainTextToSegments(content, config)
+}
+
+export { parseContent, isHtmlContent, cleanSingleLineField }
diff --git a/reactH5/src/utils/readMarketingRuleH5.ts b/reactH5/src/utils/readMarketingRuleH5.ts
new file mode 100644
index 00000000..fda493a7
--- /dev/null
+++ b/reactH5/src/utils/readMarketingRuleH5.ts
@@ -0,0 +1,143 @@
+import { request } from '@/api/request'
+
+export type Pay365RuleUi = {
+ ruleId: string
+ serverRuleId?: number
+ title: string
+ message: string
+ confirmText: string
+ cancelText: string
+ action: 'pay365' | 'navigate'
+ target: string | null
+ amount: number | null
+ revealMode: 'modal' | 'anchor'
+ anchorLabel: string
+}
+
+function trimStr(v: unknown): string {
+ if (v == null) return ''
+ return String(v).trim()
+}
+
+function findRuleByTriggerCondition(rules: Array>, conditionKey: string) {
+ return rules.find((r) => {
+ if (r.completed) return false
+ const tc = r.triggerConditions
+ if (Array.isArray(tc)) return tc.includes(conditionKey)
+ if (typeof tc === 'string') {
+ try {
+ const parsed = JSON.parse(tc) as unknown
+ if (Array.isArray(parsed)) return parsed.includes(conditionKey)
+ } catch {
+ /* */
+ }
+ }
+ return false
+ })
+}
+
+function parseActionConfig(rule: Record | undefined): Record {
+ if (!rule) return {}
+ const ac = rule.actionConfig
+ if (ac && typeof ac === 'object' && !Array.isArray(ac)) return ac as Record
+ if (typeof ac === 'string' && ac.trim()) {
+ try {
+ const p = JSON.parse(ac) as unknown
+ if (p && typeof p === 'object') return p as Record
+ } catch {
+ /* */
+ }
+ }
+ return {}
+}
+
+let rulesCache: Record[] | null = null
+let cacheTs = 0
+const CACHE_TTL = 5 * 60 * 1000
+
+export async function loadUserRules(userId: string, force = false): Promise[]> {
+ const now = Date.now()
+ if (!force && rulesCache && now - cacheTs < CACHE_TTL) return rulesCache
+ try {
+ const url = userId.trim()
+ ? `/api/miniprogram/user-rules?userId=${encodeURIComponent(userId)}`
+ : '/api/miniprogram/user-rules'
+ const res = await request<{ success?: boolean; rules?: Record[] }>({
+ url,
+ method: 'GET',
+ silent: true,
+ })
+ if (res?.success && Array.isArray(res.rules)) {
+ rulesCache = res.rules
+ cacheTs = now
+ return res.rules
+ }
+ } catch {
+ /* */
+ }
+ return rulesCache || []
+}
+
+export async function getChapterReadMarketingRule(
+ userId: string,
+ ctx: {
+ hasFullBook: boolean
+ isVip: boolean
+ getReadCount: () => number
+ getBrowseDistinctChapterCount: () => number
+ }
+): Promise {
+ if (!userId.trim()) return null
+ if (ctx.hasFullBook || ctx.isVip) return null
+
+ const rules = await loadUserRules(userId)
+ const rule =
+ findRuleByTriggerCondition(rules as Array>, 'view_n_chapters_pay365') ||
+ findRuleByTriggerCondition(rules as Array>, 'browse_n_chapters')
+ if (!rule) return null
+
+ const ac = parseActionConfig(rule)
+ const threshold = Number(ac.chapterThreshold) > 0 ? Number(ac.chapterThreshold) : 3
+ const fullRead = ctx.getReadCount()
+ const browseDistinct = ctx.getBrowseDistinctChapterCount()
+ const readCount = Math.max(Number(fullRead) || 0, Number(browseDistinct) || 0)
+ if (readCount < threshold) return null
+
+ const ctaKind = String(ac.ctaKind || 'pay365')
+ const amount = ctaKind === 'pay365' ? (Number(ac.amount) > 0 ? Number(ac.amount) : 365) : null
+ const rm = ac.revealMode
+ let revealMode: 'modal' | 'anchor' = rm === 'modal' || rm === 'anchor' ? (rm as 'modal' | 'anchor') : 'modal'
+ if (!revealMode) revealMode = 'modal'
+ const anchorLabel = trimStr(ac.anchorLabel)
+
+ return {
+ ruleId: 'view_n_pay365',
+ serverRuleId:
+ typeof rule.id === 'number' ? rule.id : typeof rule.id === 'string' ? parseInt(String(rule.id), 10) : undefined,
+ title: trimStr(rule.title) || '加入 365 读书会',
+ message:
+ trimStr(rule.description) ||
+ `你已阅读 ${readCount} 个章节,加入 365 读书会可解锁全部内容,一年内不限次阅读。`,
+ confirmText: amount ? `¥${amount} 立即加入` : '去了解',
+ cancelText: '再看看',
+ action: ctaKind === 'pay365' ? 'pay365' : 'navigate',
+ target: null,
+ amount,
+ revealMode,
+ anchorLabel: anchorLabel || '加入 365 读书会,解锁全部内容',
+ }
+}
+
+export async function markRuleCompletedOnServer(userId: string, ruleId: number | null | undefined) {
+ if (!userId || !ruleId || !Number.isFinite(ruleId)) return
+ try {
+ await request({
+ url: '/api/miniprogram/user-rules/complete',
+ method: 'POST',
+ data: { userId, ruleId },
+ silent: true,
+ })
+ } catch {
+ /* */
+ }
+}
diff --git a/reactH5/src/utils/readStorage.ts b/reactH5/src/utils/readStorage.ts
new file mode 100644
index 00000000..b73887f0
--- /dev/null
+++ b/reactH5/src/utils/readStorage.ts
@@ -0,0 +1,71 @@
+const RECENT_OPENS_KEY = 'recent_section_opens'
+const READ_IDS_KEY = 'readSectionIds'
+
+type RecentOpen = { id: string; t: number }
+
+export function touchRecentSection(sectionId: string) {
+ if (!sectionId) return
+ try {
+ let list: RecentOpen[] = []
+ const raw = localStorage.getItem(RECENT_OPENS_KEY)
+ if (raw) {
+ const p = JSON.parse(raw) as unknown
+ if (Array.isArray(p)) list = p as RecentOpen[]
+ }
+ const now = Date.now()
+ list = list.filter((x) => x && String(x.id) !== String(sectionId))
+ list.unshift({ id: String(sectionId), t: now })
+ localStorage.setItem(RECENT_OPENS_KEY, JSON.stringify(list.slice(0, 40)))
+ } catch {
+ /* */
+ }
+}
+
+export function getBrowseDistinctChapterCount(): number {
+ try {
+ const raw = localStorage.getItem(RECENT_OPENS_KEY)
+ if (!raw) return 0
+ const list = JSON.parse(raw) as unknown
+ if (!Array.isArray(list)) return 0
+ const ids = new Set()
+ for (const x of list) {
+ if (x && typeof x === 'object' && (x as RecentOpen).id) {
+ ids.add(String((x as RecentOpen).id))
+ }
+ }
+ return ids.size
+ } catch {
+ return 0
+ }
+}
+
+/** 对齐小程序「已读章节」列表(仅存 localStorage) */
+export function persistMarkSectionRead(sectionId: string) {
+ if (!sectionId) return loadReadSectionIds()
+ try {
+ const raw = localStorage.getItem(READ_IDS_KEY)
+ let ids: string[] = []
+ if (raw) {
+ const p = JSON.parse(raw) as unknown
+ if (Array.isArray(p)) ids = p as string[]
+ }
+ if (!ids.includes(sectionId)) {
+ ids = [...ids, sectionId]
+ localStorage.setItem(READ_IDS_KEY, JSON.stringify(ids))
+ }
+ return ids
+ } catch {
+ return loadReadSectionIds()
+ }
+}
+
+export function loadReadSectionIds(): string[] {
+ try {
+ const raw = localStorage.getItem(READ_IDS_KEY)
+ if (!raw) return []
+ const p = JSON.parse(raw) as unknown
+ return Array.isArray(p) ? (p as string[]) : []
+ } catch {
+ return []
+ }
+}
diff --git a/reactH5/src/utils/readingTrackerH5.ts b/reactH5/src/utils/readingTrackerH5.ts
new file mode 100644
index 00000000..c312ce7d
--- /dev/null
+++ b/reactH5/src/utils/readingTrackerH5.ts
@@ -0,0 +1,148 @@
+import { request } from '@/api/request'
+
+type Tracker = {
+ sectionId: string
+ startTime: number
+ lastScrollTime: number
+ totalDuration: number
+ maxProgress: number
+ lastPosition: number
+ isCompleted: boolean
+ completedAt: number | null
+}
+
+const LS_KEY = 'reading_progress'
+
+type ProgressRow = { progress: number; lastPosition: number; lastOpenAt: number }
+
+function loadMap(): Record {
+ try {
+ const raw = localStorage.getItem(LS_KEY)
+ if (!raw) return {}
+ const p = JSON.parse(raw) as unknown
+ return p && typeof p === 'object' ? (p as Record) : {}
+ } catch {
+ return {}
+ }
+}
+
+function saveLocal(sectionId: string, progress: number, lastPosition: number) {
+ try {
+ const m = loadMap()
+ m[sectionId] = { progress, lastPosition, lastOpenAt: Date.now() }
+ localStorage.setItem(LS_KEY, JSON.stringify(m))
+ } catch {
+ /* */
+ }
+}
+
+class ReadingTrackerH5 {
+ active: Tracker | null = null
+ reportInterval: ReturnType | null = null
+
+ init(sectionId: string, userId: string | null, onRestoreScroll?: (top: number) => void) {
+ this.cleanup()
+ this.active = {
+ sectionId,
+ startTime: Date.now(),
+ lastScrollTime: Date.now(),
+ totalDuration: 0,
+ maxProgress: 0,
+ lastPosition: 0,
+ isCompleted: false,
+ completedAt: null,
+ }
+ saveLocal(sectionId, 0, 0)
+ void this.reportToServer(userId, false)
+
+ const m = loadMap()[sectionId]
+ if (m && m.lastPosition > 100 && onRestoreScroll) {
+ setTimeout(() => onRestoreScroll(m.lastPosition), 400)
+ }
+
+ this.reportInterval = setInterval(() => {
+ void this.reportToServer(userId, false)
+ }, 30000)
+ }
+
+ updateProgress(
+ scrollTop: number,
+ scrollHeight: number,
+ clientHeight: number,
+ userId: string | null,
+ onProgressPct?: (n: number) => void
+ ) {
+ if (!this.active) return
+ const totalScrollable = scrollHeight - clientHeight
+ if (totalScrollable <= 0) return
+ const progress = Math.min(100, Math.round((scrollTop / totalScrollable) * 100))
+ if (progress > this.active.maxProgress) {
+ this.active.maxProgress = progress
+ this.active.lastPosition = scrollTop
+ saveLocal(this.active.sectionId, progress, scrollTop)
+ onProgressPct?.(progress)
+ }
+ if (progress >= 90 && !this.active.isCompleted) {
+ void this.checkCompletion(userId)
+ }
+ }
+
+ private async checkCompletion(userId: string | null) {
+ if (!this.active || this.active.isCompleted) return
+ await new Promise((r) => setTimeout(r, 3000))
+ if (this.active && this.active.maxProgress >= 90 && !this.active.isCompleted) {
+ this.active.isCompleted = true
+ this.active.completedAt = Date.now()
+ await this.reportToServer(userId, true)
+ }
+ }
+
+ private async reportToServer(userId: string | null, isCompletion: boolean) {
+ if (!this.active) return
+ const uid = (userId || '').trim()
+ if (!uid) return
+
+ const now = Date.now()
+ const duration = Math.round((now - this.active.lastScrollTime) / 1000)
+ this.active.totalDuration += duration
+ this.active.lastScrollTime = now
+
+ const data: Record = {
+ userId: uid,
+ sectionId: this.active.sectionId,
+ progress: this.active.maxProgress,
+ duration: this.active.totalDuration,
+ status: this.active.isCompleted ? 'completed' : 'reading',
+ }
+ if (this.active.isCompleted && this.active.completedAt != null) {
+ data.completedAt = new Date(this.active.completedAt).toISOString()
+ }
+
+ try {
+ await request({
+ url: '/api/miniprogram/user/reading-progress',
+ method: 'POST',
+ data,
+ silent: true,
+ })
+ } catch {
+ /* */
+ }
+ if (isCompletion) {
+ /* toast 由页面处理 */
+ }
+ }
+
+ cleanup(userId?: string | null) {
+ if (this.reportInterval) {
+ clearInterval(this.reportInterval)
+ this.reportInterval = null
+ }
+ if (this.active) {
+ void this.reportToServer(userId ?? null, false)
+ this.active = null
+ }
+ }
+}
+
+export const readingTrackerH5 = new ReadingTrackerH5()
diff --git a/reactH5/src/utils/sceneCompat.ts b/reactH5/src/utils/sceneCompat.ts
new file mode 100644
index 00000000..e8e5aac2
--- /dev/null
+++ b/reactH5/src/utils/sceneCompat.ts
@@ -0,0 +1,33 @@
+/** 对齐 miniprogram utils/scene.parseScene */
+
+const SEP = '_'
+
+export function parseScene(sceneStr: string | null | undefined): {
+ mid: number
+ id: string
+ ref: string
+} {
+ const res = { mid: 0, id: '', ref: '' }
+ if (!sceneStr || typeof sceneStr !== 'string') return res
+ const decoded = decodeURIComponent(String(sceneStr)).trim()
+ const parts = decoded.split(/[&_]/)
+ for (const part of parts) {
+ const eq = part.indexOf('=')
+ if (eq > 0) {
+ const k = part.slice(0, eq)
+ const v = part.slice(eq + 1)
+ if (k === 'mid') res.mid = parseInt(v, 10) || 0
+ if (k === 'id' && v) res.id = v
+ if (k === 'ref' && v) res.ref = v
+ }
+ }
+ return res
+}
+
+export function buildScene(opts: { mid?: number; id?: string; ref?: string }): string {
+ const parts: string[] = []
+ if (opts.mid != null && `${opts.mid}`.trim() !== '') parts.push(`mid=${opts.mid}`)
+ if (opts.id) parts.push(`id=${opts.id}`)
+ if (opts.ref) parts.push(`ref=${opts.ref}`)
+ return parts.join(SEP)
+}
diff --git a/reactH5/vite.config.ts b/reactH5/vite.config.ts
index 1ebded3c..f41b869e 100644
--- a/reactH5/vite.config.ts
+++ b/reactH5/vite.config.ts
@@ -1,22 +1,30 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
// https://vite.dev/config/
-export default defineConfig({
- plugins: [react()],
- resolve: {
- alias: {
- '@': path.resolve(__dirname, 'src'),
- },
- },
- server: {
- proxy: {
- '/api': {
- target: 'https://soulapi.quwanzhi.com',
- changeOrigin: true,
- secure: true,
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ // 默认走本机 soul-api :9100,避免未配 .env 时误连线上 release 导致 h5/login 403
+ const proxyTarget =
+ (env.VITE_PROXY_TARGET || '').trim() || 'http://127.0.0.1:9100'
+ const secure = proxyTarget.startsWith('https')
+
+ return {
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
},
},
- },
+ server: {
+ proxy: {
+ '/api': {
+ target: proxyTarget,
+ changeOrigin: true,
+ secure,
+ },
+ },
+ },
+ }
})
diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go
index 7a2864ac..ecf1f9e1 100644
--- a/soul-api/internal/config/config.go
+++ b/soul-api/internal/config/config.go
@@ -83,6 +83,10 @@ func (c *Config) BaseURLJoin(path string) string {
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
var defaultCORSOrigins = []string{
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:4173",
+ "http://127.0.0.1:4173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"https://soul.quwanzhi.com",
diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go
index 0ee841f7..365f2bdb 100644
--- a/soul-api/internal/database/database.go
+++ b/soul-api/internal/database/database.go
@@ -63,6 +63,7 @@ func Init(dsn string) error {
}
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
+ ensureUserPasswordHashColumn(db)
log.Println("database: connected")
return nil
}
@@ -152,6 +153,7 @@ func Init(dsn string) error {
}
ensureOpenPlatformTablesRaw(db)
ensureUserDiscPdpColumns(db)
+ ensureUserPasswordHashColumn(db)
log.Println("database: connected")
return nil
}
@@ -171,6 +173,16 @@ func ensureUserDiscPdpColumns(db *gorm.DB) {
}
}
+// ensureUserPasswordHashColumn users 表补充 password_hash(SKIP_AUTO_MIGRATE 等场景)
+func ensureUserPasswordHashColumn(db *gorm.DB) {
+ m := db.Migrator()
+ if !m.HasColumn(&model.User{}, "PasswordHash") {
+ if err := db.Exec("ALTER TABLE users ADD COLUMN password_hash VARCHAR(128) NULL COMMENT 'H5登录 bcrypt'").Error; err != nil {
+ log.Printf("database: users add password_hash column warning: %v", err)
+ }
+ }
+}
+
// DB 返回全局 *gorm.DB,仅在 Init 成功后调用
func DB() *gorm.DB {
return db
diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go
index f4917824..0f354718 100644
--- a/soul-api/internal/handler/miniprogram.go
+++ b/soul-api/internal/handler/miniprogram.go
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "regexp"
"strconv"
"strings"
"sync"
@@ -19,6 +20,7 @@ import (
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
+ "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
@@ -275,11 +277,32 @@ func MiniprogramDevLoginAs(c *gin.Context) {
})
}
+// saveH5UserPasswordHash 用原生 SQL 更新 users.password_hash,避免 GORM Model+Updates 在部分库/版本下更新失败。
+func saveH5UserPasswordHash(db *gorm.DB, userID, bcryptHash string) error {
+ userID = strings.TrimSpace(userID)
+ if userID == "" {
+ return fmt.Errorf("empty user id")
+ }
+ at := time.Now()
+ r := db.Exec("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", bcryptHash, at, userID)
+ if r.Error != nil {
+ return r.Error
+ }
+ if r.RowsAffected < 1 {
+ return fmt.Errorf("no rows updated for id=%s", userID)
+ }
+ return nil
+}
+
// miniprogramPhoneLoginAllowed 开发环境 或 显式开启 H5 手机号登录 时允许(按库内已绑定手机匹配用户,与小程序微信登录为同一套 users 表)
func miniprogramPhoneLoginAllowed() bool {
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) == "development" {
return true
}
+ // 本地常未设 APP_ENV;Gin 默认 GIN_MODE=debug,与 release 生产区分,避免联调 H5 误 403
+ if strings.ToLower(strings.TrimSpace(os.Getenv("GIN_MODE"))) == "debug" {
+ return true
+ }
v := strings.TrimSpace(os.Getenv("H5_PHONE_LOGIN_ENABLED"))
if v == "" {
return false
@@ -289,10 +312,14 @@ func miniprogramPhoneLoginAllowed() bool {
}
// MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 与 POST /api/miniprogram/h5/login-by-phone
-// 使用手机号在库中查找已注册用户;开发环境默认可用;生产环境需设置 H5_PHONE_LOGIN_ENABLED=1
+// 使用手机号在库中查找已注册用户;须至少 6 位密码:password_hash 为空时写入 bcrypt,否则校验。
+// 放行条件:APP_ENV=development,或 GIN_MODE=debug(本地默认),或 H5_PHONE_LOGIN_ENABLED=1;生产须 release + 显式开关。
func MiniprogramDevLoginByPhone(c *gin.Context) {
if !miniprogramPhoneLoginAllowed() {
- c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "手机号登录未开启(开发环境可自动使用;生产需设置 H5_PHONE_LOGIN_ENABLED=1)"})
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "error": "H5 手机号登录未开启:本机 soul-api 请使用 APP_ENV=development 或 GIN_MODE=debug;reactH5 请将 Vite 代理指向本机(默认 127.0.0.1:9100,见 vite.config)。若必须连线上 API,请在服务端设置 H5_PHONE_LOGIN_ENABLED=1",
+ })
return
}
var req struct {
@@ -315,6 +342,39 @@ func MiniprogramDevLoginByPhone(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"})
return
}
+
+ plain := strings.TrimSpace(req.Password)
+ storedHash := ""
+ if user.PasswordHash != nil {
+ storedHash = strings.TrimSpace(*user.PasswordHash)
+ }
+ if storedHash == "" {
+ if len(plain) < 6 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "首次登录请设置至少6位密码"})
+ return
+ }
+ hp, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
+ return
+ }
+ hs := string(hp)
+ if err := saveH5UserPasswordHash(db, user.ID, hs); err != nil {
+ log.Printf("login-by-phone: save password_hash user=%s err=%v", user.ID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存密码失败"})
+ return
+ }
+ } else {
+ if plain == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入密码"})
+ return
+ }
+ if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "手机号或密码错误"})
+ return
+ }
+ }
+
openID := getStringValue(user.OpenID)
if openID == "" {
openID = user.ID
@@ -371,6 +431,209 @@ func MiniprogramDevLoginByPhone(c *gin.Context) {
})
}
+var h5PhoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
+
+// MiniprogramH5RegisterByPhone POST /api/miniprogram/h5/register-by-phone
+// 与 H5 登录同开关:仅允许未占用手机号的纯 H5 账号(无微信 open_id);注册成功后返回与 login-by-phone 一致的 data。
+func MiniprogramH5RegisterByPhone(c *gin.Context) {
+ if !miniprogramPhoneLoginAllowed() {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "error": "H5 手机号注册未开启:与本机 h5/login-by-phone 403 说明相同,请开启 development / GIN_MODE=debug / H5_PHONE_LOGIN_ENABLED",
+ })
+ return
+ }
+ var req struct {
+ Phone string `json:"phone" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
+ return
+ }
+ phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
+ if !h5PhoneRegexp.MatchString(phone) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入有效的大陆11位手机号"})
+ return
+ }
+ pwd := strings.TrimSpace(req.Password)
+ if len(pwd) < 6 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少6位"})
+ return
+ }
+ db := database.DB()
+ var existed model.User
+ if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).
+ First(&existed).Error; err == nil {
+ c.JSON(http.StatusConflict, gin.H{"success": false, "error": "该手机号已注册,请直接登录"})
+ return
+ }
+ hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
+ return
+ }
+ hs := string(hp)
+
+ nickname := fmt.Sprintf("用户%s", phone[len(phone)-4:])
+ avatar := ""
+ hasFullBook := false
+ earnings := 0.0
+ pendingEarnings := 0.0
+ referralCount := 0
+ purchasedJSON := "[]"
+
+ var createdUser model.User
+ created := false
+ for attempt := 0; attempt < 12; attempt++ {
+ userID := "user_" + randomSuffix()
+ refBase := strings.ToUpper(randomSuffix())
+ if len(refBase) < 6 {
+ refBase = refBase + strings.ToUpper(randomSuffix())
+ }
+ rc := "SOUL" + refBase
+ rc = strings.TrimSpace(rc)
+ if len(rc) > 20 {
+ rc = rc[:20]
+ }
+ var dupRC model.User
+ if db.Where("referral_code = ?", rc).First(&dupRC).Error == nil {
+ continue
+ }
+
+ ph := phone
+ u := model.User{
+ ID: userID,
+ Phone: &ph,
+ PasswordHash: &hs,
+ Nickname: &nickname,
+ Avatar: &avatar,
+ ReferralCode: &rc,
+ HasFullBook: &hasFullBook,
+ PurchasedSections: &purchasedJSON,
+ Earnings: &earnings,
+ PendingEarnings: &pendingEarnings,
+ ReferralCount: &referralCount,
+ }
+
+ if err := db.Create(&u).Error; err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+ continue
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册失败,请稍后重试"})
+ return
+ }
+ createdUser = u
+ created = true
+ break
+ }
+ if !created {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册繁忙,请稍后重试"})
+ return
+ }
+
+ AdminShensheShouAutoTag(createdUser.ID, phone)
+
+ // 对齐 login-by-phone 的 token 与用户 payload
+ openID := getStringValue(createdUser.OpenID)
+ if openID == "" {
+ openID = createdUser.ID
+ }
+ tokenSuffix := openID
+ if len(openID) >= 8 {
+ tokenSuffix = openID[len(openID)-8:]
+ }
+ token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
+
+ var purchasedSections []string
+ var orderRows []struct {
+ ProductID string `gorm:"column:product_id"`
+ }
+ db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, createdUser.ID).Scan(&orderRows)
+ for _, row := range orderRows {
+ if row.ProductID != "" {
+ purchasedSections = append(purchasedSections, row.ProductID)
+ }
+ }
+ if purchasedSections == nil {
+ purchasedSections = []string{}
+ }
+
+ responseUser := map[string]interface{}{
+ "id": createdUser.ID,
+ "openId": openID,
+ "nickname": nickname,
+ "avatar": resolveAvatarURLWithRequest(c, avatar),
+ "phone": phone,
+ "wechatId": "",
+ "referralCode": getStringValue(createdUser.ReferralCode),
+ "hasFullBook": false,
+ "purchasedSections": purchasedSections,
+ "earnings": 0.0,
+ "pendingEarnings": 0.0,
+ "referralCount": 0,
+ "createdAt": createdUser.CreatedAt,
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": map[string]interface{}{
+ "openId": openID,
+ "user": responseUser,
+ "token": token,
+ },
+ })
+}
+
+// MiniprogramH5ResetPassword POST /api/miniprogram/h5/reset-password
+// 与 H5 手机号登录同开关;无短信/邮箱验码,依赖网关限流与 H5_PHONE_LOGIN_ENABLED 管控。
+func MiniprogramH5ResetPassword(c *gin.Context) {
+ if !miniprogramPhoneLoginAllowed() {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "error": "H5 密码重置与登录共用开关,请见 h5/login-by-phone 403 说明或设置 H5_PHONE_LOGIN_ENABLED=1",
+ })
+ return
+ }
+ var req struct {
+ Phone string `json:"phone" binding:"required"`
+ NewPassword string `json:"newPassword" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
+ return
+ }
+ phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
+ pwd := strings.TrimSpace(req.NewPassword)
+ if phone == "" || len(pwd) < 6 {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入手机号及至少6位新密码"})
+ return
+ }
+ db := database.DB()
+ var user model.User
+ if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err == nil {
+ hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
+ return
+ }
+ hashStr := string(hp)
+ if err := saveH5UserPasswordHash(db, user.ID, hashStr); err != nil {
+ log.Printf("MiniprogramH5ResetPassword: update password_hash user=%s err=%v", user.ID, err)
+ h := gin.H{"success": false, "error": "更新失败"}
+ if gin.Mode() == gin.DebugMode {
+ h["detail"] = err.Error()
+ }
+ c.JSON(http.StatusInternalServerError, h)
+ return
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "若该手机号已注册,已更新登录密码,请使用新密码登录",
+ })
+}
+
// 辅助函数
func getStringValue(ptr *string) string {
if ptr == nil {
diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go
index eebbf99d..35a28c9f 100644
--- a/soul-api/internal/model/user.go
+++ b/soul-api/internal/model/user.go
@@ -15,6 +15,8 @@ type User struct {
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
+ // H5/开发登录:bcrypt 哈希;微信登录用户可为空,首次 H5 登录时写入
+ PasswordHash *string `gorm:"column:password_hash;size:128" json:"-"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
// P3 资料扩展(stitch_soul)
diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go
index d3f85c36..53d07b60 100644
--- a/soul-api/internal/router/router.go
+++ b/soul-api/internal/router/router.go
@@ -357,7 +357,9 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 兼容旧路径:手机号登录
- miniprogram.POST("/h5/login-by-phone", handler.MiniprogramDevLoginByPhone) // H5 手机号登录(与 dev 同 handler)
+ miniprogram.POST("/h5/login-by-phone", handler.MiniprogramDevLoginByPhone) // H5 手机号登录
+ miniprogram.POST("/h5/register-by-phone", handler.MiniprogramH5RegisterByPhone)
+ miniprogram.POST("/h5/reset-password", handler.MiniprogramH5ResetPassword) // H5 忘记密码(同登录开关)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay", handler.MiniprogramPay)