diff --git a/.cursor/agent/小程序开发工程师/evolution/2025-03-14-联网吸收小程序最新开发规则与API.md b/.cursor/agent/小程序开发工程师/evolution/2025-03-14-联网吸收小程序最新开发规则与API.md new file mode 100644 index 00000000..3067584a --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2025-03-14-联网吸收小程序最新开发规则与API.md @@ -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. **获取同意**:使用 ` + + ) +} diff --git a/soul-admin/src/components/RichEditor.tsx b/soul-admin/src/components/RichEditor.tsx index ba566dcc..ab66c7a7 100644 --- a/soul-admin/src/components/RichEditor.tsx +++ b/soul-admin/src/components/RichEditor.tsx @@ -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(({ 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), diff --git a/soul-admin/src/layouts/AdminLayout.tsx b/soul-admin/src/layouts/AdminLayout.tsx index d9f76970..61b6473f 100644 --- a/soul-admin/src/layouts/AdminLayout.tsx +++ b/soul-admin/src/layouts/AdminLayout.tsx @@ -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() { -
-
+
+ +
diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index a4e47fb5..08d79456 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -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() {
- {/* 新建章节弹窗:与编辑章节样式功能一致 */} + {/* 新建章节弹窗:全屏 */} - + @@ -1772,9 +1774,9 @@ export function ContentPage() { - {/* 章节编辑弹窗:内容区可滚动,底部保存按钮始终在一页内可见 */} + {/* 章节编辑弹窗:全屏,内容区可滚动,底部保存按钮始终可见 */} setEditingSection(null)}> - + diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go index ea54c78b..98893025 100644 --- a/soul-api/cmd/server/main.go +++ b/soul-api/cmd/server/main.go @@ -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) diff --git a/soul-api/internal/handler/admin_dashboard.go b/soul-api/internal/handler/admin_dashboard.go index ed581ed0..e21071d5 100644 --- a/soul-api/internal/handler/admin_dashboard.go +++ b/soul-api/internal/handler/admin_dashboard.go @@ -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 { diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go index 3d95cda5..2f72e396 100644 --- a/soul-api/internal/handler/book.go +++ b/soul-api/internal/handler/book.go @@ -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}) } diff --git a/soul-api/internal/handler/ckb_open.go b/soul-api/internal/handler/ckb_open.go index 4dec47ea..f95af57b 100644 --- a/soul-api/internal/handler/ckb_open.go +++ b/soul-api/internal/handler/ckb_open.go @@ -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) { diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go index fb58df50..05eba941 100644 --- a/soul-api/internal/handler/db_book.go +++ b/soul-api/internal/handler/db_book.go @@ -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, diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 47f07efd..6f058f50 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -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 diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 3c5e386a..b0707f0e 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -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) diff --git a/soul-api/internal/wechat/balance.go b/soul-api/internal/wechat/balance.go new file mode 100644 index 00000000..b5ae437a --- /dev/null +++ b/soul-api/internal/wechat/balance.go @@ -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 +} diff --git a/soul-api/internal/wechat/transferv3/client.go b/soul-api/internal/wechat/transferv3/client.go index 471907fa..afc75708 100644 --- a/soul-api/internal/wechat/transferv3/client.go +++ b/soul-api/internal/wechat/transferv3/client.go @@ -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, "") +} diff --git a/soul-api/scripts/add-chapters-index-for-all-chapters.sql b/soul-api/scripts/add-chapters-index-for-all-chapters.sql new file mode 100644 index 00000000..b994d453 --- /dev/null +++ b/soul-api/scripts/add-chapters-index-for-all-chapters.sql @@ -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); diff --git a/soul-api/scripts/add-ckb-plan-id-only.sql b/soul-api/scripts/add-ckb-plan-id-only.sql new file mode 100644 index 00000000..357c8681 --- /dev/null +++ b/soul-api/scripts/add-ckb-plan-id-only.sql @@ -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'; diff --git a/soul-api/uploads/book-images/1773473071239662000_bnoyis.jpg b/soul-api/uploads/book-images/1773473071239662000_bnoyis.jpg new file mode 100644 index 00000000..7fda7d7a Binary files /dev/null and b/soul-api/uploads/book-images/1773473071239662000_bnoyis.jpg differ diff --git a/soul-api/uploads/book-images/1773473085207640600_dvem5h.png b/soul-api/uploads/book-images/1773473085207640600_dvem5h.png new file mode 100644 index 00000000..203a415d Binary files /dev/null and b/soul-api/uploads/book-images/1773473085207640600_dvem5h.png differ diff --git a/soul-api/uploads/book-images/1773475611724136200_tbh6ws.png b/soul-api/uploads/book-images/1773475611724136200_tbh6ws.png new file mode 100644 index 00000000..fabb67db Binary files /dev/null and b/soul-api/uploads/book-images/1773475611724136200_tbh6ws.png differ