diff --git a/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝工作台资源对接接口测试.md b/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝工作台资源对接接口测试.md new file mode 100644 index 00000000..90f8173e --- /dev/null +++ b/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝工作台资源对接接口测试.md @@ -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` 代理测试接口。 diff --git a/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝计划Key列表与重推一致.md b/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝计划Key列表与重推一致.md new file mode 100644 index 00000000..583fb699 --- /dev/null +++ b/.cursor/agent/后端工程师/evolution/2026-05-08-存客宝计划Key列表与重推一致.md @@ -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`(Gotchas:planKey 与重推一致性) +- `.cursor/skills/api-dev/SKILL.md`(Gotchas:展示与下游请求字段同源) diff --git a/.cursor/agent/后端工程师/evolution/索引.md b/.cursor/agent/后端工程师/evolution/索引.md index 256200ed..d376573c 100644 --- a/.cursor/agent/后端工程师/evolution/索引.md +++ b/.cursor/agent/后端工程师/evolution/索引.md @@ -12,3 +12,5 @@ | 2026-03-24 | router 缺失四 handler:BookRanking、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) | diff --git a/.cursor/agent/智能体-技能-经验总索引.md b/.cursor/agent/智能体-技能-经验总索引.md index ed235fb4..b35b8802 100644 --- a/.cursor/agent/智能体-技能-经验总索引.md +++ b/.cursor/agent/智能体-技能-经验总索引.md @@ -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-24:router 缺失四 handler 补齐(BookRanking 等) diff --git a/.cursor/agent/软件测试/evolution/索引.md b/.cursor/agent/软件测试/evolution/索引.md index 0ce120e9..94a28983 100644 --- a/.cursor/agent/软件测试/evolution/索引.md +++ b/.cursor/agent/软件测试/evolution/索引.md @@ -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) | diff --git a/.cursor/skills/api-dev/SKILL.md b/.cursor/skills/api-dev/SKILL.md index 1d0b9d08..dee2f43c 100644 --- a/.cursor/skills/api-dev/SKILL.md +++ b/.cursor/skills/api-dev/SKILL.md @@ -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`,禁止列表与重推各写一套顺序 | --- diff --git a/.cursor/skills/ckb-leads-api-test/SKILL.md b/.cursor/skills/ckb-leads-api-test/SKILL.md index d49acea3..2ec7437f 100644 --- a/.cursor/skills/ckb-leads-api-test/SKILL.md +++ b/.cursor/skills/ckb-leads-api-test/SKILL.md @@ -74,6 +74,7 @@ description: JWT 驱动的存客宝获客列表 API 回归。用户粘贴管理 | 把 JWT 写进会议纪要/需求文档 | **账号泄露** | 仅口头/私聊粘贴;复盘里写「已用临时 token 测过」不写原文 | | 脚本失败就认为一定是小程序 Bug | **误判** | 先区分「接口未返回字段」vs「库里真的无手机号」——前者是后端/部署,后者才可能追 C 端 | | 工作台「类型」列 join/match | **混淆** | join=**加入**(ckb/join),match=**匹配**(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` | --- diff --git a/soul-admin/src/pages/find-partner/FindPartnerPage.tsx b/soul-admin/src/pages/find-partner/FindPartnerPage.tsx index 064cf18e..fd3ae527 100644 --- a/soul-admin/src/pages/find-partner/FindPartnerPage.tsx +++ b/soul-admin/src/pages/find-partner/FindPartnerPage.tsx @@ -182,7 +182,7 @@ export function FindPartnerPage() { 关闭 -
+
diff --git a/soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx b/soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx index 507946ec..2c500ac8 100644 --- a/soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx +++ b/soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx @@ -386,11 +386,11 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial - +

存客宝工作台

@@ -444,7 +444,7 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
{activeTab === 'overview' && ( -
+

加入/匹配提交

@@ -631,8 +631,8 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial )} {activeTab === 'config' && ( -
-
+
+
{visibleRouteDefs.length} / {routeDefs.length} 项
+
{visibleRouteDefs.map((item) => (
@@ -720,6 +721,7 @@ export function CKBConfigPanel({ initialTab = 'overview', fullBleed }: { initial
))} +
)} diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go index 6e3752e3..deb940c3 100644 --- a/soul-api/internal/handler/ckb.go +++ b/soul-api/internal/handler/ckb.go @@ -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] 请求 URL(GET): %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 解析本条线索应采信的存客宝计划 apiKey(GET /api/db/ckb-leads mode=contact 的 planApiKey、 +// 以及 retryOneLeadRecord 重推 pushLeadToCKB 共用本函数,禁止两处手写两套顺序): // 1) ckb_lead_records.plan_api_key // 2) action/source 默认(join/match → 内置 ckbAPIKey;lead+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": "请先购买任意章节后再申请资源对接", diff --git a/soul-api/internal/handler/ckb_open.go b/soul-api/internal/handler/ckb_open.go index 7e46bbea..3b643929 100644 --- a/soul-api/internal/handler/ckb_open.go +++ b/soul-api/internal/handler/ckb_open.go @@ -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("无效的apiKey:CKB_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、以及完整响应(失败时便于排查) diff --git a/soul-api/internal/handler/db_ckb_leads.go b/soul-api/internal/handler/db_ckb_leads.go index b71d934e..88707b43 100644 --- a/soul-api/internal/handler/db_ckb_leads.go +++ b/soul-api/internal/handler/db_ckb_leads.go @@ -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), ¶msMap) + 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 管理端-删除一条留资记录(运营清理误报/测试数据) diff --git a/soul-api/internal/handler/db_person.go b/soul-api/internal/handler/db_person.go index 11a23cfc..1ef19333 100644 --- a/soul-api/internal/handler/db_person.go +++ b/soul-api/internal/handler/db_person.go @@ -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)