diff --git a/miniprogram/app.js b/miniprogram/app.js index 1abc1ba6..a641870e 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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 diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 0d12adec..a5719e34 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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' }) diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 21080139..a2c34075 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -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' }) diff --git a/soul-api/.env b/soul-api/.env index d002dfd1..80ea55dd 100644 --- a/soul-api/.env +++ b/soul-api/.env @@ -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 diff --git a/soul-api/.env.development b/soul-api/.env.development index 46b1ccf9..1e4812a3 100644 --- a/soul-api/.env.development +++ b/soul-api/.env.development @@ -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 diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go index a1554883..26414139 100644 --- a/soul-api/internal/config/config.go +++ b/soul-api/internal/config/config.go @@ -43,6 +43,9 @@ type Config struct { // 订单对账定时任务间隔(分钟),0 表示不启动内置定时任务 SyncOrdersIntervalMinutes int + + // 存客宝-链接卡若:可选,用于「添加好友」的 apiKey(与 GET scenarios 示例一致时可填 CKB_LEAD_API_KEY) + CkbLeadAPIKey string } // BaseURLJoin 将路径拼接到 BaseURL,path 应以 / 开头 @@ -253,5 +256,6 @@ func Load() (*Config, error) { AdminPassword: adminPassword, AdminSessionSecret: adminSessionSecret, SyncOrdersIntervalMinutes: syncOrdersInterval, + CkbLeadAPIKey: strings.TrimSpace(os.Getenv("CKB_LEAD_API_KEY")), }, nil } diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go index 0dfa7bdb..5076a1c9 100644 --- a/soul-api/internal/database/database.go +++ b/soul-api/internal/database/database.go @@ -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 } diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go index 2fff6778..87dbe2e5 100644 --- a/soul-api/internal/handler/ckb.go +++ b/soul-api/internal/handler/ckb.go @@ -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}) } diff --git a/soul-api/internal/model/ckb_lead.go b/soul-api/internal/model/ckb_lead.go new file mode 100644 index 00000000..58e5cdb4 --- /dev/null +++ b/soul-api/internal/model/ckb_lead.go @@ -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" } diff --git a/soul-api/internal/model/ckb_submit.go b/soul-api/internal/model/ckb_submit.go new file mode 100644 index 00000000..3c9b8e42 --- /dev/null +++ b/soul-api/internal/model/ckb_submit.go @@ -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" } diff --git a/开发文档/8、部署/存客宝API-Key约定.md b/开发文档/8、部署/存客宝API-Key约定.md new file mode 100644 index 00000000..030d68a6 --- /dev/null +++ b/开发文档/8、部署/存客宝API-Key约定.md @@ -0,0 +1,28 @@ +# 存客宝 API Key 约定 + +## 约定说明 + +存客宝(ckbapi.quwanzhi.com)不同业务使用**不同的 apiKey**,对接时需按场景选用,避免混用。 + +| 场景 | 用途 | Key 来源 | 说明 | +|------|------|----------|------| +| **链接卡若** | 首页「链接卡若」留资,添加卡若为好友 | 环境变量 `CKB_LEAD_API_KEY` | 需在 .env 中配置;未配置时回退为下方「其他场景」的 key;请求方式为 **POST** + JSON(name, 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。