新增置顶人功能,允许在小程序首页展示特定用户信息(昵称、头像)。更新相关 API 接口以支持获取和设置置顶人,优化用户体验。调整小程序页面以动态显示置顶人信息,确保一致性和可用性。
This commit is contained in:
@@ -85,6 +85,8 @@ App({
|
||||
lastVipContactCheck: 0,
|
||||
// 头像昵称检测:上次检测时间戳,onShow 节流 5 分钟
|
||||
lastAvatarNicknameCheck: 0,
|
||||
// 登录过期:401 后用户点「去登录」时设为 true,我的页 onShow 会检测并自动弹登录
|
||||
pendingLoginAfterExpire: false,
|
||||
},
|
||||
|
||||
|
||||
@@ -875,7 +877,29 @@ App({
|
||||
}
|
||||
if (res.statusCode === 401) {
|
||||
this.logout()
|
||||
showError('未授权,请重新登录')
|
||||
if (!silent) {
|
||||
wx.showModal({
|
||||
title: '登录已过期',
|
||||
content: '请重新登录后继续使用',
|
||||
confirmText: '去登录',
|
||||
cancelText: '稍后',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1]
|
||||
const route = (cur && cur.route) || ''
|
||||
if (route === 'pages/my/my' && typeof cur.showLogin === 'function') {
|
||||
cur.showLogin()
|
||||
} else {
|
||||
this.globalData.pendingLoginAfterExpire = true
|
||||
wx.switchTab({ url: '/pages/my/my' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showError('未授权,请重新登录')
|
||||
}
|
||||
reject(new Error('未授权'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ Component({
|
||||
'run-in': '\ue6a7',
|
||||
'pin': '\ue6a8',
|
||||
'save': '\ue6a9',
|
||||
'search': '\ue6aa',
|
||||
'search': '', // 强制走 SVG
|
||||
'share': '\ue6ab',
|
||||
'scanning': '\ue6ac',
|
||||
'security': '\ue6ad',
|
||||
@@ -157,19 +157,19 @@ Component({
|
||||
'chevron-right': '\ue6c6',
|
||||
'chevron-down': '\ue6c4',
|
||||
'chevron-up': '\ue6c2',
|
||||
'x': '\ue6c3',
|
||||
'check': '\ue6c7',
|
||||
'x': '', // 强制走 SVG
|
||||
'check': '', // 强制走 SVG
|
||||
'trash-2': '\ue66a',
|
||||
'pencil': '\ue685',
|
||||
'zap': '\ue75c',
|
||||
'zap': '', // 强制走 SVG,找伙伴页等统一用 Lucide 风格
|
||||
'info': '\ue69c',
|
||||
'map-pin': '\ue6a8',
|
||||
'message-circle': '\ue678',
|
||||
'smartphone': '\ue6a0',
|
||||
'message-circle': '', // 强制走 SVG
|
||||
'smartphone': '', // 强制走 SVG
|
||||
'refresh-cw': '\ue6a4',
|
||||
'shield': '\ue6ad',
|
||||
'star': '\ue689',
|
||||
'heart': '\ue68e',
|
||||
'star': '', // 强制走 SVG
|
||||
'heart': '', // 强制走 SVG
|
||||
'bar-chart': '\ue672',
|
||||
'clock': '\ue6b5',
|
||||
}
|
||||
@@ -224,7 +224,9 @@ Component({
|
||||
'corner-down-left': s + '<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>',
|
||||
'folder': s + '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/></svg>',
|
||||
'bar-chart': s + '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>',
|
||||
'link': s + '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
|
||||
'link': s + '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
|
||||
'zap': s + '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
'x': s + '<path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
|
||||
}
|
||||
return svgMap[name] || ''
|
||||
},
|
||||
|
||||
@@ -73,7 +73,10 @@ Page({
|
||||
searchEnabled: true,
|
||||
|
||||
// 审核模式:隐藏支付相关入口
|
||||
auditMode: false
|
||||
auditMode: false,
|
||||
|
||||
// 置顶人(链接人与事,管理端设置,小程序首页展示)
|
||||
pinnedPerson: null // { nickname, avatar, token } 或 null
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -98,7 +101,10 @@ Page({
|
||||
|
||||
onShow() {
|
||||
console.log('[Index] onShow 触发')
|
||||
const app = getApp()
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
// 每次展示时刷新置顶人(管理端可能已更换置顶)
|
||||
this.loadPinnedPerson()
|
||||
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
@@ -131,6 +137,21 @@ Page({
|
||||
this.loadBookData()
|
||||
this.loadFeaturedAndLatest()
|
||||
this.loadSuperMembers()
|
||||
this.loadPinnedPerson()
|
||||
},
|
||||
|
||||
async loadPinnedPerson() {
|
||||
const app = getApp()
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/ckb/pinned-person', silent: true })
|
||||
if (res && res.success && res.data) {
|
||||
this.setData({ pinnedPerson: res.data })
|
||||
} else {
|
||||
this.setData({ pinnedPerson: null })
|
||||
}
|
||||
} catch (e) {
|
||||
this.setData({ pinnedPerson: null })
|
||||
}
|
||||
},
|
||||
|
||||
async loadSuperMembers() {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<view class="contact-btn" bindtap="onLinkKaruo">
|
||||
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
|
||||
<text class="contact-text">点击链接卡若</text>
|
||||
<image class="contact-avatar" src="{{pinnedPerson && pinnedPerson.avatar ? pinnedPerson.avatar : '/assets/images/author-avatar.png'}}" mode="aspectFill"/>
|
||||
<text class="contact-text">点击链接{{pinnedPerson && pinnedPerson.nickname ? pinnedPerson.nickname : '卡若'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -107,6 +107,11 @@ Page({
|
||||
}
|
||||
}
|
||||
this.initUserStatus()
|
||||
// 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗
|
||||
if (app.globalData.pendingLoginAfterExpire) {
|
||||
app.globalData.pendingLoginAfterExpire = false
|
||||
setTimeout(() => this.showLogin(), 100)
|
||||
}
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
|
||||
@@ -266,6 +266,9 @@ export function ContentPage() {
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false)
|
||||
const [editingPerson, setEditingPerson] = useState<PersonItem | null>(null)
|
||||
const [personToDelete, setPersonToDelete] = useState<PersonItem | null>(null)
|
||||
// 链接人与事置顶(小程序首页展示)
|
||||
const [pinnedPersonToken, setPinnedPersonToken] = useState<string>('')
|
||||
const [personPinLoading, setPersonPinLoading] = useState(false)
|
||||
// CKB 获客统计(按人物 token 聚合)
|
||||
const [ckbLeadCounts, setCkbLeadCounts] = useState<Record<string, number>>({})
|
||||
const [ckbLeadDetailOpen, setCkbLeadDetailOpen] = useState(false)
|
||||
@@ -533,6 +536,37 @@ export function ContentPage() {
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
const loadPinnedPersonToken = useCallback(async () => {
|
||||
try {
|
||||
const data = await get<{ success?: boolean; token?: string }>('/api/db/persons/pinned-token')
|
||||
if (data?.success && typeof data.token === 'string') {
|
||||
setPinnedPersonToken(data.token)
|
||||
} else {
|
||||
setPinnedPersonToken('')
|
||||
}
|
||||
} catch {
|
||||
setPinnedPersonToken('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePersonPin = useCallback(async (token: string) => {
|
||||
if (personPinLoading) return
|
||||
setPersonPinLoading(true)
|
||||
try {
|
||||
const res = await put<{ success?: boolean; error?: string; message?: string }>('/api/db/persons/pin', { token })
|
||||
if (res?.success) {
|
||||
toast.success(res.message ?? '已置顶')
|
||||
setPinnedPersonToken(token)
|
||||
} else {
|
||||
toast.error(res?.error ?? '置顶失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '置顶失败')
|
||||
} finally {
|
||||
setPersonPinLoading(false)
|
||||
}
|
||||
}, [personPinLoading])
|
||||
|
||||
const openCkbLeadDetail = useCallback(async (token: string, name: string, page = 1) => {
|
||||
setCkbLeadDetailToken(token)
|
||||
setCkbLeadDetailName(name)
|
||||
@@ -667,8 +701,9 @@ export function ContentPage() {
|
||||
loadPersons()
|
||||
loadLinkTags()
|
||||
loadCkbLeadCounts()
|
||||
loadPinnedPersonToken()
|
||||
loadLinkedMps()
|
||||
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps])
|
||||
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadPinnedPersonToken, loadLinkedMps])
|
||||
|
||||
useEffect(() => {
|
||||
loadLinkTagList()
|
||||
@@ -2461,7 +2496,7 @@ export function ContentPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-600 text-gray-400 hover:bg-gray-700/50"
|
||||
onClick={() => loadPersons()}
|
||||
onClick={() => { loadPersons(); loadPinnedPersonToken(); }}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
@@ -2495,9 +2530,14 @@ export function ContentPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{persons.map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-700/30 hover:bg-[#0a1628]/80">
|
||||
<tr key={p.id} className={`border-b border-gray-700/30 hover:bg-[#0a1628]/80 ${pinnedPersonToken === p.id ? 'bg-amber-500/5' : ''}`}>
|
||||
<td className="py-2 px-3 text-gray-400 text-xs font-mono" title="32位token">{p.id}</td>
|
||||
<td className="py-2 px-3 text-amber-400 truncate max-w-[96px]" title="@的人">{p.name}</td>
|
||||
<td className="py-2 px-3 text-amber-400 truncate max-w-[96px]" title="@的人">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{p.name}
|
||||
{pinnedPersonToken === p.id && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" title="已置顶(小程序首页展示)" />}
|
||||
</span>
|
||||
</td>
|
||||
{(() => {
|
||||
const leadCount = ckbLeadCounts[p.id] || 0
|
||||
return (
|
||||
@@ -2532,6 +2572,16 @@ export function ContentPage() {
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 ${pinnedPersonToken === p.id ? 'text-amber-400' : 'text-gray-400 hover:text-amber-400'}`}
|
||||
title={pinnedPersonToken === p.id ? '已置顶(小程序首页展示)' : '设为置顶(小程序首页展示此人)'}
|
||||
disabled={personPinLoading}
|
||||
onClick={() => handlePersonPin(p.id)}
|
||||
>
|
||||
{pinnedPersonToken === p.id ? <Star className="w-3 h-3 fill-current" /> : <Pin className="w-3 h-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -77,6 +77,24 @@ func ckbSign(params map[string]interface{}, apiKey string) string {
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
const pinnedPersonTokenKey = "pinned_person_token"
|
||||
|
||||
// getPinnedPersonToken 读取置顶人物 token(system_config)
|
||||
func getPinnedPersonToken() string {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", pinnedPersonTokenKey).First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m["token"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCkbLeadApiKey 链接卡若密钥优先级:system_config.site_settings.ckbLeadApiKey > .env CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
|
||||
func getCkbLeadApiKey() string {
|
||||
var row model.SystemConfig
|
||||
@@ -307,8 +325,49 @@ func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// CKBPinnedPerson GET /api/miniprogram/ckb/pinned-person 小程序首页获取置顶人信息(昵称、头像)
|
||||
// 有 user_id 时从 users 表取;无则用 Person.name、Person.avatar
|
||||
func CKBPinnedPerson(c *gin.Context) {
|
||||
token := getPinnedPersonToken()
|
||||
if token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var p model.Person
|
||||
if err := db.Where("token = ?", token).First(&p).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
nickname := strings.TrimSpace(p.Name)
|
||||
avatar := strings.TrimSpace(p.Avatar)
|
||||
if p.UserID != nil && strings.TrimSpace(*p.UserID) != "" {
|
||||
var u model.User
|
||||
if db.Select("nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
|
||||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||||
nickname = strings.TrimSpace(*u.Nickname)
|
||||
}
|
||||
if u.Avatar != nil && strings.TrimSpace(*u.Avatar) != "" {
|
||||
avatar = strings.TrimSpace(*u.Avatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
if nickname == "" {
|
||||
nickname = "链接人"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"nickname": nickname,
|
||||
"avatar": avatar,
|
||||
"token": token,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
|
||||
// - 固定使用全局 CKB_LEAD_API_KEY,不受文章 @ 人物的 ckb_api_key 影响
|
||||
// - 有置顶人时:使用置顶人的 ckb_api_key 推送到其存客宝计划(与文章 @ 获客逻辑一致)
|
||||
// - 无置顶人时:fallback 全局 CKB_LEAD_API_KEY
|
||||
// - 请求体:userId(可选,用于补全昵称)、phone/wechatId(至少一个)、name(可选)
|
||||
func CKBIndexLead(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -337,8 +396,17 @@ func CKBIndexLead(c *gin.Context) {
|
||||
name = "小程序用户"
|
||||
}
|
||||
|
||||
// 首页固定使用全局密钥:system_config > .env > 代码内置
|
||||
// 优先使用置顶人的 ckb_api_key;无则用全局密钥
|
||||
leadKey := getCkbLeadApiKey()
|
||||
targetPersonID := ""
|
||||
pinnedToken := getPinnedPersonToken()
|
||||
if pinnedToken != "" {
|
||||
var p model.Person
|
||||
if db.Where("token = ?", pinnedToken).First(&p).Error == nil && strings.TrimSpace(p.CkbApiKey) != "" {
|
||||
leadKey = p.CkbApiKey
|
||||
targetPersonID = p.PersonID
|
||||
}
|
||||
}
|
||||
|
||||
// 去重限频:2 分钟内同一用户/手机/微信只能提交一次
|
||||
var cond []string
|
||||
@@ -362,16 +430,17 @@ func CKBIndexLead(c *gin.Context) {
|
||||
source := "index_link_button"
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"source": source,
|
||||
"source": source, "pinnedToken": pinnedToken,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
TargetPersonID: targetPersonID,
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
@@ -405,9 +474,17 @@ func CKBIndexLead(c *gin.Context) {
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
msg := "提交成功,我们会尽快联系您"
|
||||
if pinnedToken != "" {
|
||||
var p model.Person
|
||||
if db.Where("token = ?", pinnedToken).First(&p).Error == nil && p.Name != "" {
|
||||
msg = "提交成功," + p.Name + "会尽快联系您"
|
||||
}
|
||||
} else {
|
||||
msg = "提交成功,卡若会尽快联系您"
|
||||
}
|
||||
if repeatedSubmit {
|
||||
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
||||
msg = "您已留资过,我们已再次通知,请耐心等待添加"
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -404,8 +405,65 @@ 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 {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 token"})
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(body.Token)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "token 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var p model.Person
|
||||
if err := db.Where("token = ?", token).First(&p).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该人物不存在"})
|
||||
return
|
||||
}
|
||||
valBytes, _ := json.Marshal(map[string]string{"token": token})
|
||||
desc := "小程序首页置顶人物 token"
|
||||
var row model.SystemConfig
|
||||
var err error
|
||||
if db.Where("config_key = ?", pinnedPersonTokenKey).First(&row).Error != nil {
|
||||
row = model.SystemConfig{ConfigKey: pinnedPersonTokenKey, ConfigValue: model.ConfigValue(valBytes), Description: &desc}
|
||||
err = db.Create(&row).Error
|
||||
} else {
|
||||
row.ConfigValue = model.ConfigValue(valBytes)
|
||||
err = db.Save(&row).Error
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已置顶 " + p.Name})
|
||||
}
|
||||
|
||||
// DBPersonPinnedToken GET /api/db/persons/pinned-token 管理端-获取当前置顶人 token
|
||||
func DBPersonPinnedToken(c *gin.Context) {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", pinnedPersonTokenKey).First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "token": ""})
|
||||
return
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "token": ""})
|
||||
return
|
||||
}
|
||||
token := ""
|
||||
if v, ok := m["token"].(string); ok {
|
||||
token = strings.TrimSpace(v)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "token": token})
|
||||
}
|
||||
|
||||
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
||||
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地
|
||||
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地;若为当前置顶则清空置顶
|
||||
func DBPersonDelete(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
@@ -417,6 +475,16 @@ func DBPersonDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
// 若为当前置顶,先清空置顶配置
|
||||
var cfg model.SystemConfig
|
||||
if database.DB().Where("config_key = ?", pinnedPersonTokenKey).First(&cfg).Error == nil && len(cfg.ConfigValue) > 0 {
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &m) == nil {
|
||||
if v, ok := m["token"].(string); ok && strings.TrimSpace(v) == row.Token {
|
||||
_ = database.DB().Where("config_key = ?", pinnedPersonTokenKey).Delete(&model.SystemConfig{}).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
// 若有存客宝计划,先调 CKB 删除
|
||||
if row.CkbPlanID > 0 {
|
||||
token, err := ckbOpenGetToken()
|
||||
|
||||
@@ -15,6 +15,7 @@ type Person struct {
|
||||
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
|
||||
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token,文章/小程序传此值
|
||||
Name string `gorm:"column:name;size:100" json:"name"`
|
||||
Avatar string `gorm:"column:avatar;size:512;default:''" json:"avatar"` // 头像 URL,无 user_id 时使用;有 user_id 时优先用 users.avatar
|
||||
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
|
||||
Label string `gorm:"column:label;size:200" json:"label"`
|
||||
CkbApiKey string `gorm:"column:ckb_api_key;size:100;default:''" json:"ckbApiKey"` // 存客宝真实密钥,不对外暴露
|
||||
|
||||
@@ -201,6 +201,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
|
||||
db.GET("/persons", handler.DBPersonList)
|
||||
db.GET("/person", handler.DBPersonDetail)
|
||||
db.GET("/persons/pinned-token", handler.DBPersonPinnedToken)
|
||||
db.PUT("/persons/pin", handler.DBPersonPin)
|
||||
db.POST("/persons", handler.DBPersonSave)
|
||||
db.DELETE("/persons", handler.DBPersonDelete)
|
||||
db.GET("/link-tags", handler.DBLinkTagList)
|
||||
@@ -325,6 +327,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/match/users", handler.MatchUsers)
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
miniprogram.POST("/ckb/match", handler.CKBMatch)
|
||||
miniprogram.GET("/ckb/pinned-person", handler.CKBPinnedPerson)
|
||||
miniprogram.POST("/ckb/lead", handler.CKBLead)
|
||||
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
|
||||
6
soul-api/scripts/add_persons_avatar.sql
Normal file
6
soul-api/scripts/add_persons_avatar.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 链接人与事 - persons 表新增 avatar 字段
|
||||
-- 有 user_id 时头像从 users 表取;无 user_id 时用本字段(管理端可编辑)
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add_persons_avatar.sql
|
||||
|
||||
-- 若已存在则忽略错误
|
||||
ALTER TABLE persons ADD COLUMN avatar VARCHAR(512) DEFAULT '' COMMENT '头像URL,无user_id时使用';
|
||||
102
开发文档/1、需求/链接人与事-置顶与超级个体对应设计.md
Normal file
102
开发文档/1、需求/链接人与事-置顶与超级个体对应设计.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 链接人与事 — @的人 与 超级个体 对应设计
|
||||
|
||||
> 补充:置顶功能中「@的人」与「超级个体」的合理对应关系
|
||||
> 创建日期:2026-03-20
|
||||
|
||||
---
|
||||
|
||||
## 一、现状与关系
|
||||
|
||||
| 概念 | 数据来源 | 说明 |
|
||||
|------|----------|------|
|
||||
| **@的人** | `persons` 表 | 文章可 @ 提及,小程序点击可留资;有 token、name、user_id、ckb_api_key 等 |
|
||||
| **超级个体** | `users` + `orders` | 开通 VIP 的用户,首页「超级个体」横向滚动展示 |
|
||||
| **绑定关系** | `persons.user_id` | 超级个体开通后,`ensurePersonForUser` 自动创建 Person 并绑定 user_id |
|
||||
|
||||
**已有逻辑**:
|
||||
- 超级个体开通成功 → 调用 `ensurePersonForUser(userId)` → 创建或复用 Person,`user_id` 指向该用户
|
||||
- Person.name 与 users.nickname 保持同步(ensurePersonForUser 会按 user_id 更新 name)
|
||||
- 手工在管理端添加的 Person 无 user_id(如卡若、南风等运营角色)
|
||||
|
||||
---
|
||||
|
||||
## 二、设计原则
|
||||
|
||||
1. **一对一**:一个超级个体用户 ↔ 一个 @的人(Person),通过 `user_id` 唯一绑定
|
||||
2. **两种 Person 来源**:
|
||||
- **超级个体**:有 `user_id`,开通时自动创建,昵称/头像随 users 表实时更新
|
||||
- **手工添加**:无 `user_id`,运营在管理端手动创建,需在 Person 表维护 name、avatar
|
||||
3. **置顶候选**:两种 Person 都可被置顶,但**推荐置顶超级个体**(有 user_id),因头像/昵称无需额外维护
|
||||
|
||||
---
|
||||
|
||||
## 三、推荐设计方案
|
||||
|
||||
### 3.1 头像与昵称来源(按 user_id 区分)
|
||||
|
||||
| Person 类型 | nickname 来源 | avatar 来源 |
|
||||
|-------------|--------------|-------------|
|
||||
| 有 user_id(超级个体) | `users.nickname` | `users.avatar` |
|
||||
| 无 user_id(手工添加) | `persons.name` | `persons.avatar`(需新增字段) |
|
||||
|
||||
**实现**:`GET /api/miniprogram/ckb/pinned-person` 返回时:
|
||||
- 若 Person 有 user_id → JOIN users 取 nickname、avatar(与「我的」页一致,用户可随时改)
|
||||
- 若无 user_id → 用 Person.name、Person.avatar
|
||||
|
||||
### 3.2 置顶限制(两种策略)
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **A:不限制** | 所有 Person 都可置顶 | 运营需要置顶「卡若」等非超级个体时 |
|
||||
| **B:仅超级个体** | 置顶时校验 `user_id` 非空 | 产品强约束「首页链接入口只能是超级个体」 |
|
||||
|
||||
**推荐**:策略 A(不限制),管理端列表加「来源」列区分,运营按需选择。若产品后续要求「只能置顶超级个体」,再在 `PUT /api/db/persons/pin` 加校验即可。
|
||||
|
||||
### 3.3 管理端展示增强
|
||||
|
||||
| 改造 | 说明 |
|
||||
|------|------|
|
||||
| 列表加「来源」列 | 有 user_id 显示「超级个体」,无则显示「手工添加」 |
|
||||
| 置顶按钮 | 对所有 Person 开放;已置顶行显示「已置顶」标识 |
|
||||
| 超级个体行 | 可选展示「绑定用户」链接,便于运营跳转用户详情 |
|
||||
|
||||
### 3.4 数据流示意
|
||||
|
||||
```
|
||||
超级个体开通
|
||||
→ ensurePersonForUser(userId)
|
||||
→ 创建 Person(user_id=userId, name=nickname)
|
||||
→ 出现在「链接人与事」列表,来源=超级个体
|
||||
|
||||
运营置顶该 Person
|
||||
→ pinned_person_token = token
|
||||
→ 小程序首页 GET pinned-person
|
||||
→ 有 user_id → JOIN users 取 nickname、avatar
|
||||
→ 展示「点击链接{昵称}」+ 头像
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、与置顶功能技术分析的衔接
|
||||
|
||||
| 原技术分析 | 本设计补充 |
|
||||
|------------|------------|
|
||||
| Person 表加 avatar 字段 | **仅对无 user_id 的 Person 必填**;有 user_id 时 avatar 来自 users,可不填 |
|
||||
| pinned-person 返回 nickname、avatar | **有 user_id 时从 users 表取**;无则从 Person 取 |
|
||||
| 管理端 PersonAddEditModal | 有 user_id 时 avatar 可只读展示(来自 users),或支持「同步用户头像」按钮 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实施清单补充
|
||||
|
||||
| 角色 | 补充任务 |
|
||||
|------|----------|
|
||||
| 后端 | pinned-person 接口:有 user_id 时 JOIN users 取 nickname、avatar;无则用 Person.name、Person.avatar |
|
||||
| 后端 | Person.avatar 仅对无 user_id 的 Person 必填(或管理端编辑时可选填) |
|
||||
| 管理端 | 列表加「来源」列:超级个体 / 手工添加 |
|
||||
| 产品 | 确认:置顶是否限制为「仅超级个体」;若否,保持当前设计 |
|
||||
|
||||
---
|
||||
|
||||
**创建时间**:2026-03-20
|
||||
**关联**:`链接人与事-置顶功能-技术分析.md`(archive)
|
||||
Reference in New Issue
Block a user