From af79d1e8609e3c123b546436f3a1626ff5458304 Mon Sep 17 00:00:00 2001
From: wong <106998207@qq.com>
Date: Wed, 11 Mar 2026 14:01:50 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Server/application/command.php | 3 +
.../command/GenerateUserApiKeyCommand.php | 111 +++++++++
.../common/controller/OpenAuthController.php | 114 +++++++++
.../controller/OpenScenariosController.php | 234 ++++++++++++++++++
.../common/service/UserApiKeyService.php | 153 ++++++++++++
Server/application/cunkebao/config/route.php | 10 +
.../PostCreateAddFriendPlanV1Controller.php | 4 +-
Server/route/route.php | 7 +-
8 files changed, 632 insertions(+), 4 deletions(-)
create mode 100644 Server/application/command/GenerateUserApiKeyCommand.php
create mode 100644 Server/application/common/controller/OpenAuthController.php
create mode 100644 Server/application/common/controller/OpenScenariosController.php
create mode 100644 Server/application/common/service/UserApiKeyService.php
diff --git a/Server/application/command.php b/Server/application/command.php
index 45b6d7da8..2f8ccffd2 100644
--- a/Server/application/command.php
+++ b/Server/application/command.php
@@ -53,4 +53,7 @@ return [
// V2 流量池数据迁移
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
+
+ // 用户 API Key 管理
+ 'user:generate-api-key' => 'app\command\GenerateUserApiKeyCommand', // 批量为 ck_users 用户生成对外 API Key
];
diff --git a/Server/application/command/GenerateUserApiKeyCommand.php b/Server/application/command/GenerateUserApiKeyCommand.php
new file mode 100644
index 000000000..e4c7e9d3c
--- /dev/null
+++ b/Server/application/command/GenerateUserApiKeyCommand.php
@@ -0,0 +1,111 @@
+setName('user:generate-api-key')
+ ->setDescription('批量为 ck_users 用户生成对外 API Key')
+ ->addOption('force', 'f', Option::VALUE_NONE, '强制覆盖所有用户(包括已有 apiKey 的用户),危险!')
+ ->addOption('dry-run', null, Option::VALUE_NONE, '预览模式,不实际写入数据库');
+ }
+
+ protected function execute(Input $input, Output $output)
+ {
+ $force = (bool)$input->getOption('force');
+ $dryRun = (bool)$input->getOption('dry-run');
+
+ $output->writeln('========================================');
+ $output->writeln(' 批量生成用户 API Key');
+ $output->writeln('========================================');
+
+ if ($dryRun) {
+ $output->writeln('[预览模式] 不会实际写入数据库');
+ }
+ if ($force) {
+ $output->writeln('[FORCE] 将覆盖已有 apiKey 的用户');
+ }
+
+ $output->writeln('');
+
+ // 查询目标用户
+ $query = Db::name('users')
+ ->where('deleteTime', 0)
+ ->field('id, account, phone, apiKey');
+
+ if (!$force) {
+ // 默认只处理 apiKey 为空或 NULL 的记录
+ $query->where(function ($q) {
+ $q->where('apiKey', null)
+ ->whereOr('apiKey', '');
+ });
+ }
+
+ $users = $query->select();
+ $total = count($users);
+ $success = 0;
+ $skip = 0;
+
+ $output->writeln("共找到 {$total} 个需要处理的用户");
+ $output->writeln('');
+
+ if ($total === 0) {
+ $output->writeln('所有用户均已有 API Key,无需处理。');
+ return true;
+ }
+
+ foreach ($users as $user) {
+ $uid = (int)$user['id'];
+ $label = "用户 #{$uid} ({$user['account']}/{$user['phone']})";
+
+ try {
+ if ($dryRun) {
+ $output->writeln("[预览] 将为 {$label} 生成 apiKey");
+ $success++;
+ continue;
+ }
+
+ if ($force) {
+ $apiKey = UserApiKeyService::forceGenerate($uid);
+ } else {
+ $apiKey = UserApiKeyService::bindOrGet($uid);
+ }
+
+ $output->writeln("√ {$label} => {$apiKey}");
+ $success++;
+
+ } catch (\Exception $e) {
+ $output->writeln("✗ {$label} 失败:{$e->getMessage()}");
+ $skip++;
+ }
+ }
+
+ $output->writeln('');
+ $output->writeln('========================================');
+ $output->writeln(" 完成:成功 {$success} 个,跳过/失败 {$skip} 个");
+ $output->writeln('========================================');
+
+ if ($dryRun) {
+ $output->writeln('预览模式:未实际写入任何数据');
+ }
+
+ return true;
+ }
+}
diff --git a/Server/application/common/controller/OpenAuthController.php b/Server/application/common/controller/OpenAuthController.php
new file mode 100644
index 000000000..f3e8f87d2
--- /dev/null
+++ b/Server/application/common/controller/OpenAuthController.php
@@ -0,0 +1,114 @@
+
+ * 即可调用,与存客宝内部接口完全兼容。
+ */
+class OpenAuthController extends Controller
+{
+ /**
+ * 获取 JWT Token
+ * POST /v1/open/auth/token
+ *
+ * 请求参数:
+ * apiKey - 账号专属 API Key(ck_users.apiKey)
+ * account - 登录账号(ck_users.account),参与签名
+ * timestamp - 秒级时间戳
+ * sign - 签名值,算法见签名文档
+ *
+ * 成功响应:
+ * { code: 200, message: "success", data: { token: "xxx", expires_in: 7200 } }
+ */
+ public function getToken()
+ {
+ try {
+ $params = $this->request->param();
+
+ // ── 1. 必填参数校验 ─────────────────────────────────────────
+ if (empty($params['apiKey'])) {
+ return $this->fail('apiKey不能为空', 400);
+ }
+ if (empty($params['account'])) {
+ return $this->fail('account不能为空', 400);
+ }
+ if (empty($params['sign'])) {
+ return $this->fail('sign不能为空', 400);
+ }
+ if (empty($params['timestamp'])) {
+ return $this->fail('timestamp不能为空', 400);
+ }
+
+ // ── 2. 时间戳时效校验(±5 分钟)──────────────────────────────
+ if (abs(time() - intval($params['timestamp'])) > 300) {
+ return $this->fail('请求已过期', 400);
+ }
+
+ // ── 3. 用 apiKey 找账号,并校验 account 一致性 ───────────────
+ $user = UserApiKeyService::findUserByKey($params['apiKey']);
+ if (!$user) {
+ return $this->fail('无效的apiKey', 401);
+ }
+ if ($user['account'] !== $params['account']) {
+ return $this->fail('无效的apiKey', 401);
+ }
+
+ // ── 4. 验签(account + timestamp + apiKey)────────────────────
+ if (!UserApiKeyService::validateSign(
+ $params['account'],
+ (string)$params['timestamp'],
+ $params['apiKey'],
+ $params['sign']
+ )) {
+ return $this->fail('签名验证失败', 401);
+ }
+
+ // ── 5. 签发 JWT(2 小时有效期)────────────────────────────────
+ $expireSeconds = 7200;
+ $token = JwtUtil::createToken([
+ 'id' => (int)$user['id'],
+ 'account' => $user['account'] ?? '',
+ 'username' => $user['username'] ?? '',
+ 'phone' => $user['phone'] ?? '',
+ 'companyId' => (int)$user['companyId'],
+ 'typeId' => (int)$user['typeId'],
+ 'isAdmin' => (int)($user['isAdmin'] ?? 0),
+ 'via' => 'open_api', // 标记来源,方便日志区分
+ ], $expireSeconds);
+
+ Log::info('[OpenAuth] 对外接口登录成功', [
+ 'userId' => $user['id'],
+ 'account' => $user['account'],
+ 'ip' => $this->request->ip(),
+ ]);
+
+ return json([
+ 'code' => 200,
+ 'message' => 'success',
+ 'data' => [
+ 'token' => $token,
+ 'expires_in' => $expireSeconds,
+ ],
+ ]);
+
+ } catch (\Exception $e) {
+ Log::error('[OpenAuth] getToken 异常:' . $e->getMessage());
+ return $this->fail('系统错误: ' . $e->getMessage(), 500);
+ }
+ }
+
+ private function fail(string $message, int $code = 400)
+ {
+ return json(['code' => $code, 'message' => $message, 'data' => null]);
+ }
+}
diff --git a/Server/application/common/controller/OpenScenariosController.php b/Server/application/common/controller/OpenScenariosController.php
new file mode 100644
index 000000000..f7a0e77b0
--- /dev/null
+++ b/Server/application/common/controller/OpenScenariosController.php
@@ -0,0 +1,234 @@
+
+ */
+class OpenScenariosController extends Controller
+{
+ /**
+ * 线索上报入口
+ * POST /v1/open/scenarios
+ */
+ public function submit()
+ {
+ try {
+ // ── 1. 从 JWT 中取当前用户(由 jwt 中间件注入)─────────────────
+ $userInfo = $this->request->userInfo ?? [];
+ $companyId = (int)($userInfo['companyId'] ?? 0);
+
+ if (empty($userInfo['id']) || empty($companyId)) {
+ return $this->error('未授权访问', 401);
+ }
+
+ $params = $this->request->param();
+
+ // ── 2. planId 校验(必须属于当前账号的 companyId,且已启用)──
+ if (empty($params['planId'])) {
+ return $this->error('planId不能为空', 400);
+ }
+
+ $plan = Db::name('customer_acquisition_task')
+ ->where('id', intval($params['planId']))
+ ->where('companyId', $companyId)
+ ->where('status', 1)
+ ->find();
+
+ if (!$plan) {
+ return $this->error('计划不存在或已停用', 404);
+ }
+
+ // ── 3. 主标识(wechatId 优先,phone 次之)─────────────────────
+ $wechatId = trim($params['wechatId'] ?? '');
+ $phone = trim($params['phone'] ?? '');
+ $identifier = $wechatId ?: $phone;
+
+ if (empty($identifier)) {
+ return $this->error('wechatId 和 phone 至少传一个', 400);
+ }
+
+ // ── 4. 渠道 ID(可选,分销场景)──────────────────────────────
+ $channelId = !empty($params['cid']) ? intval($params['cid']) : 0;
+ $finalChannelId = $this->resolveChannelId($channelId, $plan);
+
+ // ── 5. 查重 & 写入 task_customer ──────────────────────────────
+ $taskCustomer = Db::name('task_customer')
+ ->where('task_id', $plan['id'])
+ ->where('phone', $identifier)
+ ->find();
+
+ if ($taskCustomer) {
+ // 已存在:仅追加站内标签
+ if (!empty($params['siteTags'])) {
+ $this->mergeSiteTags(
+ $taskCustomer['id'],
+ explode(',', $params['siteTags'])
+ );
+ }
+ return $this->success($identifier, '已存在');
+ }
+
+ // 新线索
+ $tags = !empty($params['tags']) ? explode(',', $params['tags']) : [];
+ $siteTags = !empty($params['siteTags']) ? explode(',', $params['siteTags']) : [];
+
+ $customerId = Db::name('task_customer')->insertGetId([
+ 'task_id' => $plan['id'],
+ 'channelId' => $finalChannelId,
+ 'phone' => $identifier,
+ 'name' => $params['name'] ?? '',
+ 'source' => $params['source'] ?? '',
+ 'remark' => $params['remark'] ?? '',
+ 'tags' => json_encode($tags, JSON_UNESCAPED_UNICODE),
+ 'siteTags' => json_encode($siteTags, JSON_UNESCAPED_UNICODE),
+ 'createTime' => time(),
+ ]);
+
+ // ── 6. 同步到 V2 流量池(异步,不影响主流程)─────────────────
+ if ($customerId) {
+ $this->syncToTrafficPool(
+ $identifier, $phone, $wechatId,
+ $companyId, $plan, $params,
+ $finalChannelId, $customerId
+ );
+ }
+
+ // ── 7. 分销获客奖励(异步,不影响主流程)────────────────────
+ if ($customerId && $finalChannelId > 0) {
+ try {
+ DistributionRewardService::recordCustomerReward(
+ $plan['id'], $customerId, $identifier, $finalChannelId
+ );
+ } catch (\Exception $e) {
+ Log::error('[OpenScenarios] 记录获客奖励失败:' . $e->getMessage());
+ }
+ }
+
+ return $this->success($identifier, '新增成功');
+
+ } catch (\Exception $e) {
+ Log::error('[OpenScenarios] 系统错误:' . $e->getMessage() . "\n" . $e->getTraceAsString());
+ return $this->error('系统错误: ' . $e->getMessage(), 500);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private function resolveChannelId(int $channelId, array $plan): int
+ {
+ if ($channelId <= 0) {
+ return 0;
+ }
+ $sceneConf = json_decode($plan['sceneConf'] ?? '{}', true) ?: [];
+ $distConfig = $sceneConf['distribution'] ?? [];
+ $allowedIds = $distConfig['channels'] ?? [];
+
+ if (empty($distConfig['enabled']) || !in_array($channelId, $allowedIds)) {
+ return 0;
+ }
+
+ $channel = Db::name('distribution_channel')
+ ->where([
+ ['id', '=', $channelId],
+ ['companyId', '=', $plan['companyId']],
+ ['status', '=', 'enabled'],
+ ['deleteTime', '=', 0],
+ ])
+ ->find();
+
+ return $channel ? $channelId : 0;
+ }
+
+ private function mergeSiteTags(int $taskCustomerId, array $newTags): void
+ {
+ try {
+ $row = Db::name('task_customer')->where('id', $taskCustomerId)->find();
+ if (!$row) {
+ return;
+ }
+ $existing = !empty($row['siteTags']) ? (json_decode($row['siteTags'], true) ?: []) : [];
+ $merged = array_values(array_unique(array_filter(
+ array_merge($existing, $newTags),
+ fn($t) => trim($t) !== ''
+ )));
+ Db::name('task_customer')->where('id', $taskCustomerId)->update([
+ 'siteTags' => json_encode($merged, JSON_UNESCAPED_UNICODE),
+ 'updateTime' => time(),
+ ]);
+ } catch (\Exception $e) {
+ Log::error('[OpenScenarios] 合并站内标签失败:' . $e->getMessage());
+ }
+ }
+
+ private function syncToTrafficPool(
+ string $identifier,
+ string $phone,
+ string $wechatId,
+ int $companyId,
+ array $plan,
+ array $params,
+ int $finalChannelId,
+ int $customerId
+ ): void {
+ try {
+ $isPhone = (bool)preg_match('/^\+?\d{6,}$/', $identifier);
+ $poolService = new TrafficPoolService();
+ $poolService->enterPool(
+ $identifier,
+ $companyId,
+ TrafficPoolSource::SOURCE_TYPE_API,
+ [
+ 'identifierType' => $isPhone ? 2 : 1,
+ 'mobile' => $phone ?: ($isPhone ? $identifier : ''),
+ 'wechatId' => $wechatId ?: (!$isPhone ? $identifier : ''),
+ 'nickname' => $params['name'] ?? '',
+ ],
+ [
+ 'phone' => $phone ?: ($isPhone ? $identifier : ''),
+ 'realName' => $params['name'] ?? '',
+ 'remark' => $params['remark'] ?? '',
+ ],
+ [
+ 'sourceName' => !empty($params['source'])
+ ? $params['source']
+ : ('场景获客_' . $plan['name']),
+ 'remark' => $params['remark'] ?? '',
+ 'extra' => json_encode([
+ 'planId' => $plan['id'],
+ 'planName' => $plan['name'],
+ 'channelId' => $finalChannelId,
+ 'customerId' => $customerId,
+ 'via' => 'open_api',
+ ], JSON_UNESCAPED_UNICODE),
+ ]
+ );
+ } catch (\Exception $e) {
+ Log::error('[OpenScenarios] 同步 V2 流量池失败:' . $e->getMessage());
+ }
+ }
+
+ private function success(string $data, string $message = 'success')
+ {
+ return json(['code' => 200, 'message' => $message, 'data' => $data]);
+ }
+
+ private function error(string $message, int $code = 400)
+ {
+ return json(['code' => $code, 'message' => $message, 'data' => null]);
+ }
+}
diff --git a/Server/application/common/service/UserApiKeyService.php b/Server/application/common/service/UserApiKeyService.php
new file mode 100644
index 000000000..a30d03df2
--- /dev/null
+++ b/Server/application/common/service/UserApiKeyService.php
@@ -0,0 +1,153 @@
+ 0 ? '-' : '') . $segment;
+ }
+
+ // 确保全局唯一
+ $exists = Db::name('users')->where('apiKey', $key)->find();
+ if (!$exists) {
+ return $key;
+ }
+ }
+ }
+
+ /**
+ * 为指定用户绑定 API Key(幂等:已有则直接返回,没有则生成并写入)
+ *
+ * @param int $userId ck_users.id
+ * @return string 该用户的 apiKey
+ * @throws \RuntimeException
+ */
+ public static function bindOrGet(int $userId): string
+ {
+ $user = Db::name('users')
+ ->where('id', $userId)
+ ->where('deleteTime', 0)
+ ->field('id, apiKey')
+ ->find();
+
+ if (!$user) {
+ throw new \RuntimeException('用户不存在');
+ }
+
+ if (!empty($user['apiKey'])) {
+ return $user['apiKey'];
+ }
+
+ return self::forceGenerate($userId);
+ }
+
+ /**
+ * 强制为指定用户重新生成 API Key(会覆盖旧 Key)
+ *
+ * @param int $userId ck_users.id
+ * @return string 新生成的 apiKey
+ * @throws \RuntimeException
+ */
+ public static function forceGenerate(int $userId): string
+ {
+ $user = Db::name('users')
+ ->where('id', $userId)
+ ->where('deleteTime', 0)
+ ->field('id')
+ ->find();
+
+ if (!$user) {
+ throw new \RuntimeException('用户不存在');
+ }
+
+ $apiKey = self::generate();
+
+ Db::name('users')
+ ->where('id', $userId)
+ ->update([
+ 'apiKey' => $apiKey,
+ 'updateTime' => time(),
+ ]);
+
+ Log::info("UserApiKeyService: 用户 #{$userId} 生成/更新 apiKey");
+
+ return $apiKey;
+ }
+
+ /**
+ * 通过 apiKey 查找用户(用于对外接口身份校验)
+ *
+ * @param string $apiKey
+ * @return array|null ck_users 行,或 null(key 无效/用户已删除/已禁用)
+ */
+ public static function findUserByKey(string $apiKey): ?array
+ {
+ if (empty($apiKey)) {
+ return null;
+ }
+
+ $user = Db::name('users')
+ ->where('apiKey', $apiKey)
+ ->where('deleteTime', 0)
+ ->where('status', 1)
+ ->find();
+
+ return $user ?: null;
+ }
+
+ /**
+ * 验证签名
+ *
+ * 只有三个固定字段参与签名:account、timestamp、apiKey
+ * stringToSign = account + timestamp (按字段名 ASCII 升序拼接值)
+ * firstMd5 = MD5(stringToSign)
+ * sign = MD5(firstMd5 + apiKey)
+ *
+ * @param string $account 请求中传入的 account(ck_users.account)
+ * @param string $timestamp 请求中传入的 timestamp
+ * @param string $apiKey 用户 apiKey
+ * @param string $sign 客户端传来的签名
+ * @return bool
+ */
+ public static function validateSign(string $account, string $timestamp, string $apiKey, string $sign): bool
+ {
+ // account < timestamp(ASCII 升序)
+ $stringToSign = $account . $timestamp;
+ $firstMd5 = md5($stringToSign);
+ $expectedSign = md5($firstMd5 . $apiKey);
+
+ return hash_equals($expectedSign, $sign);
+ }
+}
diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php
index 02e04f005..0f84b13fb 100644
--- a/Server/application/cunkebao/config/route.php
+++ b/Server/application/cunkebao/config/route.php
@@ -283,10 +283,20 @@ Route::group('v1/', function () {
+// 旧版场景获客对外接口(计划级 apiKey,保持兼容)
Route::group('v1/api/scenarios', function () {
Route::any('', 'app\cunkebao\controller\plan\PostExternalApiV1Controller@index');
});
+// 新版开放接口(账号级 apiKey + JWT)
+// ① 公开:用 apiKey + sign 换取 JWT Token
+Route::post('v1/open/auth/token', 'app\common\controller\OpenAuthController@getToken');
+
+// ② 需要 JWT:所有业务接口
+Route::group('v1/open', function () {
+ Route::post('scenarios', 'app\common\controller\OpenScenariosController@submit'); // 场景获客线索上报
+})->middleware(['jwt']);
+
//小程序
Route::group('v1/frontend', function () {
diff --git a/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php b/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php
index 67594a161..2f76cc89b 100644
--- a/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php
+++ b/Server/application/cunkebao/controller/plan/PostCreateAddFriendPlanV1Controller.php
@@ -21,8 +21,8 @@ class PostCreateAddFriendPlanV1Controller extends BaseController
*/
public function generateApiKey()
{
- // 生成5组随机字符串,每组5个字符
- $chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
+ // 生成5组随机字符串,每组5个字符(包含大小写字母和数字)
+ $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$apiKey = '';
for ($i = 0; $i < 5; $i++) {
diff --git a/Server/route/route.php b/Server/route/route.php
index 9f6b71c07..1fd9b0049 100644
--- a/Server/route/route.php
+++ b/Server/route/route.php
@@ -18,7 +18,7 @@ use think\facade\Route;
header('Access-Control-Max-Age: 1728000');
header('Access-Control-Allow-Credentials: true');
-// 加载Store模块路由配置
+// 加载API模块路由配置
include __DIR__ . '/../application/api/config/route.php';
// 加载Common模块路由配置
@@ -27,7 +27,10 @@ include __DIR__ . '/../application/common/config/route.php';
// 加载Cunkebao模块路由配置
include __DIR__ . '/../application/cunkebao/config/route.php';
-// 加载Store模块路由配置
+// 加载Store_old模块路由配置(V1旧版,路径:/v1/store_old/*)
+include __DIR__ . '/../application/store_old/config/route.php';
+
+// 加载Store模块路由配置(V2新版,路径:/v2/store/*)
include __DIR__ . '/../application/store/config/route.php';
// 加载Superadmin模块路由配置