Files
soul-yongping/soul-api/internal/handler/admin_shensheshou.go
卡若 5724fba877 feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调
- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
2026-03-22 08:34:28 +08:00

339 lines
8.4 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
var sssHTTPClient = &http.Client{Timeout: 15 * time.Second}
func sssBaseURL() string {
u := os.Getenv("SHENSHESHOU_BASE_URL")
if u == "" {
u = "https://your-domain.com/api/shensheshou"
}
return strings.TrimRight(u, "/")
}
func sssAPIKey() string {
return os.Getenv("SHENSHESHOU_API_KEY")
}
func sssDoRequest(method, url string, body interface{}) (map[string]interface{}, error) {
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+sssAPIKey())
req.Header.Set("Content-Type", "application/json")
resp, err := sssHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("响应解析失败: %v", err)
}
return result, nil
}
// AdminShensheShouQuery GET /api/admin/shensheshou/query?phone=xxx&openId=xxx
func AdminShensheShouQuery(c *gin.Context) {
if sssAPIKey() == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "神射手 API Key 未配置,请在 .env 中设置 SHENSHESHOU_API_KEY"})
return
}
phone := strings.TrimSpace(c.Query("phone"))
openID := strings.TrimSpace(c.Query("openId"))
if phone == "" && openID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要 phone 或 openId 参数"})
return
}
queryURL := sssBaseURL() + "?endpoint=user"
if phone != "" {
queryURL += "&phone=" + phone
}
result, err := sssDoRequest(http.MethodGet, queryURL, nil)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if success, ok := result["success"].(bool); ok && !success {
errMsg := "查询失败"
if e, ok := result["error"].(string); ok {
errMsg = e
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": errMsg})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result["data"]})
}
// AdminShensheShouIngest POST /api/admin/shensheshou/ingest — 推送用户数据到神射手
func AdminShensheShouIngest(c *gin.Context) {
if sssAPIKey() == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "神射手 API Key 未配置"})
return
}
var body struct {
Users []map[string]interface{} `json:"users" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
payload := map[string]interface{}{
"endpoint": "ingest",
"source": "cunkebao",
"users": body.Users,
}
result, err := sssDoRequest(http.MethodPost, sssBaseURL(), payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if success, ok := result["success"].(bool); ok && !success {
errMsg := "推送失败"
if e, ok := result["error"].(string); ok {
errMsg = e
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": errMsg})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result["data"]})
}
// AdminShensheShouEnrich POST /api/admin/shensheshou/enrich — 查询并回填用户基础信息
func AdminShensheShouEnrich(c *gin.Context) {
if sssAPIKey() == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "神射手 API Key 未配置"})
return
}
var body struct {
UserID string `json:"userId" binding:"required"`
Phone string `json:"phone"`
OpenID string `json:"openId"`
WechatID string `json:"wechatId"`
Address string `json:"address"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
if body.Phone == "" && body.OpenID == "" && body.WechatID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "至少提供手机号、OpenID 或微信号之一"})
return
}
// 查询神射手
queryURL := sssBaseURL() + "?endpoint=user"
if body.Phone != "" {
queryURL += "&phone=" + body.Phone
}
sssResult, err := sssDoRequest(http.MethodGet, queryURL, nil)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
var sssData map[string]interface{}
if d, ok := sssResult["data"].(map[string]interface{}); ok {
sssData = d
}
// 将神射手数据回填到用户 tags
if sssData != nil {
db := database.DB()
updates := make(map[string]interface{})
// 自动合并标签
if tags, ok := sssData["tags"].([]interface{}); ok && len(tags) > 0 {
tagStrs := make([]string, 0, len(tags))
for _, t := range tags {
if s, ok := t.(string); ok {
tagStrs = append(tagStrs, s)
}
}
if len(tagStrs) > 0 {
tagsJSON, _ := json.Marshal(tagStrs)
updates["ckb_tags"] = string(tagsJSON)
}
}
if len(updates) > 0 {
db.Table("users").Where("id = ?", body.UserID).Updates(updates)
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": sssData})
}
// AdminShensheShouAutoTag POST /api/admin/shensheshou/auto-tag — 基于行为自动打标(内部调用)
func AdminShensheShouAutoTag(userID string, phone string) {
if sssAPIKey() == "" || phone == "" {
return
}
go func() {
queryURL := sssBaseURL() + "?endpoint=user&phone=" + phone
result, err := sssDoRequest(http.MethodGet, queryURL, nil)
if err != nil || result == nil {
return
}
data, ok := result["data"].(map[string]interface{})
if !ok {
return
}
tags, ok := data["tags"].([]interface{})
if !ok || len(tags) == 0 {
return
}
tagStrs := make([]string, 0, len(tags))
for _, t := range tags {
if s, ok := t.(string); ok {
tagStrs = append(tagStrs, s)
}
}
tagsJSON, _ := json.Marshal(tagStrs)
database.DB().Table("users").Where("id = ?", userID).
Updates(map[string]interface{}{"ckb_tags": string(tagsJSON)})
}()
}
// AdminShensheShouBatchEnrich POST /api/admin/shensheshou/batch-enrich — 批量补全用户资料
func AdminShensheShouBatchEnrich(c *gin.Context) {
db := database.DB()
var body struct {
UserIDs []string `json:"userIds"`
Limit int `json:"limit"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
limit := body.Limit
if limit <= 0 || limit > 50 {
limit = 20
}
var users []model.User
q := db.Where("phone IS NOT NULL AND phone != ''")
if len(body.UserIDs) > 0 {
q = q.Where("id IN ?", body.UserIDs)
}
if err := q.Limit(limit).Find(&users).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
enriched := 0
skipped := 0
for _, u := range users {
if u.Phone == nil || *u.Phone == "" {
skipped++
continue
}
if sssAPIKey() != "" {
AdminShensheShouAutoTag(u.ID, *u.Phone)
enriched++
} else {
skipped++
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"enriched": enriched,
"skipped": skipped,
"total": len(users),
})
}
// AdminShensheShouBatchQuery POST /api/admin/shensheshou/batch — 批量查询
func AdminShensheShouBatchQuery(c *gin.Context) {
if sssAPIKey() == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "神射手 API Key 未配置"})
return
}
var body struct {
Phones []string `json:"phones"`
Fields []string `json:"fields"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
if len(body.Phones) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "phones 不能为空"})
return
}
if len(body.Phones) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "单次最多 100 个"})
return
}
fields := body.Fields
if len(fields) == 0 {
fields = []string{"rfm_score", "user_level", "tags"}
}
payload := map[string]interface{}{
"endpoint": "users/batch",
"phones": body.Phones,
"fields": fields,
}
result, err := sssDoRequest(http.MethodPost, sssBaseURL(), payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result["data"]})
}