feat: 运营-用户功能四大需求完整实现
1. 客资中心:Dashboard 聚合 CKB 线索+提交记录,联表用户信息 2. @置顶:Person 三端(后端+管理端+小程序)置顶功能,首页优先展示 3. 存客宝场景:一键检查并自动启用所有场景获客计划 4. 去重增强:后端聚合 dupCount,管理端展示重复标记和统计 5. 首页文案:"最新更新"→"推荐","开始阅读"→"点击阅读" Made-with: Cursor
This commit is contained in:
@@ -188,91 +188,6 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all
|
||||
// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count
|
||||
func AdminTrackStats(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "week")
|
||||
if period != "today" && period != "week" && period != "month" && period != "all" {
|
||||
period = "week"
|
||||
}
|
||||
now := time.Now()
|
||||
var start time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
case "week":
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
|
||||
case "month":
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
case "all":
|
||||
start = time.Time{}
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
q := db.Model(&model.UserTrack{})
|
||||
if !start.IsZero() {
|
||||
q = q.Where("created_at >= ?", start)
|
||||
}
|
||||
if err := q.Find(&tracks).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// byModule: module -> map[key] -> count, key = action + "|" + target
|
||||
type item struct {
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Module string `json:"module"`
|
||||
Page string `json:"page"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
byModule := make(map[string]map[string]*item)
|
||||
total := 0
|
||||
for _, t := range tracks {
|
||||
total++
|
||||
module := "other"
|
||||
page := ""
|
||||
if len(t.ExtraData) > 0 {
|
||||
var extra map[string]interface{}
|
||||
if err := json.Unmarshal(t.ExtraData, &extra); err == nil {
|
||||
if m, ok := extra["module"].(string); ok && m != "" {
|
||||
module = m
|
||||
}
|
||||
if p, ok := extra["page"].(string); ok {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
}
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
key := t.Action + "|" + target
|
||||
if byModule[module] == nil {
|
||||
byModule[module] = make(map[string]*item)
|
||||
}
|
||||
if byModule[module][key] == nil {
|
||||
byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0}
|
||||
}
|
||||
byModule[module][key].Count++
|
||||
}
|
||||
// 转为前端期望格式:byModule[module] = [{action,target,module,page,count},...]
|
||||
out := make(map[string][]gin.H)
|
||||
for mod, m := range byModule {
|
||||
list := make([]gin.H, 0, len(m))
|
||||
for _, v := range m {
|
||||
list = append(list, gin.H{
|
||||
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
|
||||
})
|
||||
}
|
||||
out[mod] = list
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out})
|
||||
}
|
||||
|
||||
// AdminBalanceSummary GET /api/admin/balance/summary
|
||||
// 汇总代付金额(product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」
|
||||
func AdminBalanceSummary(c *gin.Context) {
|
||||
@@ -303,6 +218,196 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDashboardLeads GET /api/admin/dashboard/leads?limit=20
|
||||
// 管理端-首页客资中心:聚合 ckb_lead_records(链接卡若留资)+ ckb_submit_records(join/match),
|
||||
// 联表 users 补齐头像/昵称,按时间倒序,每条包含联系方式(phone/wechatId)与来源。
|
||||
func AdminDashboardLeads(c *gin.Context) {
|
||||
db := database.DB()
|
||||
limit := 20
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
search := c.Query("search")
|
||||
|
||||
// 1. ckb_lead_records(链接卡若 / 文章@)
|
||||
var leads []model.CkbLeadRecord
|
||||
qLead := db.Model(&model.CkbLeadRecord{}).Order("created_at DESC")
|
||||
if search != "" {
|
||||
qLead = qLead.Where("nickname LIKE ? OR phone LIKE ? OR name LIKE ? OR wechat_id LIKE ?",
|
||||
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
qLead.Limit(limit).Find(&leads)
|
||||
|
||||
// 2. ckb_submit_records(join/match)
|
||||
var submits []model.CkbSubmitRecord
|
||||
qSub := db.Model(&model.CkbSubmitRecord{}).Order("created_at DESC")
|
||||
if search != "" {
|
||||
qSub = qSub.Where("nickname LIKE ? OR params LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
qSub.Limit(limit).Find(&submits)
|
||||
|
||||
// 收集所有 userID 关联用户信息
|
||||
userIDs := make(map[string]bool)
|
||||
for _, l := range leads {
|
||||
if l.UserID != "" {
|
||||
userIDs[l.UserID] = true
|
||||
}
|
||||
}
|
||||
for _, s := range submits {
|
||||
if s.UserID != "" {
|
||||
userIDs[s.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Select("id", "nickname", "avatar", "phone", "wechat_id", "is_vip", "tags", "ckb_tags").Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
|
||||
// 统计
|
||||
var totalLeads, totalSubmits int64
|
||||
db.Model(&model.CkbLeadRecord{}).Count(&totalLeads)
|
||||
db.Model(&model.CkbSubmitRecord{}).Count(&totalSubmits)
|
||||
var withPhone int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("phone != '' AND phone IS NOT NULL").Count(&withPhone)
|
||||
|
||||
// 去重统计:按 userId/phone/wechatId 聚合重复次数
|
||||
dupCounts := make(map[string]int64)
|
||||
for _, l := range leads {
|
||||
key := l.UserID
|
||||
if key == "" {
|
||||
key = l.Phone
|
||||
}
|
||||
if key == "" {
|
||||
key = l.WechatID
|
||||
}
|
||||
if key != "" {
|
||||
if _, ok := dupCounts[key]; !ok {
|
||||
var cnt int64
|
||||
q := db.Model(&model.CkbLeadRecord{})
|
||||
if l.UserID != "" {
|
||||
q = q.Where("user_id = ?", l.UserID)
|
||||
} else if l.Phone != "" {
|
||||
q = q.Where("phone = ?", l.Phone)
|
||||
} else {
|
||||
q = q.Where("wechat_id = ?", l.WechatID)
|
||||
}
|
||||
q.Count(&cnt)
|
||||
dupCounts[key] = cnt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构造输出
|
||||
type leadOut struct {
|
||||
SortTime time.Time
|
||||
Data gin.H
|
||||
}
|
||||
all := make([]leadOut, 0, len(leads)+len(submits))
|
||||
for _, l := range leads {
|
||||
u := userMap[l.UserID]
|
||||
avatar := ""
|
||||
userNickname := l.Nickname
|
||||
if u != nil {
|
||||
avatar = dashStr(u.Avatar)
|
||||
if dashStr(u.Nickname) != "" {
|
||||
userNickname = dashStr(u.Nickname)
|
||||
}
|
||||
}
|
||||
sourceLabel := "链接卡若"
|
||||
if l.Source == "article_mention" {
|
||||
sourceLabel = "文章@"
|
||||
} else if l.Source == "index_link_button" {
|
||||
sourceLabel = "首页链接"
|
||||
}
|
||||
key := l.UserID
|
||||
if key == "" {
|
||||
key = l.Phone
|
||||
}
|
||||
if key == "" {
|
||||
key = l.WechatID
|
||||
}
|
||||
dupCount := dupCounts[key]
|
||||
if dupCount <= 1 {
|
||||
dupCount = 0
|
||||
}
|
||||
all = append(all, leadOut{
|
||||
SortTime: l.CreatedAt,
|
||||
Data: gin.H{
|
||||
"id": l.ID,
|
||||
"type": "lead",
|
||||
"userId": l.UserID,
|
||||
"userNickname": userNickname,
|
||||
"userAvatar": avatar,
|
||||
"phone": l.Phone,
|
||||
"wechatId": l.WechatID,
|
||||
"name": l.Name,
|
||||
"source": l.Source,
|
||||
"sourceLabel": sourceLabel,
|
||||
"createdAt": l.CreatedAt,
|
||||
"dupCount": dupCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, s := range submits {
|
||||
u := userMap[s.UserID]
|
||||
avatar := ""
|
||||
userNickname := s.Nickname
|
||||
if u != nil {
|
||||
avatar = dashStr(u.Avatar)
|
||||
if dashStr(u.Nickname) != "" {
|
||||
userNickname = dashStr(u.Nickname)
|
||||
}
|
||||
}
|
||||
all = append(all, leadOut{
|
||||
SortTime: s.CreatedAt,
|
||||
Data: gin.H{
|
||||
"id": s.ID,
|
||||
"type": "submit",
|
||||
"userId": s.UserID,
|
||||
"userNickname": userNickname,
|
||||
"userAvatar": avatar,
|
||||
"matchType": s.Action,
|
||||
"source": s.Action,
|
||||
"sourceLabel": ckbSourceMap[s.Action],
|
||||
"createdAt": s.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
// 按时间倒序合并
|
||||
for i := 0; i < len(all); i++ {
|
||||
for j := i + 1; j < len(all); j++ {
|
||||
if all[j].SortTime.After(all[i].SortTime) {
|
||||
all[i], all[j] = all[j], all[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
out := make([]gin.H, 0, len(all))
|
||||
for _, a := range all {
|
||||
out = append(out, a.Data)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"leads": out,
|
||||
"totalLeads": totalLeads,
|
||||
"totalSubmits": totalSubmits,
|
||||
"withPhone": withPhone,
|
||||
"total": totalLeads + totalSubmits,
|
||||
})
|
||||
}
|
||||
|
||||
func buildNewUsersOut(newUsers []model.User) []gin.H {
|
||||
out := make([]gin.H, 0, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
|
||||
@@ -43,8 +43,46 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 查询每条记录的重复次数
|
||||
dupCounts := make(map[string]int64)
|
||||
if dedup == "true" && len(records) > 0 {
|
||||
for _, r := range records {
|
||||
key := r.UserID
|
||||
if key == "" {
|
||||
key = r.Phone
|
||||
}
|
||||
if key == "" {
|
||||
key = r.WechatID
|
||||
}
|
||||
if key != "" {
|
||||
var cnt int64
|
||||
cntQ := db.Model(&model.CkbLeadRecord{})
|
||||
if r.UserID != "" {
|
||||
cntQ = cntQ.Where("user_id = ?", r.UserID)
|
||||
} else if r.Phone != "" {
|
||||
cntQ = cntQ.Where("phone = ?", r.Phone)
|
||||
} else if r.WechatID != "" {
|
||||
cntQ = cntQ.Where("wechat_id = ?", r.WechatID)
|
||||
}
|
||||
cntQ.Count(&cnt)
|
||||
dupCounts[key] = cnt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
key := r.UserID
|
||||
if key == "" {
|
||||
key = r.Phone
|
||||
}
|
||||
if key == "" {
|
||||
key = r.WechatID
|
||||
}
|
||||
dupCount := dupCounts[key]
|
||||
if dupCount <= 1 {
|
||||
dupCount = 0
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
@@ -54,6 +92,7 @@ func DBCKBLeadList(c *gin.Context) {
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"createdAt": r.CreatedAt,
|
||||
"dupCount": dupCount,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
|
||||
|
||||
@@ -404,6 +404,100 @@ func genPersonToken() (string, error) {
|
||||
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
||||
}
|
||||
|
||||
// DBPersonPin PUT /api/db/persons/pin 管理端-置顶/取消置顶人物到小程序首页
|
||||
func DBPersonPin(c *gin.Context) {
|
||||
var body struct {
|
||||
PersonID string `json:"personId" binding:"required"`
|
||||
IsPinned *bool `json:"isPinned"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var row model.Person
|
||||
if err := db.Where("person_id = ? OR token = ?", body.PersonID, body.PersonID).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
pinned := true
|
||||
if body.IsPinned != nil {
|
||||
pinned = *body.IsPinned
|
||||
} else {
|
||||
pinned = !row.IsPinned
|
||||
}
|
||||
if err := db.Model(&row).Update("is_pinned", pinned).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "isPinned": pinned})
|
||||
}
|
||||
|
||||
// DBPersonPinnedList GET /api/db/persons/pinned 管理端/小程序-获取置顶人物列表
|
||||
func DBPersonPinnedList(c *gin.Context) {
|
||||
var rows []model.Person
|
||||
if err := database.DB().Where("is_pinned = ?", true).Order("updated_at DESC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
db := database.DB()
|
||||
for _, p := range rows {
|
||||
item := gin.H{
|
||||
"personId": p.PersonID,
|
||||
"token": p.Token,
|
||||
"name": p.Name,
|
||||
"label": p.Label,
|
||||
"isPinned": p.IsPinned,
|
||||
}
|
||||
if p.UserID != nil && *p.UserID != "" {
|
||||
var u model.User
|
||||
if db.Select("id", "nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
|
||||
item["userId"] = u.ID
|
||||
item["avatar"] = getUrlValue(u.Avatar)
|
||||
item["nickname"] = getStringValue(u.Nickname)
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "persons": out})
|
||||
}
|
||||
|
||||
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
|
||||
// 查询所有有 ckb_plan_id 的 Person,对每个计划调用存客宝获取状态
|
||||
func AdminCKBPlanCheck(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var persons []model.Person
|
||||
db.Where("ckb_plan_id > 0").Find(&persons)
|
||||
if len(persons) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"})
|
||||
return
|
||||
}
|
||||
token, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(persons))
|
||||
for _, p := range persons {
|
||||
item := gin.H{
|
||||
"personId": p.PersonID,
|
||||
"name": p.Name,
|
||||
"ckbPlanId": p.CkbPlanID,
|
||||
"status": "unknown",
|
||||
}
|
||||
// 尝试启用计划
|
||||
if enableErr := setCkbPlanEnabled(token, p.CkbPlanID, true); enableErr != nil {
|
||||
item["status"] = "error"
|
||||
item["error"] = enableErr.Error()
|
||||
} else {
|
||||
item["status"] = "online"
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": out})
|
||||
}
|
||||
|
||||
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
||||
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地
|
||||
func DBPersonDelete(c *gin.Context) {
|
||||
|
||||
@@ -32,6 +32,8 @@ type Person struct {
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
|
||||
admin.GET("/user/track", handler.UserTrackGet)
|
||||
admin.GET("/track/stats", handler.AdminTrackStats)
|
||||
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
|
||||
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
@@ -199,6 +201,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.GET("/link-tags", handler.DBLinkTagList)
|
||||
db.POST("/link-tags", handler.DBLinkTagSave)
|
||||
db.DELETE("/link-tags", handler.DBLinkTagDelete)
|
||||
db.PUT("/persons/pin", handler.DBPersonPin)
|
||||
db.GET("/persons/pinned", handler.DBPersonPinnedList)
|
||||
db.GET("/ckb-leads", handler.DBCKBLeadList)
|
||||
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
|
||||
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
|
||||
@@ -352,6 +356,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
|
||||
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
|
||||
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
|
||||
miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList)
|
||||
// 埋点
|
||||
miniprogram.POST("/track", handler.MiniprogramTrackPost)
|
||||
// 规则引擎(用户旅程引导)
|
||||
|
||||
Reference in New Issue
Block a user