Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing.

This commit is contained in:
Alex-larget
2026-03-12 11:36:50 +08:00
parent da6d2c0852
commit d3b67681d7
27 changed files with 1464 additions and 393 deletions

View File

@@ -18,6 +18,9 @@ SKIP_AUTO_MIGRATE=1
# 统一 API 域名(测试环境)
API_BASE_URL=https://souldev.quwanzhi.com
#添加卡若
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

View File

@@ -14,6 +14,9 @@ DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/sou
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
#添加卡若
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c

View File

@@ -382,9 +382,42 @@ func BookHot(c *gin.Context) {
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
func BookRecommended(c *gin.Context) {
// 优先复用管理端内容排行榜的算法,保证与后台「内容排行榜」顺序一致
sections, err := computeArticleRankingSections(database.DB())
if err == nil && len(sections) > 0 {
// 前 3 名作为精选推荐
limit := 3
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"tag": tag,
"isFree": s.IsFree,
"price": s.Price,
"isNew": s.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
// 兜底:沿用原有热门章节算法,至少保证有推荐
list := bookHotChaptersSorted(database.DB(), 3)
if len(list) == 0 {
// 兜底:按 updated_at 取前 3同样排除序言/尾声/附录
// 第二层兜底:按 updated_at 取前 3同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")

View File

@@ -290,6 +290,134 @@ func CKBSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
// - 固定使用全局 CKB_LEAD_API_KEY不受文章 @ 人物的 ckb_api_key 影响
// - 请求体userId可选用于补全昵称、phone/wechatId至少一个、name可选
func CKBIndexLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
wechatId := strings.TrimSpace(body.WechatID)
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
if phone == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
return
}
name := strings.TrimSpace(body.Name)
db := database.DB()
if name == "" && body.UserID != "" {
var u model.User
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
// 首页固定使用全局密钥CKB_LEAD_API_KEY.env或代码内置 ckbAPIKey
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
// 去重限频2 分钟内同一用户/手机/微信只能提交一次
var cond []string
var args []interface{}
if body.UserID != "" {
cond = append(cond, "user_id = ?")
args = append(args, body.UserID)
}
cond = append(cond, "phone = ?")
args = append(args, phone)
cutoff := time.Now().Add(-2 * time.Minute)
var recentCount int64
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁请2分钟后再试"})
return
}
repeatedSubmit := false
var existCount int64
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
source := "index_link_button"
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
params["phone"] = phone
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
q.Set("phone", phone)
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
msg := "提交成功,卡若会尽快联系您"
if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
}
data := gin.H{}
if result.Data != nil {
if m, ok := result.Data.(map[string]interface{}); ok {
data = m
}
}
data["repeatedSubmit"] = repeatedSubmit
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
// 存客宝返回失败,透传其错误信息与 code便于前端/运营判断原因
errMsg := strings.TrimSpace(result.Message)
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": result.Message,
})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserId被@的 personId、targetNickname、source
func CKBLead(c *gin.Context) {

View File

@@ -2,7 +2,11 @@ package handler
import (
"context"
"encoding/json"
"math"
"net/http"
"sort"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -13,13 +17,15 @@ import (
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "section_title", "price", "is_free", "is_new",
"id", "mid", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
"hot_score", "updated_at",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct {
ID string `json:"id"`
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
@@ -29,6 +35,166 @@ type sectionListItem struct {
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
FilePath *string `json:"filePath,omitempty"`
ClickCount int64 `json:"clickCount"` // 阅读次数reading_progress
PayCount int64 `json:"payCount"` // 付款笔数orders.product_type=section
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
}
// computeSectionListWithHotScore 计算章节列表(含 hotScore保持 sort_order 顺序,供 章节管理 树使用
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, false)
if err != nil {
return nil, err
}
return sections, nil
}
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
sections, err := computeSectionsWithHotScore(db, true)
if err != nil {
return nil, err
}
// 读取置顶配置 pinned_section_ids
pinnedIDs := []string{}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
}
pinnedSet := make(map[string]int) // id -> 置顶顺序
for i, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = i
}
}
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
sort.Slice(sections, func(i, j int) bool {
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
if piOk && !pjOk {
return true
}
if !piOk && pjOk {
return false
}
if piOk && pjOk {
return pi < pj
}
return sections[i].HotScore > sections[j].HotScore
})
return sections, nil
}
// computeSectionsWithHotScore 内部:计算 hotScore可选设置 isPinned
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 {
return nil, err
}
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
readCountMap := make(map[string]int64)
if len(ids) > 0 {
var rp []struct {
SectionID string `gorm:"column:section_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
Where("section_id IN ?", ids).Group("section_id").Scan(&rp)
for _, r := range rp {
readCountMap[r.SectionID] = r.Cnt
}
}
payCountMap := make(map[string]int64)
if len(ids) > 0 {
var op []struct {
ProductID string `gorm:"column:product_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Model(&model.Order{}).
Select("product_id, COUNT(*) as cnt").
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
Group("product_id").Scan(&op)
for _, r := range op {
payCountMap[r.ProductID] = r.Cnt
}
}
readWeight, payWeight, recencyWeight := 0.5, 0.3, 0.2
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 {
ReadWeight float64 `json:"readWeight"`
RecencyWeight float64 `json:"recencyWeight"`
PayWeight float64 `json:"payWeight"`
}
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
if v.ReadWeight > 0 {
readWeight = v.ReadWeight
}
if v.PayWeight > 0 {
payWeight = v.PayWeight
}
if v.RecencyWeight > 0 {
recencyWeight = v.RecencyWeight
}
}
}
pinnedIDs := []string{}
if setPinned {
var cfg2 model.SystemConfig
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
}
}
pinnedSet := make(map[string]bool)
for _, id := range pinnedIDs {
if id != "" {
pinnedSet[id] = true
}
}
now := time.Now()
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
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
}
}
hot := float64(readCnt)*readWeight + float64(payCnt)*payWeight + recencyScore*recencyWeight
item := sectionListItem{
ID: r.ID,
MID: r.MID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
ClickCount: readCnt,
PayCount: payCnt,
HotScore: hot,
}
if setPinned {
item.IsPinned = pinnedSet[r.ID]
}
sections = append(sections, item)
}
return sections, nil
}
// DBBookAction GET/POST/PUT /api/db/book
@@ -40,28 +206,20 @@ func DBBookAction(c *gin.Context) {
id := c.Query("id")
switch action {
case "list":
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
// 章节管理树:按 sort_order 顺序,含 hotScore
sections, err := computeSectionListWithHotScore(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
sections := make([]sectionListItem, 0, len(rows))
for _, r := range rows {
price := 1.0
if r.Price != nil {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID,
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
ChapterTitle: r.ChapterTitle,
})
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "ranking":
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
sections, err := computeArticleRankingSections(db)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return

View File

@@ -312,6 +312,10 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if u.VipRole != nil {
vipRole = *u.VipRole
}
vipSort := 0
if u.VipSort != nil {
vipSort = *u.VipSort
}
return gin.H{
"id": u.ID,
"name": name,
@@ -343,12 +347,14 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
"story_achievement": getStringValue(u.StoryAchievement),
"storyTurning": getStringValue(u.StoryTurning),
"story_turning": getStringValue(u.StoryTurning),
"helpOffer": getStringValue(u.HelpOffer),
"helpOffer": getStringValue(u.HelpOffer),
"help_offer": getStringValue(u.HelpOffer),
"helpNeed": getStringValue(u.HelpNeed),
"help_need": getStringValue(u.HelpNeed),
"projectIntro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"vipSort": vipSort,
"vip_sort": vipSort,
"is_vip": isVip,
}
}

View File

@@ -0,0 +1,54 @@
package handler
import (
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
func DBVipMembersList(c *gin.Context) {
limit := 200
if l := c.Query("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
limit = n
}
}
db := database.DB()
// 与 VipMembers 一致:优先 users 表is_vip=1 且 vip_expire_date>NOW排序使用 vip_sort
var users []model.User
err := db.Table("users").
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
Limit(limit).
Find(&users).Error
if err != nil || len(users) == 0 {
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
var userIDs []string
db.Model(&model.Order{}).Select("DISTINCT user_id").
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
Pluck("user_id", &userIDs)
if len(userIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
return
}
db.Where("id IN ?", userIDs).Find(&users)
}
list := make([]gin.H, 0, len(users))
for i := range users {
list = append(list, formatVipMember(&users[i], true))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}

View File

@@ -149,6 +149,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
@@ -267,6 +268,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/ckb/join", handler.CKBJoin)
miniprogram.POST("/ckb/match", handler.CKBMatch)
miniprogram.POST("/ckb/lead", handler.CKBLead)
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
miniprogram.POST("/upload", handler.UploadPost)
miniprogram.DELETE("/upload", handler.UploadDelete)
miniprogram.GET("/user/addresses", handler.UserAddressesGet)