存客宝应用接口初始化

This commit is contained in:
Ghost
2026-01-05 10:25:32 +08:00
parent fa37227954
commit 02797ba50a
414 changed files with 85530 additions and 75 deletions

0
public/.htaccess Normal file
View File

8
public/admin/.htaccess Normal file
View File

@@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^admin/(.*)$ /admin/index.html/$1 [QSA,PT,L]
</IfModule>

76
public/api_test.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
// API测试入口文件
// 检查URL参数
$url = isset($_GET['url']) ? $_GET['url'] : '';
$method = isset($_GET['method']) ? strtoupper($_GET['method']) : 'GET';
$data = isset($_GET['data']) ? $_GET['data'] : '{}';
// 将JSON字符串转为PHP数组
$jsonData = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$jsonData = [];
}
// 获取token参数
$token = isset($_GET['token']) ? $_GET['token'] : '';
echo '<h1>API测试工具</h1>';
echo '<form method="get">';
echo '<p>API路径: <input type="text" name="url" value="' . htmlspecialchars($url) . '" style="width:300px" placeholder="例如: api/auth/login"/></p>';
echo '<p>请求方法: <select name="method">
<option value="GET"' . ($method == 'GET' ? ' selected' : '') . '>GET</option>
<option value="POST"' . ($method == 'POST' ? ' selected' : '') . '>POST</option>
</select></p>';
echo '<p>请求数据 (JSON): <textarea name="data" style="width:400px;height:100px">' . htmlspecialchars($data) . '</textarea></p>';
echo '<p>Authorization Token: <input type="text" name="token" value="' . htmlspecialchars($token) . '" style="width:400px" placeholder="Bearer token..."/></p>';
echo '<p><input type="submit" value="发送请求"/></p>';
echo '</form>';
// 如果有URL参数发送API请求
if (!empty($url)) {
// 构建完整URL
$fullUrl = 'http://' . $_SERVER['HTTP_HOST'] . '/' . $url;
// 初始化cURL
$ch = curl_init($fullUrl);
// 设置cURL选项
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// 设置请求方法
if ($method == 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonData));
}
// 设置请求头
$headers = ['Content-Type: application/json'];
if (!empty($token)) {
$headers[] = 'Authorization: ' . $token;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// 执行请求
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// 检查是否有错误
if (curl_errno($ch)) {
echo '<h2>请求错误</h2>';
echo '<pre>' . htmlspecialchars(curl_error($ch)) . '</pre>';
} else {
echo '<h2>响应结果 (HTTP状态码: ' . $httpCode . ')</h2>';
echo '<pre>' . htmlspecialchars($result) . '</pre>';
// 尝试解析JSON
$jsonResult = json_decode($result, true);
if (json_last_error() === JSON_ERROR_NONE) {
echo '<h2>格式化JSON响应</h2>';
echo '<pre>' . htmlspecialchars(json_encode($jsonResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
}
}
// 关闭cURL资源
curl_close($ch);
}

413
public/doc/api_v1.md Normal file
View File

@@ -0,0 +1,413 @@
# 对外获客线索上报接口文档V1
## 一、接口概述
- **接口名称**:对外获客线索上报接口
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
- **接口协议**HTTP
- **请求方式**`POST`
- **请求地址** `http://ckbapi.quwanzhi.com/v1/api/scenarios`
> 具体 URL 以实际环境配置为准。
- **数据格式**
- 推荐:`application/json`
- 兼容:`application/x-www-form-urlencoded`
- **字符编码**`UTF-8`
---
## 二、鉴权与签名
### 2.1 必填鉴权字段
| 字段名 | 类型 | 必填 | 说明 |
|-------------|--------|------|---------------------------------------|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
| `sign` | string | 是 | 签名值 |
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
### 2.2 时间戳校验
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
- 通过条件:`|server_time - timestamp| <= 300`
- 超出范围则返回:`请求已过期`
### 2.3 签名生成规则
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
#### 第一步:移除特定字段
`params` 中移除以下字段:
- `sign` —— 自身不参与签名
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
#### 第二步:移除空值字段
从剩余参数中,移除值为:
- `null`
- 空字符串 `''`
的字段,这些字段不参与签名。
#### 第三步:按参数名升序排序
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
```text
例如: name, phone, source, timestamp
```
#### 第四步:拼接参数值
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
- 示例:
排序后参数为:
```text
name = 张三
phone = 13800000000
source = 微信广告
timestamp = 1710000000
```
则拼接:
```text
stringToSign = "张三13800000000微信广告1710000000"
```
#### 第五步:第一次 MD5
对上一步拼接得到的字符串做一次 MD5
\[
\text{firstMd5} = \text{MD5}(\text{stringToSign})
\]
#### 第六步:拼接 apiKey 再次 MD5
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5得到最终签名值
\[
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
\]
#### 第七步:放入请求
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
> 建议:
> - 使用小写 MD5 字符串(双方约定统一即可)。
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
### 2.4 签名示例PHP 伪代码)
```php
$params = [
'apiKey' => 'YOUR_API_KEY',
'timestamp' => '1710000000',
'phone' => '13800000000',
'name' => '张三',
'source' => '微信广告',
'remark' => '通过H5落地页留资',
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
// 'sign' => '待生成',
];
// 1. 去掉 sign、apiKey、portrait
unset($params['sign'], $params['apiKey'], $params['portrait']);
// 2. 去掉空值
$params = array_filter($params, function($value) {
return !is_null($value) && $value !== '';
});
// 3. 按键名升序排序
ksort($params);
// 4. 拼接参数值
$stringToSign = implode('', array_values($params));
// 5. 第一次 MD5
$firstMd5 = md5($stringToSign);
// 6. 第二次 MD5拼接 apiKey
$apiKey = 'YOUR_API_KEY';
$sign = md5($firstMd5 . $apiKey);
// 将 $sign 作为字段发送
$params['sign'] = $sign;
```
---
## 三、请求参数说明
### 3.1 主标识字段(至少传一个)
| 字段名 | 类型 | 必填 | 说明 |
|-----------|--------|------|-------------------------------------------|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
### 3.2 基础信息字段
| 字段名 | 类型 | 必填 | 说明 |
|------------|--------|------|-------------------------|
| `name` | string | 否 | 客户姓名 |
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
| `remark` | string | 否 | 备注信息 |
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
### 3.3 用户画像字段 `portrait`(可选)
`portrait` 为一个对象JSON用于记录用户的行为画像数据。
#### 3.3.1 基本示例
```json
"portrait": {
"type": 1,
"source": 1,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"productId": "P12345",
"pageUrl": "https://example.com/product/123"
},
"remark": "画像-基础属性",
"uniqueId": "user_13800000000_20250301_001"
}
```
#### 3.3.2 字段详细说明
| 字段名 | 类型 | 必填 | 说明 |
|-----------------------|--------|------|----------------------------------------|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值0 |
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值0 |
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对年龄、性别、城市、商品ID、页面URL等 |
| `portrait.remark` | string | 否 | 画像备注信息最大长度100字符 |
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计count字段累加<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
#### 3.3.3 画像类型type说明
| 值 | 类型 | 说明 | 适用场景 |
|---|------|------|---------|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
#### 3.3.4 画像来源source说明
| 值 | 来源 | 说明 |
|---|------|------|
| 0 | 本站 | 来自本站的数据 |
| 1 | 老油条 | 来自"老油条"系统的数据 |
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
#### 3.3.5 sourceData 数据格式说明
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
```json
{
"age": 28,
"gender": "female",
"city": "上海",
"province": "上海市",
"productId": "P12345",
"productName": "商品名称",
"category": "女装",
"price": 299.00,
"pageUrl": "https://example.com/product/123",
"referrer": "https://www.baidu.com",
"device": "mobile",
"browser": "WeChat"
}
```
> **注意**
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
> - 嵌套对象会被序列化为 JSON 字符串存储
> - 建议根据实际业务需求定义字段结构
#### 3.3.6 uniqueId 去重机制说明
- **作用**:防止重复记录相同的画像数据
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
- **建议格式**`{来源标识}_{用户标识}_{时间戳}_{序号}`
- 示例:`site_13800000000_1710000000_001`
- 示例:`wechat_wxid_abc123_1710000000_001`
- **注意事项**
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
> **重要提示**`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
---
## 四、请求示例
### 4.1 JSON 请求示例(无画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000000",
"name": "张三",
"source": "微信广告",
"remark": "通过H5落地页留资",
"tags": "高意向,电商",
"siteTags": "新客,女装",
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.2 JSON 请求示例(带微信号与画像)
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"wechatId": "wxid_abcdefg123",
"phone": "13800000001",
"name": "李四",
"source": "小程序落地页",
"remark": "点击【立即咨询】按钮",
"tags": "中意向,直播",
"siteTags": "复购,高客单",
"portrait": {
"type": 1,
"source": 0,
"sourceData": {
"age": 28,
"gender": "female",
"city": "上海",
"pageUrl": "https://example.com/product/123",
"productId": "P12345"
},
"remark": "画像-点击行为",
"uniqueId": "site_13800000001_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
### 4.3 JSON 请求示例(多种画像类型)
#### 4.3.1 浏览行为画像
```json
{
"apiKey": "YOUR_API_KEY",
"timestamp": 1710000000,
"phone": "13800000002",
"name": "王五",
"source": "百度推广",
"portrait": {
"type": 0,
"source": 0,
"sourceData": {
"pageUrl": "https://example.com/product/456",
"productName": "商品名称",
"category": "女装",
"stayTime": 120,
"device": "mobile"
},
"remark": "商品浏览",
"uniqueId": "site_13800000002_1710000000_001"
},
"sign": "根据签名规则生成的MD5字符串"
}
```
```
---
## 五、响应说明
### 5.1 成功响应
**1新增线索成功**
```json
{
"code": 200,
"message": "新增成功",
"data": "13800000000"
}
```
**2线索已存在**
```json
{
"code": 200,
"message": "已存在",
"data": "13800000000"
}
```
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
### 5.2 常见错误响应
```json
{ "code": 400, "message": "apiKey不能为空", "data": null }
{ "code": 400, "message": "sign不能为空", "data": null }
{ "code": 400, "message": "timestamp不能为空", "data": null }
{ "code": 400, "message": "请求已过期", "data": null }
{ "code": 401, "message": "无效的apiKey", "data": null }
{ "code": 401, "message": "签名验证失败", "data": null }
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
```
---
## 六、常见问题FAQ
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
### Q2: portrait 字段是否必须传递?
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
### Q3: sourceData 中可以存储哪些类型的数据?
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
### Q4: uniqueId 的作用是什么?
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
### Q5: 画像数据如何与用户关联?
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
---

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

33
public/index.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// [ 应用入口文件 ]
namespace think;
////处理跨域预检请求
if($_SERVER['REQUEST_METHOD'] == 'OPTIONS'){
header("Access-Control-Allow-Origin: " . (isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '*'));
header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization, Cookie");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH');
header("Access-Control-Allow-Credentials: true");
exit;
}
define('ROOT_PATH', dirname(__DIR__));
define('DS', DIRECTORY_SEPARATOR);
// 加载基础文件
require __DIR__ . '/../thinkphp/base.php';
// 支持事先使用静态方法设置Request对象和Config对象
// 执行应用并响应
Container::get('app')->run()->send();

123
public/login.html Normal file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录测试</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
#result {
margin-top: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
min-height: 100px;
}
</style>
</head>
<body>
<h1>JWT登录测试</h1>
<div class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" value="admin">
</div>
<div class="form-group">
<label for="password">密码:</label>
<input type="password" id="password" value="123456">
</div>
<button id="loginBtn">登录</button>
<button id="infoBtn">获取用户信息</button>
<div id="result">
<p>响应结果将显示在这里</p>
</div>
<script>
document.getElementById('loginBtn').addEventListener('click', login);
document.getElementById('infoBtn').addEventListener('click', getUserInfo);
let token = '';
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
document.getElementById('result').innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
if (data.code === 200 && data.data.token) {
token = data.data.token;
console.log('Token stored:', token);
}
} catch (error) {
document.getElementById('result').innerHTML = `<p>Error: ${error.message}</p>`;
}
}
async function getUserInfo() {
if (!token) {
document.getElementById('result').innerHTML = '<p>请先登录获取Token</p>';
return;
}
try {
const response = await fetch('/api/auth/info', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
document.getElementById('result').innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`;
} catch (error) {
document.getElementById('result').innerHTML = `<p>Error: ${error.message}</p>`;
}
}
</script>
</body>
</html>

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

17
public/router.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
// $Id$
if (is_file($_SERVER["DOCUMENT_ROOT"] . $_SERVER["SCRIPT_NAME"])) {
return false;
} else {
require __DIR__ . "/index.php";
}

36
public/test.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
// 测试登录API
$url = 'http://localhost/api/auth/login';
$data = json_encode([
'username' => 'admin',
'password' => '123456'
]);
// 初始化cURL
$ch = curl_init($url);
// 设置cURL选项
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Content-Length: ' . strlen($data)
]);
// 执行请求
$result = curl_exec($ch);
// 检查是否有错误
if (curl_errno($ch)) {
echo '请求错误: ' . curl_error($ch);
} else {
// 输出结果
echo '<pre>';
print_r(json_decode($result, true));
echo '</pre>';
}
// 关闭cURL资源
curl_close($ch);