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模块路由配置