feat: unify planKey handling for lead management

- Added a new entry for the 存客宝计划 Key in the documentation, ensuring consistency between the displayed planKey and the apiKey used for retries.
- Implemented a caching mechanism for the JWT token in the ckbOpenGetToken function to reduce redundant API calls.
- Refactored the lead management logic to ensure that the same planKey is used for both displaying leads and retrying failed pushes, enhancing data integrity and reducing discrepancies.

This update aims to streamline lead management processes and improve the reliability of API interactions.
This commit is contained in:
乘风
2026-05-08 15:39:47 +08:00
parent 7399e392aa
commit b2b753d566
13 changed files with 114 additions and 76 deletions

View File

@@ -0,0 +1,13 @@
# 存客宝工作台「join·资源对接」接口测试误报失败
## 现象
管理端 `CKBConfigPanel` 接口测试对 `POST /api/ckb/join``type=investor``userId=admin_test``CKBJoin` 对资源对接在非空 `userId` 时校验 `userHasContentPurchase``admin_test` 非真实用户且无订单 → 返回 `success:false``CONTENT_PURCHASE_REQUIRED`,与其它 join 子项不一致。
## 处理
`internal/handler/ckb.go` 中:**当 `userId` 去空格后为 `admin_test` 时跳过购课校验**(该串仅工作台连通性测试使用)。真实用户 ID 仍为 UUID不受影响。
## 注意
勿将真实用户 ID 设为 `admin_test`;若后续收紧,可改为管理端专用 Header 或仅 `/api/db` 代理测试接口。

View File

@@ -0,0 +1,37 @@
# 存客宝:计划 Key 列表展示与重推实发 apiKey 一致
## 问题现象
- 管理端「推广中心 / 获客列表」**计划Key** 列显示为某条计划(如 `2y4v5-…`)。
-**重推** 时,实际请求 `ckbapi…/v1/api/scenarios``apiKey` 却是全局 `getCkbLeadApiKey()`(如 `IPQ9s-…`),存客宝返回 `无效的apiKey` / 401。
## 根因
1. **两套解析链**`GET /api/db/ckb-leads?mode=contact``planApiKey` 做多层兜底(`plan_api_key` → action 默认 → `target_person_id`/`persons.ckb_api_key` → index_link 全局人物);旧 **`retryOneLeadRecord`** 在 `plan_api_key` 为空时**直接** `getCkbLeadApiKey()`,且曾用 `params.targetUserId` 覆盖却**未读表字段 `target_person_id`**,甚至曾在非空快照上再被人物 key 覆盖。
2. **展示与写请求分叉**:运营按界面计划 Key 排障,与抓包不一致,误判为「存客宝挂了」。
## 修复要点(已实现)
-**`resolveLeadPlanAPIKeyForRetry`**(命名保留历史;语义为「本条线索应采信的计划 apiKey」顺序与列表原注释一致。
- **`retryOneLeadRecord`** 仅通过该函数取 key`target_person_id``params.targetUserId` 并用。
- **`DBCKBLeadList` mode=contact** 循环内 **`planApiKey` 改为调用同一函数**,与重推、定时重试(同走 `retryOneLeadRecord`)长期同源,避免再分叉。
- 调试用的 **`DBCKBLeadRetry` 终端打印、`pushLeadToCKB` URL 打印** 已按上线要求移除。
## 排查同类问题时的检查清单
| 检查项 | 说明 |
|--------|------|
| 是否存在「列表/详情一套、再请求另一套」 | 搜索同一业务字段在两处分别拼接 |
| `plan_api_key` 空时行为 | 是否与人物、`action/source` 一致 |
| 人物标识 | 表字段 `target_person_id` 与 JSON `targetUserId` 是否同时考虑 |
| 重推与首次提交 | 重推当前为 **GET scenarios 简化参数**;若与首次 **POST JSON** 不一致,可能仍有个案失败(产品另行评估) |
## 关联代码
- `soul-api/internal/handler/ckb.go``resolveLeadPlanAPIKeyForRetry``retryOneLeadRecord`
- `soul-api/internal/handler/db_ckb_leads.go``DBCKBLeadList` mode=contact 中 `planApiKey`
## 关联文档 / Skill
- `.cursor/skills/ckb-leads-api-test/SKILL.md`GotchasplanKey 与重推一致性)
- `.cursor/skills/api-dev/SKILL.md`Gotchas展示与下游请求字段同源

View File

@@ -12,3 +12,5 @@
| 2026-03-24 | router 缺失四 handlerBookRanking、DBPersonPinnedToken、CKBPinnedPerson、AdminDashboardLeads | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档:接口/迁移文档化与 P0/P1 标注 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀接口与需求表同步、BACKEND-P0 行 | [2026-04-14.md](./2026-04-14.md) |
| 2026-05-08 | 存客宝获客列表计划Key与重推 apiKey`resolveLeadPlanAPIKeyForRetry` 与列表同源,避免展示与抓包分叉 | [2026-05-08-存客宝计划Key列表与重推一致.md](./2026-05-08-存客宝计划Key列表与重推一致.md) |
| 2026-05-08 | 存客宝工作台 join·资源对接测试`userId=admin_test` 跳过购课校验,避免 CONTENT_PURCHASE_REQUIRED 误报 | [2026-05-08-存客宝工作台资源对接接口测试.md](./2026-05-08-存客宝工作台资源对接接口测试.md) |

View File

@@ -24,6 +24,7 @@
- 2026-03-10迁移 Mycontent-temp主导航收敛/Settings 承载)
- 2026-03-05文章详情@某人(编辑页插入 @ 用户)
- 后端工程师:`agent/后端工程师/evolution/索引.md`
- 2026-05-08存客宝计划 Key 列表与重推 apiKey 同源resolveLeadPlanAPIKeyForRetry
- 2026-04-14吸收沉淀接口与需求表、BACKEND-P0
- 2026-04-13按功能同步开发文档接口/迁移文档化、P0/P1
- 2026-03-24router 缺失四 handler 补齐BookRanking 等)

View File

@@ -12,4 +12,5 @@
| 2026-03-16 | 测试用例归档规则混合→process、纯端→miniapp/web需求变更时更新 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-04-13 | 按功能同步开发文档:以文档与索引为真源、契约缺失可阻塞 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀LOOP 需求行与逆推清单回归 | [2026-04-14.md](./2026-04-14.md) |
| 2026-05-08 | 获客列表计划Key与重推 apiKey 同源后可验收:同 id 下列展示与推送 key 一致 | [后端 2026-05-08](../../后端工程师/evolution/2026-05-08-存客宝计划Key列表与重推一致.md) |
| 2026-05-07 | 存客宝工作台线索类型join=加入、match=匹配、lead=留资;开发文档与 UI 中文映射 | [存客宝-工作台线索类型](../../../开发文档/6、后端/存客宝-工作台线索类型与中英文说明.md) |

View File

@@ -112,6 +112,7 @@ description: Trigger when 编辑 soul-api、后端、API、Go、Gin、GORM、新
| 新增表/字段后未执行迁移 | 线上报错、字段缺失 | 编写 scripts/add-xxx.sql → 执行 `node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-xxx.sql` |
| 新增 model 未 AutoMigrate | 表不存在、启动失败 | 在 `internal/database/database.go``AutoMigrate(&model.Xxx{})` |
| 业务逻辑直接 `os.Getenv` | 配置分散、难维护 | 仅通过 `internal/config``Load()` 读环境变量 |
| 管理端展示的「计划 Key / 状态」与再次调用第三方用的密钥 **分两处手写解析** | 线上列表对的、重推错的;排障永远对不上抓包 | **单一真源函数**:如存客宝线索 `resolveLeadPlanAPIKeyForRetry` 同时服务 `GET .../ckb-leads?mode=contact``planApiKey``retryOneLeadRecord`,禁止列表与重推各写一套顺序 |
---

View File

@@ -74,6 +74,7 @@ description: JWT 驱动的存客宝获客列表 API 回归。用户粘贴管理
| 把 JWT 写进会议纪要/需求文档 | **账号泄露** | 仅口头/私聊粘贴;复盘里写「已用临时 token 测过」不写原文 |
| 脚本失败就认为一定是小程序 Bug | **误判** | 先区分「接口未返回字段」vs「库里真的无手机号」——前者是后端/部署,后者才可能追 C 端 |
| 工作台「类型」列 join/match | **混淆** | join=**加入**ckb/joinmatch=**匹配**ckb/match留资为 lead见 `开发文档/6、后端/存客宝-工作台线索类型与中英文说明.md` |
| **获客列表「计划Key」与重推 URL 里 `apiKey` 不一致** | 界面是人物/快照兜底后的展示,重推曾误用全局 `CKB_LEAD_API_KEY`报「无效的apiKey」 | 后端已统一:`resolveLeadPlanAPIKeyForRetry` 同时用于 `mode=contact` 的 `planApiKey` 与 `POST .../ckb-leads/retry` 内部推送;验收时对比**同一条 id** 下列 key 与(临时)服务端日志/抓包是否同源 |
| 控制台中文乱码 | 难以读输出 | Windows 可先 `chcp 65001` 或设 `PYTHONIOENCODING=utf-8` |
---

View File

@@ -182,7 +182,7 @@ export function FindPartnerPage() {
</Button>
</header>
<div className="flex-1 min-h-0 overflow-hidden px-4 pb-4 pt-3">
<div className="flex flex-1 min-h-0 flex-col overflow-hidden px-4 pb-4 pt-3">
<CKBConfigPanel fullBleed />
</div>
</div>

View File

@@ -386,11 +386,11 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
<Card
className={
fullBleed
? 'bg-[#0f2137] border-orange-500/30 mb-0 flex flex-col min-h-0 flex-1 rounded-none border-x-0 border-b-0 shadow-none'
? 'bg-[#0f2137] border-orange-500/30 mb-0 flex h-full min-h-0 flex-1 flex-col rounded-none border-x-0 border-b-0 shadow-none'
: 'bg-[#0f2137] border-orange-500/30 mb-6'
}
>
<CardContent className={`p-5 flex flex-col min-h-0 ${fullBleed ? 'flex-1 overflow-hidden' : ''}`}>
<CardContent className={`p-5 flex flex-col min-h-0 ${fullBleed ? 'h-full flex-1 overflow-hidden' : ''}`}>
<div className="flex items-center justify-between mb-4 shrink-0">
<div className="flex items-center gap-3">
<h3 className="text-white font-semibold"></h3>
@@ -444,7 +444,7 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
<div className={`min-h-0 flex flex-col ${fullBleed ? 'flex-1 overflow-hidden' : ''}`}>
{activeTab === 'overview' && (
<div className="space-y-6 overflow-auto">
<div className={`space-y-6 overflow-y-auto ${fullBleed ? 'min-h-0 flex-1' : ''}`}>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
<p className="text-gray-400 text-xs mb-2">/</p>
@@ -631,8 +631,8 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
)}
{activeTab === 'config' && (
<div className="space-y-4 overflow-auto flex-1 min-h-0 pb-4">
<div className="flex flex-wrap items-end gap-2">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 flex-wrap items-end gap-2 pb-3">
<div className="flex-1 min-w-[200px] max-w-md">
<Label className="text-gray-500 text-xs"></Label>
<Input
@@ -662,6 +662,7 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
</Button>
<span className="text-xs text-gray-500">{visibleRouteDefs.length} / {routeDefs.length} </span>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overflow-x-hidden pr-1 pb-2">
{visibleRouteDefs.map((item) => (
<div key={item.key} className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
@@ -720,6 +721,7 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
</div>
</div>
))}
</div>
</div>
)}

View File

@@ -287,7 +287,6 @@ func pushLeadToCKB(name, phone, wechatId, leadKey string) (ckbLeadPushResult, er
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
fmt.Printf("[存客宝 scenarios] 请求 URLGET: %s\n", reqURL)
resp, err := http.Get(reqURL)
if err != nil {
return ckbLeadPushResult{}, err
@@ -328,7 +327,8 @@ func resolvePersonForLead(db *gorm.DB, targetUserID string) (model.Person, bool)
return model.Person{}, false
}
// resolveLeadPlanAPIKeyForRetry GET /api/db/ckb-leads?mode=contact planApiKey 展示顺序一致:
// resolveLeadPlanAPIKeyForRetry 解析本条线索应采信的存客宝计划 apiKeyGET /api/db/ckb-leads mode=contact planApiKey
// 以及 retryOneLeadRecord 重推 pushLeadToCKB 共用本函数,禁止两处手写两套顺序):
// 1) ckb_lead_records.plan_api_key
// 2) action/source 默认join/match → 内置 ckbAPIKeylead+index_link_button → getCkbLeadApiKey
// 3) 仍为空:按 target_person_id 或 params.targetUserId 查人物 persons.ckb_api_key
@@ -571,8 +571,10 @@ func CKBJoin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
return
}
// 资源对接:真实用户须已购章节;管理端存客宝工作台「接口测试」固定 userId=admin_test仅测连通性不做购课校验
if body.Type == "investor" && body.UserID != "" {
if !userHasContentPurchase(database.DB(), body.UserID) {
uid := strings.TrimSpace(body.UserID)
if uid != "admin_test" && !userHasContentPurchase(database.DB(), body.UserID) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "请先购买任意章节后再申请资源对接",

View File

@@ -11,6 +11,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -38,12 +39,30 @@ func getCkbOpenConfig() (apiKey, account string) {
return
}
// ckbOpenGetToken 获取开放 API JWT
var (
ckbOpenTokenMu sync.Mutex
ckbOpenCachedToken string
ckbOpenCachedExpiry time.Time
)
// ckbOpenTokenCacheTTL 开放 API JWT 内存缓存时长(避免 GET /api/db/persons 等高频路径对存客宝重复 /v1/open/auth/token
const ckbOpenTokenCacheTTL = 45 * time.Minute
// ckbOpenGetToken 获取开放 API JWT带进程内短期缓存
func ckbOpenGetToken() (string, error) {
apiKey, account := getCkbOpenConfig()
if apiKey == "" || account == "" {
return "", fmt.Errorf("无效的apiKeyCKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试")
}
ckbOpenTokenMu.Lock()
if ckbOpenCachedToken != "" && time.Now().Before(ckbOpenCachedExpiry) {
tok := ckbOpenCachedToken
ckbOpenTokenMu.Unlock()
return tok, nil
}
ckbOpenTokenMu.Unlock()
ts := time.Now().Unix()
sign := ckbOpenSign(account, ts, apiKey)
authBody := map[string]interface{}{
@@ -74,7 +93,13 @@ func ckbOpenGetToken() (string, error) {
}
return "", fmt.Errorf("%s", msg)
}
return authResult.Data.Token, nil
ckbOpenTokenMu.Lock()
ckbOpenCachedToken = authResult.Data.Token
ckbOpenCachedExpiry = time.Now().Add(ckbOpenTokenCacheTTL)
out := ckbOpenCachedToken
ckbOpenTokenMu.Unlock()
return out, nil
}
// ckbOpenCreatePlan 调用 /v1/plan/create 创建获客计划,返回 planId、存客宝原始 data、以及完整响应失败时便于排查

View File

@@ -1,10 +1,8 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@@ -151,31 +149,18 @@ func DBCKBLeadList(c *gin.Context) {
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
// 兜底:历史数据 plan_api_key 可能为空(已迁移但仍有漏网),按 action 回填展示,不改库
planKey := strings.TrimSpace(r.PlanAPIKey)
if planKey == "" {
if strings.TrimSpace(r.Action) == "join" || strings.TrimSpace(r.Action) == "match" {
planKey = ckbAPIKey
} else if strings.TrimSpace(r.Action) == "lead" && r.Source == "index_link_button" {
planKey = getCkbLeadApiKey()
}
}
// 与重推 retryOneLeadRecord 共用 resolveLeadPlanAPIKeyForRetry避免列表「计划Key」与实发 apiKey 再分叉
var paramsMap map[string]interface{}
_ = json.Unmarshal([]byte(r.Params), &paramsMap)
planKey := resolveLeadPlanAPIKeyForRetry(db, r, paramsMap)
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.CkbPlanID
// 兜底:迁移后计划 key 多写在 persons.ckb_api_key但 lead_records.plan_api_key 可能仍为空
// 此处仅用于管理端回显(前端会做掩码展示),不改库
if planKey == "" && strings.TrimSpace(p.CkbApiKey) != "" {
planKey = strings.TrimSpace(p.CkbApiKey)
}
} else if strings.TrimSpace(r.TargetPersonID) == "" && r.Source == "index_link_button" && indexLinkFallback != nil {
personName = indexLinkFallback.Name
ckbPlanId = indexLinkFallback.CkbPlanID
if planKey == "" && strings.TrimSpace(indexLinkFallback.CkbApiKey) != "" {
planKey = strings.TrimSpace(indexLinkFallback.CkbApiKey)
}
}
displayNick := r.Nickname
userAvatar := ""
@@ -315,48 +300,13 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
func logCkbLeadRetryResponse(resp gin.H) {
b, err := json.MarshalIndent(resp, "", " ")
if err != nil {
fmt.Printf("[DBCKBLeadRetry] response(marshal err=%v): %+v\n", err, resp)
return
}
fmt.Printf("[DBCKBLeadRetry] response JSON:\n%s\n", string(b))
}
func logCkbLeadRetryRequest(raw []byte, readErr error) {
if readErr != nil {
fmt.Printf("[DBCKBLeadRetry] request body read error: %v\n", readErr)
return
}
s := strings.TrimSpace(string(raw))
if s == "" {
fmt.Printf("[DBCKBLeadRetry] request body: (empty)\n")
return
}
if json.Valid([]byte(s)) {
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(s), "", " "); err == nil {
fmt.Printf("[DBCKBLeadRetry] request JSON:\n%s\n", buf.String())
return
}
}
fmt.Printf("[DBCKBLeadRetry] request body: %s\n", s)
}
// DBCKBLeadRetry POST /api/db/ckb-leads/retry 管理端-手动重推单条失败线索
func DBCKBLeadRetry(c *gin.Context) {
raw, readErr := io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
logCkbLeadRetryRequest(raw, readErr)
var body struct {
ID int64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
resp := gin.H{"success": false, "error": "缺少有效 id"}
logCkbLeadRetryResponse(resp)
c.JSON(http.StatusOK, resp)
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
return
}
ok, err := RetryCkbLeadByID(c.Request.Context(), body.ID)
@@ -365,20 +315,16 @@ func DBCKBLeadRetry(c *gin.Context) {
if msg == "" {
msg = "重推失败"
}
resp := gin.H{"success": false, "error": msg}
logCkbLeadRetryResponse(resp)
c.JSON(http.StatusOK, resp)
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
return
}
db := database.DB()
var r model.CkbLeadRecord
if err := db.Where("id = ?", body.ID).First(&r).Error; err != nil {
resp := gin.H{"success": true, "pushed": ok}
logCkbLeadRetryResponse(resp)
c.JSON(http.StatusOK, resp)
c.JSON(http.StatusOK, gin.H{"success": true, "pushed": ok})
return
}
resp := gin.H{
c.JSON(http.StatusOK, gin.H{
"success": true,
"pushed": ok,
"record": gin.H{
@@ -392,9 +338,7 @@ func DBCKBLeadRetry(c *gin.Context) {
"lastPushAt": r.LastPushAt,
"nextRetryAt": r.NextRetryAt,
},
}
logCkbLeadRetryResponse(resp)
c.JSON(http.StatusOK, resp)
})
}
// DBCKBLeadDelete POST /api/db/ckb-leads/delete 管理端-删除一条留资记录(运营清理误报/测试数据)

View File

@@ -129,6 +129,15 @@ func syncMissingSuperIndividualsAsPersons(db *gorm.DB) {
for _, b := range bound {
boundSet[strings.TrimSpace(b)] = struct{}{}
}
// 未配置「超级个体统一获客计划」时,创建 Person 走 createPersonMinimal必须先过存客宝开放鉴权。
// 鉴权失败若仍逐用户 ensurePersonForUser会对每个超级个体各打一条相同错误刷屏且拖慢 /api/db/persons。
if _, sharedOk := loadSuperIndividualSharedPlanConfig(db); !sharedOk {
if _, err := ckbOpenGetToken(); err != nil {
log.Printf("[persons] sync super-individual skipped (need CKB open API or shared plan): %v — configure CKB_OPEN_API_KEY + CKB_OPEN_ACCOUNT, or system_config key %s",
err, SuperIndividualSharedPlanConfigKey)
return
}
}
any := false
for _, uid := range ids {
uid = strings.TrimSpace(uid)