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:
@@ -0,0 +1,90 @@
|
||||
# 联网吸收:微信小程序最新开发规则与 API(2025-03)
|
||||
|
||||
> 来源:微信开放文档、基础库更新日志、Skyline 文档、隐私合规指南等
|
||||
|
||||
---
|
||||
|
||||
## 一、基础库版本与更新节奏
|
||||
|
||||
- **当前最新**:v3.14.2(2026-01-22),v3.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 接入收益
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 渐变遮罩 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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:外边包 view,padding 写在 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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
48
soul-admin/src/components/RechargeAlert.tsx
Normal file
48
soul-admin/src/components/RechargeAlert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -18,14 +19,69 @@ import (
|
||||
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// allChaptersSelectCols 列表不加载 content(longtext),避免 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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
56
soul-api/internal/wechat/balance.go
Normal file
56
soul-api/internal/wechat/balance.go
Normal 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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
5
soul-api/scripts/add-chapters-index-for-all-chapters.sql
Normal file
5
soul-api/scripts/add-chapters-index-for-all-chapters.sql
Normal 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);
|
||||
2
soul-api/scripts/add-ckb-plan-id-only.sql
Normal file
2
soul-api/scripts/add-ckb-plan-id-only.sql
Normal 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';
|
||||
BIN
soul-api/uploads/book-images/1773473071239662000_bnoyis.jpg
Normal file
BIN
soul-api/uploads/book-images/1773473071239662000_bnoyis.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
soul-api/uploads/book-images/1773473085207640600_dvem5h.png
Normal file
BIN
soul-api/uploads/book-images/1773473085207640600_dvem5h.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
soul-api/uploads/book-images/1773475611724136200_tbh6ws.png
Normal file
BIN
soul-api/uploads/book-images/1773475611724136200_tbh6ws.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user