新增置顶人功能,允许在小程序首页展示特定用户信息(昵称、头像)。更新相关 API 接口以支持获取和设置置顶人,优化用户体验。调整小程序页面以动态显示置顶人信息,确保一致性和可用性。

This commit is contained in:
Alex-larget
2026-03-20 15:58:18 +08:00
parent f7e7552eb1
commit 1b87fa92f7
12 changed files with 389 additions and 30 deletions

View File

@@ -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
}

View File

@@ -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] || ''
},

View File

@@ -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() {

View File

@@ -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>

View File

@@ -107,6 +107,11 @@ Page({
}
}
this.initUserStatus()
// 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗
if (app.globalData.pendingLoginAfterExpire) {
app.globalData.pendingLoginAfterExpire = false
setTimeout(() => this.showLogin(), 100)
}
},
async loadFeatureConfig() {

View File

@@ -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"

View File

@@ -77,6 +77,24 @@ func ckbSign(params map[string]interface{}, apiKey string) string {
return hex.EncodeToString(h2[:])
}
const pinnedPersonTokenKey = "pinned_person_token"
// getPinnedPersonToken 读取置顶人物 tokensystem_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 {

View File

@@ -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()

View File

@@ -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"` // 存客宝真实密钥,不对外暴露

View File

@@ -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)

View 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时使用';

View 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