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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user