Update mini program development documentation and enhance user interface elements

- Added a new entry for the latest mini program development rules and APIs in the evolution index.
- Updated the skill documentation to include guidelines on privacy authorization and capability detection.
- Modified the read and settings pages to improve user experience with new input styles and layout adjustments.
- Implemented user-select functionality for text elements in the read page to enhance interactivity.
- Refined CSS styles for better responsiveness and visual consistency across various components.
This commit is contained in:
Alex-larget
2026-03-14 16:23:01 +08:00
parent 8778a42429
commit c936371165
27 changed files with 510 additions and 68 deletions

View File

@@ -0,0 +1,90 @@
# 联网吸收:微信小程序最新开发规则与 API2025-03
> 来源微信开放文档、基础库更新日志、Skyline 文档、隐私合规指南等
---
## 一、基础库版本与更新节奏
- **当前最新**v3.14.22026-01-22v3.14.3 灰度中
- **建议**:在 `app.json` 中设置 `"useExtendedLib": { "weui": true }` 或指定 `libVersion`,关注灰度版本说明
- **兼容**:使用 `wx.canIUse('api.xxx')` 做能力检测,避免在低版本报错
---
## 二、新增 / 重要 API基础库 3.14 系列)
| API | 用途 | 基础库 |
|-----|------|--------|
| `wx.rewriteRoute` | 路由重写 | 3.14+ |
| `wx.openOfficialAccountProfile` | 打开公众号 | 3.14+ |
| `wx.openOfficialAccountChat` | 跳转公众号会话 | 3.14+ |
| `wx.openInquiriesTopic` | 跳转问一问话题 | 3.14.0 |
| `wx.loadBuiltInFontFace` | 加载微信内置字体 | 3.14+ |
| `wx.onUserOffTranslation` | 监听用户关闭翻译 | 3.14.3 灰度 |
**其他能力**
- 鼠标右键点击事件支持PC 端)
- 图片分享朋友圈
- 半屏小程序 `openEmbeddedMiniProgram` 上限提升到 100
- TCPSocket 支持 `TCP_NODELAY`
---
## 三、Skyline 渲染引擎(可选升级)
- **定位**:新一代渲染引擎,以性能为首要目标,仍用 WXML/WXSS
- **配置**:页面级 `page.json``"renderer": "skyline"`
- **性能**:启动耗时降约 20%,跳页耗时降约 50%;长列表 `scroll-view` 仅渲染屏内节点
- **注意**CSS 特性精简,只保留更现代的集合;鸿蒙 OS 已灰度支持
- **Soul 项目**:当前为 WebView 渲染,若需性能优化可逐步按页面接入 Skyline
---
## 四、隐私合规2025 重要变更)
### 4.1 核心变化:从集中授权改为按需授权
- **旧**:首次启动一次性请求所有权限
- **新**:必须在用户**实际触发相关功能时**才发起对应授权请求
- **影响**:需拆分授权逻辑到具体业务场景,不能集中在 `app.onLaunch`
### 4.2 必须完成的步骤
1. **后台配置**:在小程序管理后台填写《小程序用户隐私保护指引》,声明处理的用户信息类型及用途
2. **查询与展示**`wx.getPrivacySetting` 查询授权状态,`wx.openPrivacyContract` 打开隐私协议
3. **获取同意**:使用 `<button open-type="agreePrivacyAuthorization">` 获取用户明示同意,用户点击后微信同步状态,开发者才可调用已声明的隐私接口
### 4.3 敏感权限
- 通讯录、位置、摄像头、麦克风、相册等**不会默认开启**
- 需用户明确同意后才可调用
- 需为「用户拒绝」设计降级方案
---
## 五、网络请求wx.request
- **官方**`wx.request` 仍为主流,仅支持回调,不支持原生 Promise
- **Soul 项目**:已用 `app.request` 封装,支持 Promise、统一 baseUrl、鉴权、错误处理符合规范
- **第三方**:若需更丰富能力(拦截器、重试等),可考虑 wechat-http、mini-quest 等
---
## 六、与 Soul 项目 SKILL 的衔接
| 联网吸收内容 | Soul miniprogram-dev SKILL 对应 |
|-------------|--------------------------------|
| 隐私按需授权 | 登录、手机号、推荐码等涉及隐私的接口,应在用户触发时再请求,避免启动时集中授权 |
| Skyline 可选 | 当前 SKILL 未强制 Skyline性能敏感页面可单独配置 `renderer: skyline` |
| 新 API | 路由重写、公众号跳转等按需使用,调用前用 `wx.canIUse` 检测 |
| 基础库版本 | 建议在项目文档中注明最低支持版本,便于兼容性排查 |
---
## 七、建议动作
1. **隐私合规**:检查 `app.js` 及登录/手机号/推荐码流程,确保按需授权,不在启动时集中请求
2. **文档**:在 README 或开发文档中注明基础库最低版本(如 2.19.0 或 3.0.0
3. **能力检测**:新增依赖新 API 的功能时,使用 `wx.canIUse` 做降级
4. **Skyline**:阅读页、章节列表等长列表页面可评估 Skyline 接入收益

View File

@@ -2,6 +2,7 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2025-03-14 | 联网吸收:基础库 3.14、Skyline、隐私按需授权、新 API | [2025-03-14-联网吸收小程序最新开发规则与API.md](./2025-03-14-联网吸收小程序最新开发规则与API.md) |
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
| 2026-03-03 | 我的页面卡片区边距优化16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
| 2026-03-05 | 分支合并后核心流程自测app.json 拆行orders 接口确认 | [2026-03-05.md](./2026-03-05.md) |

View File

@@ -76,11 +76,20 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
---
## 8. 何时使用本 Skill
## 8. 平台合规与能力检测2025 起)
- **隐私按需授权**:涉及用户信息的接口(登录、手机号、位置等)必须在用户**实际触发功能时**再请求授权,禁止在 `app.onLaunch` 中集中请求。需配置《小程序用户隐私保护指引》,使用 `<button open-type="agreePrivacyAuthorization">` 获取同意。
- **能力检测**:使用新 API 前用 `wx.canIUse('api.xxx')` 检测,低版本做降级。
- **Skyline可选**:性能敏感页面可在 `page.json` 中配置 `"renderer": "skyline"`,仍使用 WXML/WXSS。
---
## 9. 何时使用本 Skill
-**miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
- 做登录、手机号、推荐码等涉及用户信息的授权时(遵循 §8 隐私按需授权)。
- 做表单、input/textarea 样式时(遵循 §6用 view 包裹padding 写在 view 上)。
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx

View File

@@ -76,7 +76,6 @@
<text class="section-lock {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
<text class="section-title {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
<text wx:if="{{section.isNew}}" class="tag tag-new">NEW</text>
<text wx:if="{{section.isPremium}}" class="tag tag-vip">增值</text>
</view>
<view class="section-right">
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>

View File

@@ -30,7 +30,7 @@
<text class="chapter-id">{{section.id}}</text>
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
</view>
<text class="chapter-title">{{section.title}}</text>
<text class="chapter-title" user-select>{{section.title}}</text>
</view>
<!-- 加载状态 -->
@@ -46,9 +46,9 @@
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<text wx:if="{{seg.type === 'text'}}" user-select>{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" user-select bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" user-select bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
@@ -102,7 +102,7 @@
<!-- 预览内容 + 付费墙 - 未登录 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_login'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->
@@ -153,7 +153,7 @@
<!-- 预览内容 + 付费墙 - 已登录未购买 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_purchased'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->
@@ -223,7 +223,7 @@
<!-- 错误状态 - 网络异常 -->
<view class="article preview" wx:if="{{accessState === 'error'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->

View File

@@ -348,18 +348,20 @@
display: flex;
gap: 24rpx;
margin-bottom: 48rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex: 1;
flex: 1 1 0;
min-width: 0;
padding: 24rpx;
border-radius: 24rpx;
max-width: 48%;
}
.nav-btn-placeholder {
flex: 1;
max-width: 48%;
flex: 1 1 0;
min-width: 0;
}
.nav-prev {
@@ -400,12 +402,16 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.btn-row {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
overflow: hidden;
}
.btn-arrow {

View File

@@ -130,10 +130,10 @@
</view>
<view class="modal-body">
<view class="input-wrapper">
<view class="form-input-wrap">
<input
class="form-input-inner"
type="{{bindType === 'phone' ? 'number' : 'text'}}"
class="form-input"
placeholder="{{bindType === 'phone' ? '请输入11位手机号' : bindType === 'wechat' ? '请输入微信号' : '请输入支付宝账号'}}"
placeholder-class="input-placeholder"
value="{{bindValue}}"
@@ -161,9 +161,9 @@
<view class="modal-close" bindtap="closeSwitchAccountModal">✕</view>
</view>
<view class="modal-body">
<view class="input-wrapper">
<view class="form-input-wrap">
<input
class="form-input"
class="form-input-inner"
placeholder="请输入目标用户的 userId如 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
placeholder-class="input-placeholder"
value="{{switchAccountUserId}}"

View File

@@ -112,9 +112,9 @@
.modal-title { font-size: 36rpx; font-weight: 700; color: #fff; }
.modal-close { width: 64rpx; height: 64rpx; background: rgba(255,255,255,0.08); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32rpx; color: rgba(255,255,255,0.5); }
.modal-body { padding: 16rpx 40rpx 48rpx; }
.input-wrapper { margin-bottom: 32rpx; }
.form-input { width: 100%; padding: 32rpx 24rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; font-size: 32rpx; color: #fff; box-sizing: border-box; transition: all 0.2s; }
.form-input:focus { border-color: rgba(0,206,209,0.5); background: rgba(0,206,209,0.05); }
/* 弹窗 input外边包 viewpadding 写在 view 上,避免光标截断 */
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; margin-bottom: 32rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }
.input-placeholder { color: rgba(255,255,255,0.25); }
.bind-tip { font-size: 24rpx; color: rgba(255,255,255,0.4); margin-bottom: 40rpx; display: block; line-height: 1.6; text-align: center; }
.btn-primary { padding: 32rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #000; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 28rpx; }

View File

@@ -59,7 +59,17 @@ export async function request<T = unknown>(
const json: T = contentType.includes('application/json')
? ((await res.json()) as T)
: (res as unknown as T)
const maybeTriggerRechargeAlert = (data: unknown) => {
const obj = data as { message?: string; error?: string }
const msg = (obj?.message || obj?.error || '').toString()
if (msg.includes('可提现金额不足') || msg.includes('可提现不足') || msg.includes('余额不足')) {
window.dispatchEvent(new CustomEvent('recharge-alert', { detail: msg }))
}
}
if (!res.ok) {
maybeTriggerRechargeAlert(json)
const err = new Error((json as { error?: string })?.error || `HTTP ${res.status}`) as Error & {
status: number
data: T
@@ -68,6 +78,7 @@ export async function request<T = unknown>(
err.data = json
throw err
}
maybeTriggerRechargeAlert(json)
return json
}

View File

@@ -0,0 +1,48 @@
/**
* 可关闭的充值告警条
* 当接口返回「可提现金额不足」「可提现不足」「余额不足」等错误时,由 client.ts 触发 recharge-alert 事件,
* 本组件监听并展示告警,提示管理员充值商户号或核对账户。
*/
import { useState, useEffect } from 'react'
import { AlertCircle, X } from 'lucide-react'
export function RechargeAlert() {
const [visible, setVisible] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<string>).detail
setMessage(detail || '可提现/余额不足,请及时充值商户号')
setVisible(true)
}
window.addEventListener('recharge-alert', handler)
return () => window.removeEventListener('recharge-alert', handler)
}, [])
if (!visible) return null
return (
<div
className="flex items-center justify-between gap-4 px-4 py-3 bg-red-900/80 border-b border-red-600/50 text-red-100"
role="alert"
>
<div className="flex items-center gap-3 min-w-0">
<AlertCircle className="w-5 h-5 shrink-0 text-red-400" />
<span className="text-sm font-medium">
{message}
<span className="ml-2 text-red-300"></span>
</span>
</div>
<button
type="button"
onClick={() => setVisible(false)}
className="shrink-0 p-1 rounded hover:bg-red-800/50 transition-colors"
aria-label="关闭告警"
>
<X className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -1,7 +1,6 @@
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
@@ -249,9 +248,10 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({
link: { openOnClick: false, HTMLAttributes: { class: 'rich-link' } },
}),
Image.configure({ inline: true, allowBase64: true }),
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),

View File

@@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
import { RechargeAlert } from '@/components/RechargeAlert'
// 主菜单5 项平铺,按 Mycontent-temp 新规范)
const primaryMenuItems = [
@@ -126,8 +127,9 @@ export function AdminLayout() {
</div>
</div>
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0">
<div className="w-full min-w-[1024px] min-h-full">
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0 flex flex-col">
<RechargeAlert />
<div className="w-full min-w-[1024px] min-h-full flex-1">
<Outlet />
</div>
</div>

View File

@@ -494,7 +494,9 @@ export function ContentPage() {
})
if (res && (res as { success?: boolean }).success !== false) {
toast.success('排名权重已保存')
setShowRankingAlgorithmModal(false)
loadList()
loadRanking()
} else {
toast.error('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
}
@@ -1170,9 +1172,9 @@ export function ContentPage() {
</div>
</div>
{/* 新建章节弹窗:与编辑章节样式功能一致 */}
{/* 新建章节弹窗:全屏 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white inset-0 translate-x-0 translate-y-0 w-screen h-screen max-w-none max-h-none rounded-none flex flex-col p-0 gap-0" showCloseButton>
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
<DialogTitle className="text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-[#38bdac]" />
@@ -1772,9 +1774,9 @@ export function ContentPage() {
</DialogContent>
</Dialog>
{/* 章节编辑弹窗:内容区可滚动,底部保存按钮始终在一页内可见 */}
{/* 章节编辑弹窗:全屏,内容区可滚动,底部保存按钮始终可见 */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white inset-0 translate-x-0 translate-y-0 w-screen h-screen max-w-none max-h-none rounded-none flex flex-col p-0 gap-0" showCloseButton>
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />

View File

@@ -38,6 +38,12 @@ func main() {
Handler: r,
}
// 预热 all-chapters 缓存,避免首请求冷启动 502
go func() {
time.Sleep(2 * time.Second) // 等 DB 完全就绪
handler.WarmAllChaptersCache()
}()
go func() {
log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)

View File

@@ -7,6 +7,7 @@ import (
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -179,6 +180,26 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
return out
}
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error
func AdminDashboardMerchantBalance(c *gin.Context) {
bal, err := wechat.QueryMerchantBalance("BASIC")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": err.Error(),
"message": "查询商户余额失败,可能未开通权限(请联系微信支付运营申请)",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"availableAmount": bal.AvailableAmount, // 单位:分
"pendingAmount": bal.PendingAmount, // 单位:分
})
}
func buildNewUsersOut(newUsers []model.User) []gin.H {
out := make([]gin.H, 0, len(newUsers))
for _, u := range newUsers {

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -18,14 +19,69 @@ import (
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
var excludeParts = []string{"序言", "尾声", "附录"}
// allChaptersSelectCols 列表不加载 contentlongtext避免 502 超时
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",
}
// allChaptersCache 内存缓存,减轻 DB 压力30 秒 TTL
var allChaptersCache struct {
mu sync.RWMutex
data []model.Chapter
expires time.Time
key string // excludeFixed 不同则 key 不同
}
const allChaptersCacheTTL = 30 * time.Second
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
func WarmAllChaptersCache() {
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
var list []model.Chapter
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
return
}
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
t := true
z := float64(0)
list[i].IsFree = &t
list[i].Price = &z
}
}
allChaptersCache.mu.Lock()
allChaptersCache.data = list
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
allChaptersCache.key = "default"
allChaptersCache.mu.Unlock()
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
func BookAllChapters(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Chapter{})
cacheKey := "default"
if c.Query("excludeFixed") == "1" {
cacheKey = "excludeFixed"
}
allChaptersCache.mu.RLock()
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
data := allChaptersCache.data
allChaptersCache.mu.RUnlock()
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
return
}
allChaptersCache.mu.RUnlock()
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
if cacheKey == "excludeFixed" {
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
@@ -44,6 +100,13 @@ func BookAllChapters(c *gin.Context) {
list[i].Price = &z
}
}
allChaptersCache.mu.Lock()
allChaptersCache.data = list
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
allChaptersCache.key = cacheKey
allChaptersCache.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
@@ -416,10 +479,27 @@ func BookRecommended(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// BookLatestChapters GET /api/book/latest-chapters
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
func BookLatestChapters(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
var list []model.Chapter
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
t := true
z := float64(0)
list[i].IsFree = &t
list[i].Price = &z
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}

View File

@@ -193,6 +193,37 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
return result.Data.APIKey, nil
}
// ckbOpenDeletePlan 调用 DELETE /v1/plan/delete 删除存客宝获客计划
func ckbOpenDeletePlan(token string, planID int64) error {
payload := map[string]interface{}{"planId": planID}
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodDelete, ckbOpenBaseURL+"/v1/plan/delete", bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("构造删除计划请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("请求存客宝删除计划失败: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(b, &result)
if result.Code != 200 {
if result.Message == "" {
result.Message = "删除计划失败"
}
return fmt.Errorf(result.Message)
}
return nil
}
// AdminCKBDevices GET /api/admin/ckb/devices 管理端-存客宝设备列表(供链接人与事选择设备)
// 通过开放 API 获取 JWT再调用 /v1/devices返回精简后的设备列表。
func AdminCKBDevices(c *gin.Context) {

View File

@@ -3,7 +3,6 @@ package handler
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"time"
@@ -87,7 +86,9 @@ func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
return sections, nil
}
// computeSectionsWithHotScore 内部:计算 hotScore,可选设置 isPinned
// computeSectionsWithHotScore 内部:按排名分算法计算 hotScore
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
// 阅读量前20名: 第1名=20分...第20名=1分最近更新前30篇: 第1名=30分...第30名=1分付款数前20名: 第1名=20分...第20名=1分
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
@@ -123,7 +124,7 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
payCountMap[r.ProductID] = r.Cnt
}
}
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
readWeight, payWeight, recencyWeight := 0.1, 0.4, 0.5 // 默认与截图一致
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
var v struct {
@@ -132,13 +133,13 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
PayWeight float64 `json:"payWeight"`
}
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
if v.ReadWeight > 0 {
if v.ReadWeight >= 0 {
readWeight = v.ReadWeight
}
if v.PayWeight > 0 {
if v.PayWeight >= 0 {
payWeight = v.PayWeight
}
if v.RecencyWeight > 0 {
if v.RecencyWeight >= 0 {
recencyWeight = v.RecencyWeight
}
}
@@ -156,7 +157,52 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
pinnedSet[id] = true
}
}
now := time.Now()
// 1. 阅读量排名:按 readCount 降序前20名得 20~1 分
type idCnt struct {
id string
cnt int64
}
readRank := make([]idCnt, 0, len(rows))
for _, r := range rows {
readRank = append(readRank, idCnt{r.ID, readCountMap[r.ID]})
}
sort.Slice(readRank, func(i, j int) bool { return readRank[i].cnt > readRank[j].cnt })
readRankScoreMap := make(map[string]float64)
for i := 0; i < len(readRank) && i < 20; i++ {
readRankScoreMap[readRank[i].id] = float64(20 - i)
}
// 2. 新度排名:按 updated_at 降序最近更新在前前30篇得 30~1 分
recencyRank := make([]struct {
id string
updatedAt time.Time
}, 0, len(rows))
for _, r := range rows {
recencyRank = append(recencyRank, struct {
id string
updatedAt time.Time
}{r.ID, r.UpdatedAt})
}
sort.Slice(recencyRank, func(i, j int) bool {
return recencyRank[i].updatedAt.After(recencyRank[j].updatedAt)
})
recencyRankScoreMap := make(map[string]float64)
for i := 0; i < len(recencyRank) && i < 30; i++ {
recencyRankScoreMap[recencyRank[i].id] = float64(30 - i)
}
// 3. 付款数排名:按 payCount 降序前20名得 20~1 分
payRank := make([]idCnt, 0, len(rows))
for _, r := range rows {
payRank = append(payRank, idCnt{r.ID, payCountMap[r.ID]})
}
sort.Slice(payRank, func(i, j int) bool { return payRank[i].cnt > payRank[j].cnt })
payRankScoreMap := make(map[string]float64)
for i := 0; i < len(payRank) && i < 20; i++ {
payRankScoreMap[payRank[i].id] = float64(20 - i)
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
@@ -165,15 +211,15 @@ func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem
}
readCnt := readCountMap[r.ID]
payCnt := payCountMap[r.ID]
recencyScore := 0.0
if !r.UpdatedAt.IsZero() {
days := now.Sub(r.UpdatedAt).Hours() / 24
recencyScore = math.Max(0, (30-days)/30)
if recencyScore > 1 {
recencyScore = 1
}
readRankScore := readRankScoreMap[r.ID]
recencyRankScore := recencyRankScoreMap[r.ID]
payRankScore := payRankScoreMap[r.ID]
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
hot := readWeight*readRankScore + recencyWeight*recencyRankScore + payWeight*payRankScore
// 若章节有手动覆盖的 hot_score>0则优先使用
if r.HotScore > 0 {
hot = float64(r.HotScore)
}
hot := float64(readCnt)*readWeight + float64(payCnt)*payWeight + recencyScore*recencyWeight
item := sectionListItem{
ID: r.ID,
MID: r.MID,

View File

@@ -138,16 +138,16 @@ func DBPersonSave(c *gin.Context) {
}
}
planPayload := map[string]interface{}{
"name": name,
"sceneId": 11,
"scenario": 11,
"remarkType": body.RemarkType,
"greeting": body.Greeting,
"addInterval": addInterval,
"startTime": startTime,
"endTime": endTime,
"enabled": true,
"tips": body.Tips,
"name": name,
"sceneId": 11,
"scenario": 11,
"remarkType": body.RemarkType,
"greeting": body.Greeting,
"addInterval": addInterval,
"startTime": startTime,
"endTime": endTime,
"enabled": true,
"tips": body.Tips,
"distributionEnabled": false,
}
if len(deviceIDs) > 0 {
@@ -172,16 +172,16 @@ func DBPersonSave(c *gin.Context) {
}
newPerson := model.Person{
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,
Greeting: body.Greeting,
Tips: body.Tips,
RemarkType: body.RemarkType,
RemarkFormat: body.RemarkFormat,
PersonID: body.PersonID,
Token: tok,
Name: body.Name,
Label: body.Label,
CkbApiKey: apiKey,
CkbPlanID: planID,
Greeting: body.Greeting,
Tips: body.Tips,
RemarkType: body.RemarkType,
RemarkFormat: body.RemarkFormat,
AddFriendInterval: addInterval,
StartTime: startTime,
EndTime: endTime,
@@ -221,12 +221,30 @@ func genPersonToken() (string, error) {
}
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
// 若有 ckb_plan_id先调存客宝删除计划再删本地
func DBPersonDelete(c *gin.Context) {
pid := c.Query("personId")
if pid == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
return
}
var row model.Person
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
// 若有存客宝计划,先调 CKB 删除
if row.CkbPlanID > 0 {
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取存客宝鉴权失败: " + err.Error()})
return
}
if err := ckbOpenDeletePlan(token, row.CkbPlanID); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除存客宝计划失败: " + err.Error()})
return
}
}
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -52,6 +52,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/dashboard/recent-orders", handler.AdminDashboardRecentOrders)
admin.GET("/dashboard/new-users", handler.AdminDashboardNewUsers)
admin.GET("/dashboard/overview", handler.AdminDashboardOverview)
admin.GET("/dashboard/merchant-balance", handler.AdminDashboardMerchantBalance)
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
admin.GET("/payment", handler.AdminPayment)
admin.POST("/payment", handler.AdminPayment)

View File

@@ -0,0 +1,56 @@
package wechat
import (
"encoding/json"
"fmt"
"soul-api/internal/config"
"soul-api/internal/wechat/transferv3"
)
// MerchantBalance 商户余额(微信返回,单位:分)
type MerchantBalance struct {
AvailableAmount int64 `json:"available_amount"` // 可用余额(分)
PendingAmount int64 `json:"pending_amount"` // 不可用/待结算余额(分)
}
// QueryMerchantBalance 查询商户平台账户实时余额API: GET /v3/merchant/fund/balance/{account_type}
// accountType: BASIC(基本户) | OPERATION(运营账户) | FEES(手续费账户),默认 BASIC
// 注意普通商户可能需向微信申请开通权限403 NO_AUTH 时表示未开通
func QueryMerchantBalance(accountType string) (*MerchantBalance, error) {
if accountType == "" {
accountType = "BASIC"
}
cfg := config.Get()
if cfg == nil {
return nil, fmt.Errorf("配置未加载")
}
key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath)
if err != nil {
return nil, fmt.Errorf("加载商户私钥失败: %w", err)
}
client := transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key)
data, status, err := client.GetMerchantBalance(accountType)
if err != nil {
return nil, fmt.Errorf("请求微信接口失败: %w", err)
}
if status != 200 {
// 403 NO_AUTH 时返回友好提示,便于管理端展示
if status == 403 {
var errResp struct {
Code string `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(data, &errResp)
if errResp.Code == "NO_AUTH" {
return nil, fmt.Errorf("NO_AUTH: 当前商户号未开通余额查询权限,请登录微信商户平台联系客服申请")
}
}
return nil, fmt.Errorf("微信返回 %d: %s", status, string(data))
}
var bal MerchantBalance
if err := json.Unmarshal(data, &bal); err != nil {
return nil, fmt.Errorf("解析余额响应失败: %w", err)
}
return &bal, nil
}

View File

@@ -118,3 +118,11 @@ func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int,
"/details/detail-id/" + url.PathEscape(outDetailNo)
return c.do("GET", path, "")
}
// GetMerchantBalance 查询商户平台账户实时余额文档GET /v3/merchant/fund/balance/{account_type}
// accountType: BASIC(基本户) | OPERATION(运营账户) | FEES(手续费账户)
// 注意普通商户可能需向微信申请开通权限403 NO_AUTH 表示未开通
func (c *Client) GetMerchantBalance(accountType string) ([]byte, int, error) {
path := "/v3/merchant/fund/balance/" + url.PathEscape(accountType)
return c.do("GET", path, "")
}

View File

@@ -0,0 +1,5 @@
-- 为 all-chapters 接口加速sort_order + id 排序索引
-- 执行node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-chapters-index-for-all-chapters.sql
-- 若索引已存在会报错,可忽略
CREATE INDEX idx_chapters_sort_id ON chapters(sort_order, id);

View File

@@ -0,0 +1,2 @@
-- 仅添加 ckb_plan_id若 add-persons-ckb-fields.sql 已部分执行或需单独补列)
ALTER TABLE `persons` ADD COLUMN `ckb_plan_id` BIGINT NOT NULL DEFAULT 0 COMMENT '存客宝获客计划ID';

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB