diff --git a/miniprogram/app.js b/miniprogram/app.js index 16e686f3..d7a15421 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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 } diff --git a/miniprogram/components/icon/icon.js b/miniprogram/components/icon/icon.js index 095a9068..10918dac 100644 --- a/miniprogram/components/icon/icon.js +++ b/miniprogram/components/icon/icon.js @@ -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 + '', 'folder': s + '', 'bar-chart': s + '', - 'link': s + '' + 'link': s + '', + 'zap': s + '', + 'x': s + '' } return svgMap[name] || '' }, diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index c9d457f2..53fcba0d 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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() { diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 8739bf3a..bf9fb1c4 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -18,8 +18,8 @@ - - 点击链接卡若 + + 点击链接{{pinnedPerson && pinnedPerson.nickname ? pinnedPerson.nickname : '卡若'}} diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 973c1e59..cacbfb73 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -107,6 +107,11 @@ Page({ } } this.initUserStatus() + // 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗 + if (app.globalData.pendingLoginAfterExpire) { + app.globalData.pendingLoginAfterExpire = false + setTimeout(() => this.showLogin(), 100) + } }, async loadFeatureConfig() { diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index 37d124a5..c7b85206 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -266,6 +266,9 @@ export function ContentPage() { const [personModalOpen, setPersonModalOpen] = useState(false) const [editingPerson, setEditingPerson] = useState(null) const [personToDelete, setPersonToDelete] = useState(null) + // 链接人与事置顶(小程序首页展示) + const [pinnedPersonToken, setPinnedPersonToken] = useState('') + const [personPinLoading, setPersonPinLoading] = useState(false) // CKB 获客统计(按人物 token 聚合) const [ckbLeadCounts, setCkbLeadCounts] = useState>({}) 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="刷新" > @@ -2495,9 +2530,14 @@ export function ContentPage() { {persons.map(p => ( - + {p.id} - {p.name} + + + {p.name} + {pinnedPersonToken === p.id && } + + {(() => { const leadCount = ckbLeadCounts[p.id] || 0 return ( @@ -2532,6 +2572,16 @@ export function ContentPage() { + handlePersonPin(p.id)} + > + {pinnedPersonToken === p.id ? : } + .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 { diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 0ed11056..85482c86 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -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() diff --git a/soul-api/internal/model/person.go b/soul-api/internal/model/person.go index d821b039..90fbf45e 100644 --- a/soul-api/internal/model/person.go +++ b/soul-api/internal/model/person.go @@ -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"` // 存客宝真实密钥,不对外暴露 diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 0854b9d6..31342b6f 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -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) diff --git a/soul-api/scripts/add_persons_avatar.sql b/soul-api/scripts/add_persons_avatar.sql new file mode 100644 index 00000000..198a2c61 --- /dev/null +++ b/soul-api/scripts/add_persons_avatar.sql @@ -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时使用'; diff --git a/开发文档/1、需求/链接人与事-置顶与超级个体对应设计.md b/开发文档/1、需求/链接人与事-置顶与超级个体对应设计.md new file mode 100644 index 00000000..a73871e1 --- /dev/null +++ b/开发文档/1、需求/链接人与事-置顶与超级个体对应设计.md @@ -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)