更新小程序配置,切换API基础地址至本地开发环境。优化用户提交联系方式的逻辑,增加2分钟内限频提示,确保用户体验流畅。调整后端存客宝接口,支持记录用户提交信息并处理频率限制。

This commit is contained in:
Alex-larget
2026-03-07 21:30:40 +08:00
parent 6852004303
commit 73ecead336
11 changed files with 213 additions and 25 deletions

View File

@@ -8,9 +8,9 @@ const { parseScene } = require('./utils/scene.js')
App({
globalData: {
// API基础地址 - 连接真实后端
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:8080',
baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID

View File

@@ -320,7 +320,12 @@ Page({
return
}
const userId = app.globalData.userInfo.id
const leadKey = 'karuo_lead_' + userId
// 2 分钟内只能点一次(与后端限频一致)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
@@ -333,11 +338,6 @@ Page({
} catch (e) {}
}
if (phone || wechatId) {
const hasLead = wx.getStorageSync(leadKey)
if (hasLead) {
wx.showToast({ title: '已提交联系方式,卡若会尽快联系你', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
@@ -352,7 +352,7 @@ Page({
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync(leadKey, true)
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
@@ -384,9 +384,14 @@ Page({
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
// 2 分钟内只能点一次(与后端限频一致)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
const leadKey = userId ? ('karuo_lead_' + userId) : ''
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
@@ -401,7 +406,7 @@ Page({
wx.hideLoading()
this.setData({ showLeadModal: false, leadPhone: '' })
if (res && res.success) {
if (leadKey) wx.setStorageSync(leadKey, true)
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,卡若会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })

View File

@@ -551,9 +551,10 @@ Page({
})
return
}
const leadKey = `mention_lead_${myUserId}_${targetUserId}`
if (wx.getStorageSync(leadKey)) {
wx.showToast({ title: '已提交过,对方会尽快联系您', icon: 'none' })
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
@@ -573,7 +574,7 @@ Page({
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync(leadKey, true)
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })

View File

@@ -42,3 +42,6 @@ WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey与 join/match 不同)
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl

View File

@@ -44,3 +44,6 @@ WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
# 存客宝-链接卡若:请求到存客宝添加好友使用的 apiKey与 join/match 不同)
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl

View File

@@ -43,6 +43,9 @@ type Config struct {
// 订单对账定时任务间隔分钟0 表示不启动内置定时任务
SyncOrdersIntervalMinutes int
// 存客宝-链接卡若:可选,用于「添加好友」的 apiKey与 GET scenarios 示例一致时可填 CKB_LEAD_API_KEY
CkbLeadAPIKey string
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -253,5 +256,6 @@ func Load() (*Config, error) {
AdminPassword: adminPassword,
AdminSessionSecret: adminSessionSecret,
SyncOrdersIntervalMinutes: syncOrdersInterval,
CkbLeadAPIKey: strings.TrimSpace(os.Getenv("CKB_LEAD_API_KEY")),
}, nil
}

View File

@@ -48,6 +48,12 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
log.Printf("database: admin_users migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.CkbSubmitRecord{}); err != nil {
log.Printf("database: ckb_submit_records migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
log.Printf("database: ckb_lead_records migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -15,16 +16,31 @@ import (
"github.com/gin-gonic/gin"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
)
// 存客宝 API Key 约定(详见 开发文档/8、部署/存客宝API-Key约定.md
// - 链接卡若(添加卡若好友):使用 CKB_LEAD_API_KEY.env 配置),未配则用下方 ckbAPIKey
// - 其他场景join/match 等):使用 ckbAPIKey
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
// ckbSubmitSave 加好友/留资类接口统一落库:记录 action、userId、昵称、用户提交的传参写入 ckb_submit_records
func ckbSubmitSave(action, userID, nickname string, params interface{}) {
paramsJSON, _ := json.Marshal(params)
_ = database.DB().Create(&model.CkbSubmitRecord{
Action: action,
UserID: userID,
Nickname: nickname,
Params: string(paramsJSON),
}).Error
}
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait空值跳过按键升序拼接值MD5(拼接串) 再 MD5(结果+apiKey)
func ckbSign(params map[string]interface{}, apiKey string) string {
keys := make([]string, 0, len(params))
@@ -85,6 +101,20 @@ func CKBJoin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
return
}
nickname := strings.TrimSpace(body.Name)
if nickname == "" && body.UserID != "" {
var u model.User
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
nickname = *u.Nickname
}
}
if nickname == "" {
nickname = "-"
}
ckbSubmitSave("join", body.UserID, nickname, map[string]interface{}{
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
})
ts := time.Now().Unix()
params := map[string]interface{}{
"timestamp": ts,
@@ -195,6 +225,14 @@ func CKBMatch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
nickname := strings.TrimSpace(body.Nickname)
if nickname == "" {
nickname = "-"
}
ckbSubmitSave("match", body.UserID, nickname, map[string]interface{}{
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
})
ts := time.Now().Unix()
label := ckbSourceMap[body.MatchType]
if label == "" {
@@ -279,12 +317,58 @@ func CKBLead(c *gin.Context) {
if name == "" {
name = "小程序用户"
}
db := database.DB()
var cond []string
var args []interface{}
if body.UserID != "" {
cond = append(cond, "user_id = ?")
args = append(args, body.UserID)
}
if phone != "" {
cond = append(cond, "phone = ?")
args = append(args, phone)
}
if wechatId != "" {
cond = append(cond, "wechat_id = ?")
args = append(args, wechatId)
}
// 2 分钟内同一用户/手机/微信只能提交一次(与前端限频一致)
if len(cond) > 0 {
cutoff := time.Now().Add(-2 * time.Minute)
var recentCount int64
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁请2分钟后再试"})
return
}
}
// 是否曾留资过(仅用于成功后的提示文案)
repeatedSubmit := false
if len(cond) > 0 {
var existCount int64
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Params: string(paramsJSON),
}).Error
ts := time.Now().Unix()
// 链接卡若GET + query便于浏览器测试传参name, phone, wechatId, apiKey, timestamp, sign
leadKey := ckbAPIKey
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
leadKey = cfg.CkbLeadAPIKey
}
params := map[string]interface{}{
"timestamp": ts,
"source": "小程序-链接卡若",
"remark": "首页点击「链接卡若」留资",
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
if phone != "" {
params["phone"] = phone
@@ -292,19 +376,30 @@ func CKBLead(c *gin.Context) {
if wechatId != "" {
params["wechatId"] = wechatId
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
if phone != "" {
q.Set("phone", phone)
}
if wechatId != "" {
q.Set("wechatId", wechatId)
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
_ = json.Unmarshal(b, &result)
@@ -313,12 +408,23 @@ func CKBLead(c *gin.Context) {
if result.Message == "已存在" {
msg = "您已留资,我们会尽快联系您"
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
if repeatedSubmit {
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
}
data := gin.H{}
if result.Data != nil {
if m, ok := result.Data.(map[string]interface{}); ok {
data = m
}
}
data["repeatedSubmit"] = repeatedSubmit
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
errMsg := result.Message
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
}

View File

@@ -0,0 +1,17 @@
package model
import "time"
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
type CkbLeadRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (CkbLeadRecord) TableName() string { return "ckb_lead_records" }

View File

@@ -0,0 +1,15 @@
package model
import "time"
// CkbSubmitRecord 加好友/留资类接口提交记录(存客宝 lead/join/match
type CkbSubmitRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Action string `gorm:"column:action;size:20;not null" json:"action"` // lead | join | match
UserID string `gorm:"column:user_id;size:50;index" json:"userId"` // 用户 id
Nickname string `gorm:"column:nickname;size:100" json:"nickname"` // 昵称
Params string `gorm:"column:params;type:json;not null" json:"params"` // 用户提交的传参 JSON
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (CkbSubmitRecord) TableName() string { return "ckb_submit_records" }

View File

@@ -0,0 +1,28 @@
# 存客宝 API Key 约定
## 约定说明
存客宝ckbapi.quwanzhi.com不同业务使用**不同的 apiKey**,对接时需按场景选用,避免混用。
| 场景 | 用途 | Key 来源 | 说明 |
|------|------|----------|------|
| **链接卡若** | 首页「链接卡若」留资,添加卡若为好友 | 环境变量 `CKB_LEAD_API_KEY` | 需在 .env 中配置;未配置时回退为下方「其他场景」的 key请求方式为 **POST** + JSONname, phone, wechatId, apiKey, timestamp, sign |
| **其他** | join团队/资源/导师/合伙、match找伙伴匹配等 | 代码常量 `ckbAPIKey` | 当前为 `fyngh-ecy9h-qkdae-epwd5-rz6kd` |
## 配置示例
- **链接卡若**(添加好友需用专用 key示例
```env
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
```
- 后续若有其他「添加某某为好友」类场景,由存客宝提供对应 key再在配置或代码中单独挂接**不要与链接卡若的 key 混用**。
## 代码位置
- soul-api`internal/handler/ckb.go`
- 链接卡若:`CKBLead` 中读取 `config.Get().CkbLeadAPIKey`,有则用,无则用 `ckbAPIKey`
- join/match统一使用 `ckbAPIKey`
---
记录时间2025-03原因链接卡若需专用 key 才能正常添加好友,其他场景用另一 key。