feat: CRM 报告与双端结果页、管理端用户/设置与默认企业配置
1、修复了用户详情与人脸/DISC 展示、手机号与资料校验、相机页与测试选择等体验问题。 2、新增了 SystemDefaultEnterprise、discDisplay、管理端设置扩展接口;CrmReport/Test/Analyze 等 API 与路由能力补强。 3、优化了 resultFormat、descriptions、payment、企业上下文;海报与 PDP/DISC 文案;超管 Commerce/Users 与 admin Settings/Users。 Made-with: Cursor
This commit is contained in:
@@ -10,7 +10,7 @@ class PdpDiscResultText
|
||||
private const PDP_EN_TO_CN = [
|
||||
'Tiger' => '老虎型',
|
||||
'Peacock' => '孔雀型',
|
||||
'Koala' => '考拉型',
|
||||
'Koala' => '无尾熊型',
|
||||
'Owl' => '猫头鹰型',
|
||||
'Chameleon' => '变色龙型',
|
||||
];
|
||||
|
||||
40
api/app/common/SystemDefaultEnterprise.php
Normal file
40
api/app/common/SystemDefaultEnterprise.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
|
||||
use app\model\Enterprise as EnterpriseModel;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 超管「系统基础配置」中的小程序默认企业,与 AppConfig 语义一致。
|
||||
*/
|
||||
class SystemDefaultEnterprise
|
||||
{
|
||||
/**
|
||||
* 有效则返回企业 ID,否则 null(企业不存在或已软删时亦为 null)
|
||||
*/
|
||||
public static function getId(): ?int
|
||||
{
|
||||
try {
|
||||
$row = Db::name('system_config')->where('key', 'system')->where('enterprise_id', 0)->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return null;
|
||||
}
|
||||
$raw = $row['value'];
|
||||
$arr = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (!is_array($arr) || !isset($arr['defaultEnterpriseId']) || $arr['defaultEnterpriseId'] === '' || $arr['defaultEnterpriseId'] === null) {
|
||||
return null;
|
||||
}
|
||||
$de = (int) $arr['defaultEnterpriseId'];
|
||||
if ($de <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (!EnterpriseModel::where('id', $de)->find()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $de;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,7 +513,7 @@ class PosterService
|
||||
self::drawRoundedRect($img, $cx - 80, 150, 65, 24, 8, imagecolorallocatealpha($img, 255, 255, 255, 50));
|
||||
self::drawRoundedRect($img, $cx + 5, 150, 65, 24, 8, imagecolorallocatealpha($img, 255, 255, 255, 50));
|
||||
self::drawText($img, $cx - 70, 155, 'INTJ', $primary, 10);
|
||||
self::drawText($img, $cx - 45, 155, '战略家', $gray, 10);
|
||||
self::drawText($img, $cx - 45, 155, '建筑师', $gray, 10);
|
||||
self::drawText($img, $cx + 15, 155, 'PDP', $secondary, 10);
|
||||
self::drawText($img, $cx + 40, 155, '猫头鹰', $gray, 10);
|
||||
|
||||
|
||||
@@ -174,6 +174,115 @@ class Settings extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝 Key 默认结构(人脸/MBTI/PDP/DISC × 企业版与个人版)
|
||||
*/
|
||||
private static function defaultCunkebaoKeysStructure(): array
|
||||
{
|
||||
$blank = ['enterprise' => '', 'personal' => '', 'reportTiming' => 'after_paid'];
|
||||
|
||||
return [
|
||||
'face' => $blank,
|
||||
'pdp' => $blank,
|
||||
'disc' => $blank,
|
||||
'mbti' => $blank,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw 已解码的配置或 null
|
||||
*/
|
||||
private static function normalizeCunkebaoKeysPayload($raw): array
|
||||
{
|
||||
$out = self::defaultCunkebaoKeysStructure();
|
||||
if (!is_array($raw)) {
|
||||
return $out;
|
||||
}
|
||||
foreach (array_keys($out) as $type) {
|
||||
if (!isset($raw[$type]) || !is_array($raw[$type])) {
|
||||
continue;
|
||||
}
|
||||
$row = $raw[$type];
|
||||
$out[$type]['enterprise'] = isset($row['enterprise']) ? trim((string) $row['enterprise']) : '';
|
||||
$out[$type]['personal'] = isset($row['personal']) ? trim((string) $row['personal']) : '';
|
||||
$out[$type]['reportTiming'] = in_array($row['reportTiming'] ?? '', ['after_paid', 'after_test'], true)
|
||||
? $row['reportTiming']
|
||||
: 'after_paid';
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 中的企业 ID(企业管理员必有关联企业)
|
||||
*/
|
||||
private function adminBoundEnterpriseId($user): int
|
||||
{
|
||||
if (!is_array($user)) {
|
||||
return 0;
|
||||
}
|
||||
$eid = (int) ($user['enterpriseId'] ?? $user['enterprise_id'] ?? 0);
|
||||
|
||||
return $eid > 0 ? $eid : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/settings/cunkebao-keys
|
||||
* 按当前管理员所属企业读取(system_config.enterprise_id = 企业 ID)
|
||||
*/
|
||||
public function getCunkebaoKeys()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$eid = $this->adminBoundEnterpriseId($user);
|
||||
if ($eid <= 0) {
|
||||
return error('当前账号未关联企业,无法配置存客宝 Key', 403);
|
||||
}
|
||||
|
||||
$row = self::getConfig('cunkebao_keys', $eid, false);
|
||||
|
||||
return success([
|
||||
'cunkebaoKeys' => self::normalizeCunkebaoKeysPayload($row),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/settings/cunkebao-keys
|
||||
*/
|
||||
public function updateCunkebaoKeys()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
$eid = $this->adminBoundEnterpriseId($user);
|
||||
if ($eid <= 0) {
|
||||
return error('当前账号未关联企业,无法配置存客宝 Key', 403);
|
||||
}
|
||||
|
||||
$raw = $this->request->getContent();
|
||||
$input = $raw ? json_decode($raw, true) : [];
|
||||
if (!is_array($input)) {
|
||||
$input = [];
|
||||
}
|
||||
$payload = $input['cunkebaoKeys'] ?? [];
|
||||
if (!is_array($payload)) {
|
||||
return error('存客宝 Key 格式错误', 400);
|
||||
}
|
||||
|
||||
$sanitized = self::normalizeCunkebaoKeysPayload($payload);
|
||||
|
||||
try {
|
||||
self::saveConfig('cunkebao_keys', $sanitized, $eid, '存客宝 Key(本企业 · 人脸/MBTI/PDP/DISC)');
|
||||
|
||||
return success($sanitized, '存客宝 Key 已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解码 JSON(处理可能的多重编码)
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\SystemDefaultEnterprise;
|
||||
use app\controller\api\Test as TestController;
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use app\model\SystemConfig as SystemConfigModel;
|
||||
@@ -58,6 +59,12 @@ class Analyze extends BaseController
|
||||
if (!empty($boundEid)) {
|
||||
$pricingEnterpriseId = (int) $boundEid; // admin_personal + eid
|
||||
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
|
||||
} else {
|
||||
$defEid = SystemDefaultEnterprise::getId();
|
||||
if ($defEid !== null) {
|
||||
$pricingEnterpriseId = $defEid;
|
||||
$writeEnterpriseId = $defEid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +138,11 @@ class Analyze extends BaseController
|
||||
// 将用户上传的图片 URL 一并写入结果,方便 mbti_test_results 保留原始图片记录
|
||||
$storePayload = $result;
|
||||
if (is_array($storePayload)) {
|
||||
// 若模型结果中尚未包含 photoUrls,则追加一份
|
||||
if (!isset($storePayload['photoUrls'])) {
|
||||
$storePayload['photoUrls'] = $photoUrls;
|
||||
}
|
||||
// 始终以本次请求中的上传 URL 为准(最多 3 张),覆盖模型 JSON 里可能截断或错误的 photoUrls
|
||||
$cleanUrls = array_values(array_slice(array_filter($photoUrls, static function ($u) {
|
||||
return is_string($u) && trim($u) !== '';
|
||||
}), 0, 3));
|
||||
$storePayload['photoUrls'] = $cleanUrls;
|
||||
}
|
||||
|
||||
// 当前标准定价(分)与是否需要付费
|
||||
@@ -187,6 +195,19 @@ class Analyze extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
// 分销失败不影响主流程
|
||||
}
|
||||
|
||||
// 存客宝线索上报(reportTiming=after_test 时,测试完成即上报)
|
||||
try {
|
||||
\app\controller\api\CrmReport::reportTestCompletion(
|
||||
$userId,
|
||||
'face',
|
||||
(int) $testResultId,
|
||||
(int) ($writeEnterpriseId ?? 0),
|
||||
$enterpriseFromRequest ? 'enterprise' : 'personal'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 上报失败不阻断
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 写入失败不影响返回分析结果
|
||||
@@ -366,6 +387,16 @@ class Analyze extends BaseController
|
||||
'amountFen' => $standardAmountFen,
|
||||
'amountYuan' => $standardAmountFen > 0 ? round($standardAmountFen / 100, 2) : 0,
|
||||
];
|
||||
|
||||
if (!TestController::isWechatProfileComplete($userId)) {
|
||||
$_tid = $responseData['_testResultId'] ?? 0;
|
||||
$_pay = $responseData['_payment'] ?? [];
|
||||
unset($responseData['_testResultId'], $responseData['_payment']);
|
||||
$responseData = TestController::filterResultToPartialStatic('resume', $responseData);
|
||||
$responseData['_testResultId'] = $_tid;
|
||||
$responseData['_payment'] = $_pay;
|
||||
}
|
||||
|
||||
return success($responseData);
|
||||
}
|
||||
|
||||
@@ -383,7 +414,7 @@ class Analyze extends BaseController
|
||||
{
|
||||
"version": 2,
|
||||
"mbti": "四字母类型,如 INTJ;无数据则留空字符串",
|
||||
"pdp": "老虎/孔雀/考拉/猫头鹰/变色龙 其一;无数据则留空字符串",
|
||||
"pdp": "老虎/孔雀/无尾熊/猫头鹰/变色龙 其一;无数据则留空字符串",
|
||||
"disc": "D/I/S/C 其一;无数据则留空字符串",
|
||||
"overview": "50字以内整体人才画像摘要,HR 看一眼能记住的句子",
|
||||
"portrait": {
|
||||
@@ -919,7 +950,7 @@ PROMPT;
|
||||
. '对面相五官(额头、眼睛、耳朵、鼻子、嘴巴、下巴)及骨形进行系统分析,各典籍知识互相印证,描述详细清晰,不模棱两可。'
|
||||
. "\n\n分析内容包括:\n"
|
||||
. "1. MBTI性格类型(直接给出四字母结论)\n"
|
||||
. "2. PDP行为偏好:主性格+辅性格(老虎、孔雀、无尾熊/考拉、猫头鹰、变色龙)\n"
|
||||
. "2. PDP行为偏好:主性格+辅性格(老虎、孔雀、无尾熊、猫头鹰、变色龙)\n"
|
||||
. "3. DISC沟通风格:主性格+辅性格(力量D、活跃I、和平S、完美C)\n"
|
||||
. "4. 盖洛普前三大优势主题\n"
|
||||
. "5. 面相五官详细分析(额头、眼睛、耳朵、鼻子、嘴巴、下巴,约100字)\n"
|
||||
@@ -941,7 +972,7 @@ PROMPT;
|
||||
|
||||
// 个人版字段说明 + 示例 JSON
|
||||
$basePersonal = "\n"
|
||||
. '【字段说明】mbti=四字母类型,pdp=PDP主性格(老虎/孔雀/考拉/猫头鹰/变色龙),pdpAux=PDP辅性格(同上),'
|
||||
. '【字段说明】mbti=四字母类型,pdp=PDP主性格(老虎/孔雀/无尾熊/猫头鹰/变色龙),pdpAux=PDP辅性格(同上),'
|
||||
. 'disc=DISC主性格字母(D/I/S/C),discAux=DISC辅性格字母(D/I/S/C),'
|
||||
. 'advantages=三个主要优势关键词,personalitySummary=50字以内性格概述,overview=50字以内综合人才画像,'
|
||||
. 'faceAnalysis=面相五官详细描述(额头/眼睛/耳朵/鼻子/嘴巴/下巴,约100字),'
|
||||
@@ -965,7 +996,7 @@ PROMPT;
|
||||
|
||||
// 企业版在个人版基础上追加 portrait / hrView / bossView / resumeHighlights 字段说明和示例
|
||||
$baseEnterprise = "\n"
|
||||
. '【字段说明】mbti=四字母类型,pdp=PDP主性格(老虎/孔雀/考拉/猫头鹰/变色龙),pdpAux=PDP辅性格(同上),'
|
||||
. '【字段说明】mbti=四字母类型,pdp=PDP主性格(老虎/孔雀/无尾熊/猫头鹰/变色龙),pdpAux=PDP辅性格(同上),'
|
||||
. 'disc=DISC主性格字母(D/I/S/C),discAux=DISC辅性格字母(D/I/S/C),'
|
||||
. 'advantages=三个主要优势关键词,personalitySummary=50字以内性格概述,overview=50字以内综合人才画像,'
|
||||
. 'faceAnalysis=面相五官详细描述(约100字),boneAnalysis=《冰鉴》八骨骨相描述(约100字),'
|
||||
@@ -994,7 +1025,7 @@ PROMPT;
|
||||
. '"performance":{"potential":"高潜","drivers":["目标导向","成就感驱动"],"risks":["变化环境下适应较慢"]},'
|
||||
. '"complianceRisk":{"level":"低","notes":"规则意识强,合规风险极低"},'
|
||||
. '"teamFit":{"bestTeam":"执行型或分工明确的团队","manageAdvice":"给予清晰目标与自主空间,定期反馈"}},'
|
||||
. '"bossView":{"headline":"稳健型执行骨干,适合担任核心执行岗或中层管理,建议优先录用",'
|
||||
. '"bossView":{"headline":"和平型执行骨干,适合担任核心执行岗或中层管理,建议优先录用",'
|
||||
. '"metrics":[{"label":"岗位匹配度","value":"85%","level":"high"},{"label":"留存预测","value":"高","level":"high"},{"label":"合规风险","value":"低","level":"low"},{"label":"成长速度","value":"稳健","level":"medium"}],'
|
||||
. '"costInsight":"性价比高,预期产出稳定,培养成本低"},'
|
||||
. '"resumeHighlights":"面相沉稳、骨相坚毅,典型执行型人才,适合精细化管理岗位"}'
|
||||
@@ -1379,12 +1410,13 @@ PROMPT;
|
||||
private function mbtiTitle(string $type): string
|
||||
{
|
||||
$titles = [
|
||||
'INTJ' => '战略家', 'INTP' => '逻辑学家', 'ENTJ' => '指挥官', 'ENTP' => '辩论家',
|
||||
'INTJ' => '建筑师', 'INTP' => '逻辑学家', 'ENTJ' => '指挥官', 'ENTP' => '辩论家',
|
||||
'INFJ' => '提倡者', 'INFP' => '调停者', 'ENFJ' => '主人公', 'ENFP' => '竞选者',
|
||||
'ISTJ' => '物流师', 'ISFJ' => '守卫者', 'ESTJ' => '总经理', 'ESFJ' => '执政官',
|
||||
'ISTP' => '鉴赏家', 'ISFP' => '探险家', 'ESTP' => '企业家', 'ESFP' => '表演者',
|
||||
'ISTJ' => '物流师', 'ISFJ' => '守卫者', 'ESTJ' => '管理者', 'ESFJ' => '执政官',
|
||||
'ISTP' => '鉴赏家', 'ISFP' => '艺术家', 'ESTP' => '动力者', 'ESFP' => '表演者',
|
||||
];
|
||||
return $titles[strtoupper($type)] ?? '战略家';
|
||||
$u = strtoupper(trim($type));
|
||||
return $titles[$u] ?? ($u !== '' ? $u : 'MBTI');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,235 +1,593 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\JwtService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 存客宝获客线索上报
|
||||
* 将小程序用户行为(申请咨询/完成付款)上报给存客宝系统
|
||||
*/
|
||||
class CrmReport extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST api/crm/report
|
||||
* 接收前端上报请求,向存客宝发送线索数据
|
||||
*
|
||||
* @param string apiKey 类目配置中的存客宝KEY(consultWechat字段)
|
||||
* @param string source 线索来源描述,如"个人深度服务-1v1深度解读"
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
// 获取当前用户(支持中间件注入和手动解析两种方式)
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
$token = JwtService::getTokenFromRequest($this->request);
|
||||
if ($token) {
|
||||
$payload = JwtService::verifyToken($token);
|
||||
if ($payload) {
|
||||
$user = [
|
||||
'source' => $payload['source'] ?? '',
|
||||
'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) ($user['user_id'] ?? 0);
|
||||
|
||||
// 接收参数
|
||||
$apiKey = trim((string) ($this->request->param('apiKey', '') ?? ''));
|
||||
$source = trim((string) ($this->request->param('source', '') ?? ''));
|
||||
$remark = trim((string) ($this->request->param('remark', '') ?? ''));
|
||||
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
|
||||
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
|
||||
|
||||
// apiKey 为空则跳过,不影响主流程
|
||||
if (empty($apiKey)) {
|
||||
return success(['reported' => false, 'reason' => 'no_api_key']);
|
||||
}
|
||||
|
||||
// 从数据库获取用户信息(手机号、openid、昵称)
|
||||
$phone = '';
|
||||
$openid = '';
|
||||
$nickname = '';
|
||||
if ($userId > 0) {
|
||||
$wechatUser = Db::name('wechat_users')
|
||||
->where('id', $userId)
|
||||
->field('phone, openid, nickname')
|
||||
->find();
|
||||
if ($wechatUser) {
|
||||
$phone = (string) ($wechatUser['phone'] ?? '');
|
||||
$openid = (string) ($wechatUser['openid'] ?? '');
|
||||
$nickname = (string) ($wechatUser['nickname'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// 至少需要手机号或微信号,否则没有意义
|
||||
if (empty($phone) && empty($openid)) {
|
||||
return success(['reported' => false, 'reason' => 'no_identifier']);
|
||||
}
|
||||
|
||||
// 读取接口地址(从 .env 的 API_URL)
|
||||
$apiUrl = env('API_URL', 'https://ckbapi.quwanzhi.com/v1/api/scenarios');
|
||||
$timestamp = time();
|
||||
|
||||
// 构建请求参数(只加非空字段)
|
||||
$params = ['apiKey' => $apiKey, 'timestamp' => $timestamp];
|
||||
if ($phone !== '') $params['phone'] = $phone;
|
||||
if ($nickname !== '') $params['name'] = $nickname;
|
||||
if ($source !== '') $params['source'] = $source;
|
||||
if ($remark !== '') $params['remark'] = $remark;
|
||||
if ($tags !== '') $params['tags'] = $tags;
|
||||
if ($siteTags !== '') $params['siteTags'] = $siteTags;
|
||||
|
||||
// 生成签名(portrait 不参与签名,需在签名后单独附加)
|
||||
$params['sign'] = self::generateSign($params, $apiKey);
|
||||
|
||||
// 附加用户画像(从最近测试结果构建,不参与签名)
|
||||
$portrait = self::buildPortrait($userId);
|
||||
if ($portrait !== null) {
|
||||
$params['portrait'] = $portrait;
|
||||
}
|
||||
|
||||
// 发起请求
|
||||
$result = self::callApi($apiUrl, $params);
|
||||
|
||||
if ($result['success']) {
|
||||
return success(['reported' => true]);
|
||||
}
|
||||
|
||||
Log::warning('[CrmReport] 上报失败 userId=' . $userId . ' reason=' . json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
// 上报失败不影响主业务,始终返回成功
|
||||
return success(['reported' => false, 'reason' => $result['error'] ?? 'api_error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库读取用户最近一次 MBTI / DISC / PDP 测试结果,构建 portrait 对象
|
||||
* portrait 整体不参与签名,直接附加到请求体中(见接口文档 §2.3)
|
||||
*/
|
||||
private static function buildPortrait(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 一次查出所有相关类型的最新记录(按时间倒序)
|
||||
$rows = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->whereIn('testType', ['mbti', 'disc', 'pdp'])
|
||||
->field('testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$found = [];
|
||||
foreach ($rows as $row) {
|
||||
$type = $row['testType'];
|
||||
if (isset($found[$type])) continue; // 只取每种类型的最新一条
|
||||
|
||||
$data = [];
|
||||
if (!empty($row['resultData'])) {
|
||||
$decoded = json_decode($row['resultData'], true);
|
||||
$data = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'mbti':
|
||||
$val = $data['mbtiType'] ?? $data['mbti'] ?? '';
|
||||
if ($val !== '') $found['mbti'] = (string) $val;
|
||||
break;
|
||||
case 'disc':
|
||||
$val = $data['dominantType'] ?? $data['disc'] ?? '';
|
||||
if ($val !== '') $found['disc'] = $val . '型';
|
||||
break;
|
||||
case 'pdp':
|
||||
$val = $data['description']['type'] ?? $data['pdp'] ?? '';
|
||||
if ($val !== '') $found['pdp'] = (string) $val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($found)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 4, // 互动(咨询/购买行为)
|
||||
'source' => 0, // 本站
|
||||
'sourceData' => $found,
|
||||
'remark' => '性格测试画像',
|
||||
'uniqueId' => 'wxmp_' . $userId . '_' . date('YmdH'), // 同一小时内去重
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存客宝签名
|
||||
* 规则(来自接口文档 §2.3):
|
||||
* 1. 移除 sign / apiKey / portrait
|
||||
* 2. 移除值为 null 或空字符串的字段
|
||||
* 3. 按参数名 ASCII 升序排序
|
||||
* 4. 只取"值"按顺序拼接
|
||||
* 5. 第一次 MD5
|
||||
* 6. 拼接 apiKey 后第二次 MD5,得到最终签名
|
||||
*/
|
||||
private static function generateSign(array $params, string $apiKey): string
|
||||
{
|
||||
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||
|
||||
$params = array_filter($params, static function ($value) {
|
||||
return !is_null($value) && $value !== '';
|
||||
});
|
||||
|
||||
ksort($params);
|
||||
|
||||
$stringToSign = implode('', array_values($params));
|
||||
$firstMd5 = md5($stringToSign);
|
||||
|
||||
return md5($firstMd5 . $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 cURL 调用存客宝接口
|
||||
*/
|
||||
private static function callApi(string $url, array $params): array
|
||||
{
|
||||
$payload = json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'Content-Length: ' . strlen($payload),
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
return ['success' => false, 'error' => 'curl:' . $curlError];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (is_array($data) && isset($data['code']) && (int) $data['code'] === 200) {
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $data['message'] ?? 'unknown',
|
||||
'response' => $response,
|
||||
];
|
||||
}
|
||||
}
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\common\service\JwtService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 存客宝获客线索上报
|
||||
* 将小程序用户行为(申请咨询/完成付款)上报给存客宝系统
|
||||
*/
|
||||
class CrmReport extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST api/crm/report
|
||||
* 接收前端上报请求,向存客宝发送线索数据
|
||||
*
|
||||
* @param string apiKey 类目配置中的存客宝KEY(consultWechat字段)
|
||||
* @param string source 线索来源描述,如"个人深度服务-1v1深度解读"
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
// 获取当前用户(支持中间件注入和手动解析两种方式)
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
$token = JwtService::getTokenFromRequest($this->request);
|
||||
if ($token) {
|
||||
$payload = JwtService::verifyToken($token);
|
||||
if ($payload) {
|
||||
$user = [
|
||||
'source' => $payload['source'] ?? '',
|
||||
'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) ($user['user_id'] ?? 0);
|
||||
|
||||
// 接收参数
|
||||
$apiKey = trim((string) ($this->request->param('apiKey', '') ?? ''));
|
||||
$source = trim((string) ($this->request->param('source', '') ?? ''));
|
||||
$remark = trim((string) ($this->request->param('remark', '') ?? ''));
|
||||
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
|
||||
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
|
||||
|
||||
// 测评类付费(人脸/MBTI/PDP/DISC):未传 apiKey 时从企业后台配置 cunkebao_keys 解析(与深度服务「完成付款」上报一致)
|
||||
$testType = trim((string) ($this->request->param('testType', '') ?? ''));
|
||||
$testResultId = (int) ($this->request->param('testResultId', 0) ?? 0);
|
||||
$contextEnterpriseId = (int) ($this->request->param('contextEnterpriseId', 0) ?? 0);
|
||||
|
||||
$resolvedFromEnterpriseKeys = false;
|
||||
if ($apiKey === '' && $testType !== '' && $userId > 0) {
|
||||
$resolved = self::resolveTestPaymentApiKey($userId, $testType, $testResultId, $contextEnterpriseId);
|
||||
if ($resolved !== null && $resolved !== '') {
|
||||
$apiKey = $resolved;
|
||||
$resolvedFromEnterpriseKeys = true;
|
||||
if ($source === '') {
|
||||
$source = self::buildTestPaymentSource($testType, $testResultId, $contextEnterpriseId, $userId);
|
||||
}
|
||||
// 测评类:不写「测试完成/完成付款」等流程备注,仅结果摘要+标签见下方 apply
|
||||
if ($siteTags === '') {
|
||||
$siteTags = self::testTypeSiteTag($testType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用企业 cunkebao_keys 的「测评付费」上报:须校验已支付(防止未支付伪造「完成付款」)
|
||||
if ($resolvedFromEnterpriseKeys && !empty($apiKey)) {
|
||||
$deny = self::verifyTestPaidForCrmReport($userId, $testType, $testResultId);
|
||||
if ($deny !== null) {
|
||||
return success(['reported' => false, 'reason' => $deny]);
|
||||
}
|
||||
}
|
||||
|
||||
// apiKey 为空则跳过,不影响主流程
|
||||
if (empty($apiKey)) {
|
||||
return success(['reported' => false, 'reason' => 'no_api_key']);
|
||||
}
|
||||
|
||||
// 测评结果:备注仅摘要;站内/微信标签含「测评名,结果」
|
||||
if ($testResultId > 0 && $userId > 0) {
|
||||
self::applyTestResultSummaryToReportPayload($userId, $testType, $testResultId, $remark, $tags, $siteTags);
|
||||
}
|
||||
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
|
||||
return success(['reported' => $ok, 'reason' => $ok ? '' : 'api_error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业 KEY 解析的测评付费上报:必须带 testResultId,且若该记录需付费则 isPaid=1
|
||||
*/
|
||||
private static function verifyTestPaidForCrmReport(int $userId, string $testType, int $testResultId): ?string
|
||||
{
|
||||
if ($testResultId <= 0) {
|
||||
return 'need_test_result_id';
|
||||
}
|
||||
|
||||
$tr = Db::name('test_results')
|
||||
->where('id', $testResultId)
|
||||
->where('userId', $userId)
|
||||
->field('testType,requiresPayment,isPaid')
|
||||
->find();
|
||||
if (!$tr) {
|
||||
return 'test_result_not_found';
|
||||
}
|
||||
|
||||
$rowType = (string) ($tr['testType'] ?? '');
|
||||
if ($rowType !== '' && $rowType !== $testType) {
|
||||
return 'test_type_mismatch';
|
||||
}
|
||||
|
||||
if ((int) ($tr['requiresPayment'] ?? 0) === 1 && (int) ($tr['isPaid'] ?? 0) !== 1) {
|
||||
return 'not_paid';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评付费场景:从 system_config.cunkebao_keys(企业后台配置)解析存客宝 KEY
|
||||
*
|
||||
* @return string|null 解析失败返回 null
|
||||
*/
|
||||
private static function resolveTestPaymentApiKey(
|
||||
int $userId,
|
||||
string $testType,
|
||||
int $testResultId,
|
||||
int $contextEnterpriseId
|
||||
): ?string {
|
||||
$allowed = ['face', 'mbti', 'pdp', 'disc'];
|
||||
if (!in_array($testType, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$testScope = '';
|
||||
$trEnterpriseId = 0;
|
||||
|
||||
if ($testResultId > 0) {
|
||||
$tr = Db::name('test_results')
|
||||
->where('id', $testResultId)
|
||||
->where('userId', $userId)
|
||||
->field('enterpriseId,testScope')
|
||||
->find();
|
||||
if ($tr) {
|
||||
$testScope = (string) ($tr['testScope'] ?? 'personal');
|
||||
$trEnterpriseId = (int) ($tr['enterpriseId'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$configEid = $trEnterpriseId > 0 ? $trEnterpriseId : $contextEnterpriseId;
|
||||
if ($configEid <= 0 && $userId > 0) {
|
||||
$configEid = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
}
|
||||
|
||||
$useEnterpriseColumn = ($testScope === 'enterprise')
|
||||
|| ($testScope === '' && $contextEnterpriseId > 0);
|
||||
|
||||
$col = $useEnterpriseColumn ? 'enterprise' : 'personal';
|
||||
$key = self::readCunkebaoCellWithFallback($configEid, $testType, $col);
|
||||
|
||||
return $key !== '' ? $key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某企业 cunkebao_keys 中一格;为空时再读 enterprise_id=0 全局行
|
||||
*/
|
||||
private static function readCunkebaoCellWithFallback(int $enterpriseId, string $testType, string $col): string
|
||||
{
|
||||
$v = self::readCunkebaoCell($enterpriseId, $testType, $col);
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
if ($enterpriseId > 0) {
|
||||
$v = self::readCunkebaoCell(0, $testType, $col);
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
private static function readCunkebaoCell(int $enterpriseId, string $testType, string $col): string
|
||||
{
|
||||
$row = Db::name('system_config')
|
||||
->where('key', 'cunkebao_keys')
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return '';
|
||||
}
|
||||
$decoded = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (!is_array($decoded) || !isset($decoded[$testType][$col])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $decoded[$testType][$col]);
|
||||
}
|
||||
|
||||
private static function testTypeSiteTag(string $testType): string
|
||||
{
|
||||
$map = [
|
||||
'face' => 'AI人脸性格分析',
|
||||
'mbti' => 'MBTI性格测试',
|
||||
'pdp' => 'PDP动物性格测试',
|
||||
'disc' => 'DISC行为风格测试',
|
||||
];
|
||||
|
||||
return $map[$testType] ?? $testType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与深度服务「个人/企业深度服务-类目名」风格对齐
|
||||
*/
|
||||
private static function buildTestPaymentSource(
|
||||
string $testType,
|
||||
int $testResultId,
|
||||
int $contextEnterpriseId,
|
||||
int $userId
|
||||
): string {
|
||||
$prefix = '个人测评';
|
||||
if ($testResultId > 0) {
|
||||
$scope = trim((string) (Db::name('test_results')->where('id', $testResultId)->where('userId', $userId)->value('testScope') ?? ''));
|
||||
if ($scope === 'enterprise') {
|
||||
$prefix = '企业测评';
|
||||
} elseif ($scope === '') {
|
||||
$prefix = $contextEnterpriseId > 0 ? '企业测评' : '个人测评';
|
||||
}
|
||||
} else {
|
||||
$prefix = $contextEnterpriseId > 0 ? '企业测评' : '个人测评';
|
||||
}
|
||||
|
||||
$title = self::testTypeSiteTag($testType);
|
||||
|
||||
return $prefix . '-' . $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 test_results.resultData 解析一行简短结果(如 PDP「孔雀+老虎型」、DISC「D+I型」、MBTI 四字母)
|
||||
*/
|
||||
private static function summarizeResultLine(string $testType, $resultDataRaw): string
|
||||
{
|
||||
if ($resultDataRaw === null || $resultDataRaw === '') {
|
||||
return '';
|
||||
}
|
||||
$data = is_string($resultDataRaw) ? json_decode($resultDataRaw, true) : $resultDataRaw;
|
||||
if (!is_array($data)) {
|
||||
return '';
|
||||
}
|
||||
switch ($testType) {
|
||||
case 'pdp':
|
||||
return PdpDiscResultText::pdpTopTwo($data);
|
||||
case 'disc':
|
||||
return PdpDiscResultText::discTopTwo($data);
|
||||
case 'mbti':
|
||||
$t = $data['mbtiType'] ?? $data['mbti'] ?? '';
|
||||
|
||||
return is_string($t) ? trim($t) : '';
|
||||
case 'face':
|
||||
$fa = $data['faceAnalysis'] ?? '';
|
||||
if (is_string($fa) && $fa !== '') {
|
||||
$fa = preg_replace('/\s+/u', ' ', trim($fa));
|
||||
|
||||
return mb_strlen($fa) > 80 ? (mb_substr($fa, 0, 80) . '…') : $fa;
|
||||
}
|
||||
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评上报:备注不要流程类文案,仅填结果摘要;tags/siteTags 为「测评名,结果摘要」
|
||||
*/
|
||||
private static function applyTestResultSummaryToReportPayload(
|
||||
int $userId,
|
||||
string $paramTestType,
|
||||
int $testResultId,
|
||||
string &$remark,
|
||||
string &$tags,
|
||||
string &$siteTags
|
||||
): void {
|
||||
if ($testResultId <= 0 || $userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$tr = Db::name('test_results')
|
||||
->where('id', $testResultId)
|
||||
->where('userId', $userId)
|
||||
->field('resultData,testType')
|
||||
->find();
|
||||
if (!$tr) {
|
||||
return;
|
||||
}
|
||||
$tt = (string) ($tr['testType'] ?? $paramTestType);
|
||||
if ($tt === '') {
|
||||
$tt = $paramTestType;
|
||||
}
|
||||
$summary = self::summarizeResultLine($tt, $tr['resultData'] ?? '');
|
||||
$typeLabel = self::testTypeSiteTag($tt);
|
||||
|
||||
$remark = $summary;
|
||||
|
||||
$parts = array_values(array_unique(array_filter([$typeLabel, $summary], static function ($s) {
|
||||
return $s !== null && $s !== '';
|
||||
})));
|
||||
$merged = implode(',', $parts);
|
||||
if ($merged !== '') {
|
||||
$siteTags = $merged;
|
||||
$tags = $merged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 Test::submit / Analyze 等内部调用:测试结果保存成功后上报
|
||||
* - requiresPayment=0(免费):有 Key 即上报
|
||||
* - requiresPayment=1 且 reportTiming=after_test:提交后上报
|
||||
* - requiresPayment=1 且 reportTiming=after_paid:此处不上报,由支付成功流程上报
|
||||
*
|
||||
* @param int $userId wechat_users.id
|
||||
* @param string $testType face|mbti|pdp|disc
|
||||
* @param int $testResultId test_results.id
|
||||
* @param int $enterpriseId 写入 test_results 时的 enterpriseId
|
||||
* @param string $testScope enterprise|personal
|
||||
*/
|
||||
public static function reportTestCompletion(
|
||||
int $userId,
|
||||
string $testType,
|
||||
int $testResultId,
|
||||
int $enterpriseId,
|
||||
string $testScope = 'personal'
|
||||
): void {
|
||||
try {
|
||||
$allowed = ['face', 'mbti', 'pdp', 'disc'];
|
||||
if (!in_array($testType, $allowed, true) || $userId <= 0 || $testResultId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$trRow = Db::name('test_results')
|
||||
->where('id', $testResultId)
|
||||
->where('userId', $userId)
|
||||
->field('requiresPayment,resultData,testType')
|
||||
->find();
|
||||
if (!$trRow) {
|
||||
return;
|
||||
}
|
||||
$requiresPayment = (int) ($trRow['requiresPayment'] ?? 0);
|
||||
|
||||
// 未配置付费(免费):测试完成即上报(与「没付款可直接调用」一致)
|
||||
// 若需付费:仅当后台为「测试完即上报」时在提交后上报;「付款后才上报」则等支付成功后再走前端/接口
|
||||
$timing = self::readReportTiming($enterpriseId, $testType);
|
||||
if ($requiresPayment === 1 && $timing !== 'after_test') {
|
||||
return;
|
||||
}
|
||||
|
||||
$col = ($testScope === 'enterprise') ? 'enterprise' : 'personal';
|
||||
$apiKey = self::readCunkebaoCellWithFallback($enterpriseId, $testType, $col);
|
||||
if ($apiKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$source = self::buildTestPaymentSource($testType, $testResultId, $enterpriseId, $userId);
|
||||
$rowType = (string) ($trRow['testType'] ?? $testType);
|
||||
$summary = self::summarizeResultLine($rowType, $trRow['resultData'] ?? '');
|
||||
$typeLabel = self::testTypeSiteTag($testType);
|
||||
$parts = array_values(array_unique(array_filter([$typeLabel, $summary], static function ($s) {
|
||||
return $s !== null && $s !== '';
|
||||
})));
|
||||
$merged = implode(',', $parts);
|
||||
$remark = $summary;
|
||||
$tags = $merged;
|
||||
$siteTags = $merged;
|
||||
|
||||
self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[CrmReport] reportTestCompletion 异常 userId=' . $userId . ' err=' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某企业某测评类型的 reportTiming(after_paid / after_test)
|
||||
*/
|
||||
private static function readReportTiming(int $enterpriseId, string $testType): string
|
||||
{
|
||||
$val = self::readReportTimingFromRow($enterpriseId, $testType);
|
||||
if ($val !== '') {
|
||||
return $val;
|
||||
}
|
||||
if ($enterpriseId > 0) {
|
||||
$val = self::readReportTimingFromRow(0, $testType);
|
||||
}
|
||||
|
||||
return $val !== '' ? $val : 'after_paid';
|
||||
}
|
||||
|
||||
private static function readReportTimingFromRow(int $enterpriseId, string $testType): string
|
||||
{
|
||||
$row = Db::name('system_config')
|
||||
->where('key', 'cunkebao_keys')
|
||||
->where('enterprise_id', $enterpriseId)
|
||||
->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return '';
|
||||
}
|
||||
$decoded = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (!is_array($decoded) || !isset($decoded[$testType]['reportTiming'])) {
|
||||
return '';
|
||||
}
|
||||
$t = (string) $decoded[$testType]['reportTiming'];
|
||||
|
||||
return in_array($t, ['after_paid', 'after_test'], true) ? $t : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部通用上报(供 report() 接口和 reportTestCompletion 共用)
|
||||
*/
|
||||
private static function doReport(
|
||||
int $userId,
|
||||
string $apiKey,
|
||||
string $source,
|
||||
string $remark,
|
||||
string $tags,
|
||||
string $siteTags
|
||||
): bool {
|
||||
$phone = '';
|
||||
$openid = '';
|
||||
$nickname = '';
|
||||
if ($userId > 0) {
|
||||
$wu = Db::name('wechat_users')->where('id', $userId)->field('phone, openid, nickname')->find();
|
||||
if ($wu) {
|
||||
$phone = (string) ($wu['phone'] ?? '');
|
||||
$openid = (string) ($wu['openid'] ?? '');
|
||||
$nickname = (string) ($wu['nickname'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($phone === '' && $openid === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$apiUrl = env('API_URL', 'https://ckbapi.quwanzhi.com/v1/api/scenarios');
|
||||
$timestamp = time();
|
||||
$params = ['apiKey' => $apiKey, 'timestamp' => $timestamp];
|
||||
if ($phone !== '') $params['phone'] = $phone;
|
||||
if ($nickname !== '') $params['name'] = $nickname;
|
||||
if ($source !== '') $params['source'] = $source;
|
||||
if ($remark !== '') $params['remark'] = $remark;
|
||||
if ($tags !== '') $params['tags'] = $tags;
|
||||
if ($siteTags !== '') $params['siteTags'] = $siteTags;
|
||||
|
||||
$params['sign'] = self::generateSign($params, $apiKey);
|
||||
|
||||
$portrait = self::buildPortrait($userId);
|
||||
if ($portrait !== null) {
|
||||
$params['portrait'] = $portrait;
|
||||
}
|
||||
|
||||
$result = self::callApi($apiUrl, $params);
|
||||
if (!$result['success']) {
|
||||
Log::warning('[CrmReport] doReport 失败 userId=' . $userId . ' reason=' . json_encode($result, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
return $result['success'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库读取用户最近一次 MBTI / DISC / PDP 测试结果,构建 portrait 对象
|
||||
* portrait 整体不参与签名,直接附加到请求体中(见接口文档 §2.3)
|
||||
*/
|
||||
private static function buildPortrait(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 一次查出所有相关类型的最新记录(按时间倒序)
|
||||
$rows = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->whereIn('testType', ['mbti', 'disc', 'pdp'])
|
||||
->field('testType, resultData, createdAt')
|
||||
->order('createdAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$found = [];
|
||||
foreach ($rows as $row) {
|
||||
$type = $row['testType'];
|
||||
if (isset($found[$type])) continue; // 只取每种类型的最新一条
|
||||
|
||||
$data = [];
|
||||
if (!empty($row['resultData'])) {
|
||||
$decoded = json_decode($row['resultData'], true);
|
||||
$data = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'mbti':
|
||||
$val = $data['mbtiType'] ?? $data['mbti'] ?? '';
|
||||
if ($val !== '') $found['mbti'] = (string) $val;
|
||||
break;
|
||||
case 'disc':
|
||||
$val = $data['dominantType'] ?? $data['disc'] ?? '';
|
||||
if ($val !== '') $found['disc'] = $val . '型';
|
||||
break;
|
||||
case 'pdp':
|
||||
$val = $data['description']['type'] ?? $data['pdp'] ?? '';
|
||||
if ($val !== '') $found['pdp'] = (string) $val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($found)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 4, // 互动(咨询/购买行为)
|
||||
'source' => 0, // 本站
|
||||
'sourceData' => $found,
|
||||
'remark' => '性格测试画像',
|
||||
'uniqueId' => 'wxmp_' . $userId . '_' . date('YmdH'), // 同一小时内去重
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存客宝签名
|
||||
* 规则(来自接口文档 §2.3):
|
||||
* 1. 移除 sign / apiKey / portrait
|
||||
* 2. 移除值为 null 或空字符串的字段
|
||||
* 3. 按参数名 ASCII 升序排序
|
||||
* 4. 只取"值"按顺序拼接
|
||||
* 5. 第一次 MD5
|
||||
* 6. 拼接 apiKey 后第二次 MD5,得到最终签名
|
||||
*/
|
||||
private static function generateSign(array $params, string $apiKey): string
|
||||
{
|
||||
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||
|
||||
$params = array_filter($params, static function ($value) {
|
||||
return !is_null($value) && $value !== '';
|
||||
});
|
||||
|
||||
ksort($params);
|
||||
|
||||
$stringToSign = implode('', array_values($params));
|
||||
$firstMd5 = md5($stringToSign);
|
||||
|
||||
return md5($firstMd5 . $apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 cURL 调用存客宝接口
|
||||
*/
|
||||
private static function callApi(string $url, array $params): array
|
||||
{
|
||||
$payload = json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'Content-Length: ' . strlen($payload),
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
return ['success' => false, 'error' => 'curl:' . $curlError];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (is_array($data) && isset($data['code']) && (int) $data['code'] === 200) {
|
||||
return ['success' => true, 'data' => $data];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $data['message'] ?? 'unknown',
|
||||
'response' => $response,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\SystemDefaultEnterprise;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\model\Enterprise as EnterpriseModel;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
@@ -118,7 +119,7 @@ class Test extends BaseController
|
||||
if ($needPayUnlock || $profileIncomplete) {
|
||||
$data = self::filterFaceResultToPreview($data);
|
||||
}
|
||||
} elseif ($requiresPayment && !$isPaid) {
|
||||
} elseif ($needPayUnlock || $profileIncomplete) {
|
||||
$data = $this->filterResultToPartial($testType, $data);
|
||||
}
|
||||
}
|
||||
@@ -392,6 +393,22 @@ class Test extends BaseController
|
||||
$raw = $row['resultData'] ?? ($row['result'] ?? null);
|
||||
$data = $this->decodeResultDataPayload($raw);
|
||||
|
||||
$userIdRow = (int) ($row['userId'] ?? 0);
|
||||
$profileIncomplete = $userIdRow > 0 && !self::isWechatProfileComplete($userIdRow);
|
||||
$requiresPayment = (int) ($row['requiresPayment'] ?? 0);
|
||||
$isPaid = (int) ($row['isPaid'] ?? 0);
|
||||
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
|
||||
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
|
||||
if ($data !== []) {
|
||||
if (in_array($testType, ['face', 'ai'], true)) {
|
||||
if ($needPayUnlock || $profileIncomplete) {
|
||||
$data = self::filterFaceResultToPreview($data);
|
||||
}
|
||||
} elseif ($needPayUnlock || $profileIncomplete) {
|
||||
$data = $this->filterResultToPartial($testType, $data);
|
||||
}
|
||||
}
|
||||
|
||||
$resultText = '';
|
||||
$emoji = '';
|
||||
$typeName = '';
|
||||
@@ -534,7 +551,7 @@ class Test extends BaseController
|
||||
if ($needPaymentToUnlock || $profileIncomplete) {
|
||||
$data = self::filterFaceResultToPreview($data);
|
||||
}
|
||||
} elseif ($needPaymentToUnlock) {
|
||||
} elseif ($needPaymentToUnlock || $profileIncomplete) {
|
||||
$data = $this->filterResultToPartial($testType, $data);
|
||||
}
|
||||
}
|
||||
@@ -549,6 +566,7 @@ class Test extends BaseController
|
||||
'paidAmount' => $paidAmount,
|
||||
'amountYuan' => $paidAmount > 0 ? round($paidAmount / 100, 2) : 0,
|
||||
'needPaymentToUnlock'=> $needPaymentToUnlock,
|
||||
'profileIncomplete' => $profileIncomplete,
|
||||
'orderId' => isset($row['orderId']) ? (int) $row['orderId'] : null,
|
||||
'paidAt' => isset($row['paidAt']) ? (int) $row['paidAt'] : null,
|
||||
]);
|
||||
@@ -618,6 +636,13 @@ class Test extends BaseController
|
||||
if (!empty($boundEid)) {
|
||||
$pricingEnterpriseId = (int) $boundEid; // admin_personal + eid
|
||||
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
|
||||
} else {
|
||||
// 主入口无参数且未绑定企业:回落超管配置的默认企业(与个人版 getEnterpriseIdForApiPayload 不传参一致,由服务端统一落库)
|
||||
$defEid = SystemDefaultEnterprise::getId();
|
||||
if ($defEid !== null) {
|
||||
$pricingEnterpriseId = $defEid;
|
||||
$writeEnterpriseId = $defEid;
|
||||
}
|
||||
}
|
||||
}
|
||||
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
|
||||
@@ -656,6 +681,19 @@ class Test extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
// 佣金结算失败不阻断测试保存
|
||||
}
|
||||
|
||||
// 存客宝线索:MBTI/DISC/PDP 等问卷与人脸一致——免费(requiresPayment=0)测完即报;需付费则按企业后台「上报时机」
|
||||
try {
|
||||
\app\controller\api\CrmReport::reportTestCompletion(
|
||||
$userId,
|
||||
$testType,
|
||||
(int) $id,
|
||||
(int) ($writeEnterpriseId ?? 0),
|
||||
$enterpriseId !== null ? 'enterprise' : 'personal'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 上报失败不阻断
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return error('保存测试结果失败', 500);
|
||||
@@ -720,12 +758,13 @@ class Test extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 未付费时只返回部分数据(完整数据需付费解锁)
|
||||
* 问卷/简历类:未付费或资料未完善时只返回部分数据(与实例方法 filterResultToPartial 一致)
|
||||
*
|
||||
* @param string $testType
|
||||
* @param array|null $data 原始 resultData
|
||||
* @return array|null 脱敏后的数据
|
||||
* @param array|null $data
|
||||
* @return array|null
|
||||
*/
|
||||
protected function filterResultToPartial(string $testType, $data)
|
||||
public static function filterResultToPartialStatic(string $testType, $data)
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return $data;
|
||||
@@ -748,9 +787,32 @@ class Test extends BaseController
|
||||
'locked' => true,
|
||||
];
|
||||
}
|
||||
if ($testType === 'resume') {
|
||||
$preview = '';
|
||||
if (!empty($data['content']) && is_string($data['content'])) {
|
||||
$preview = self::truncatePreviewText(strip_tags($data['content']), 72);
|
||||
} elseif (!empty($data['overview']) && is_string($data['overview'])) {
|
||||
$preview = self::truncatePreviewText(strip_tags($data['overview']), 72);
|
||||
}
|
||||
|
||||
return [
|
||||
'locked' => true,
|
||||
'content' => $preview,
|
||||
'_structured' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未付费时只返回部分数据(完整数据需付费解锁)
|
||||
*/
|
||||
protected function filterResultToPartial(string $testType, $data)
|
||||
{
|
||||
return self::filterResultToPartialStatic($testType, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信用户资料是否与小程序 isProfileComplete 一致:头像、昵称、手机号必填
|
||||
*/
|
||||
|
||||
@@ -61,9 +61,9 @@ class Settings extends BaseController
|
||||
'defaultEnterpriseId' => null,
|
||||
];
|
||||
$systemOut = $systemDefault;
|
||||
if ($systemConfig && !empty($systemConfig->value)) {
|
||||
if ($systemConfig && $systemConfig->value !== null && $systemConfig->value !== '') {
|
||||
$raw = $systemConfig->value;
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
$decoded = is_array($raw) ? $raw : (is_string($raw) ? json_decode($raw, true) : null);
|
||||
if (is_array($decoded)) {
|
||||
$systemOut = array_merge($systemDefault, $decoded);
|
||||
}
|
||||
@@ -84,6 +84,16 @@ class Settings extends BaseController
|
||||
}
|
||||
$systemOut['maintenanceMode'] = $maint;
|
||||
|
||||
// 与前端 el-option 的 number value 对齐,避免类型不一致导致下拉不反显
|
||||
if (array_key_exists('defaultEnterpriseId', $systemOut)) {
|
||||
$de = $systemOut['defaultEnterpriseId'];
|
||||
if ($de === '' || $de === null || (int) $de <= 0) {
|
||||
$systemOut['defaultEnterpriseId'] = null;
|
||||
} else {
|
||||
$systemOut['defaultEnterpriseId'] = (int) $de;
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
'system' => $systemOut,
|
||||
'reviewMode' => ['enabled' => $maint],
|
||||
@@ -94,7 +104,7 @@ class Settings extends BaseController
|
||||
'newEnterpriseNotify' => true
|
||||
],
|
||||
'prompts' => $promptsConfig && !empty($promptsConfig->value) ? $promptsConfig->value : [
|
||||
'faceAnalyze' => '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
|
||||
'faceAnalyze' => '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/无尾熊/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
|
||||
'reportSummary' => ''
|
||||
],
|
||||
'reportRequiresPayment' => $reportRequiresPaymentConfig && !empty($reportRequiresPaymentConfig->value) ? $reportRequiresPaymentConfig->value : ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0],
|
||||
|
||||
@@ -35,9 +35,11 @@ class SystemConfig extends Model
|
||||
'updatedAt' => 'integer',
|
||||
];
|
||||
|
||||
// JSON字段自动转换
|
||||
protected $json = ['value'];
|
||||
|
||||
/**
|
||||
* 注意:不要同时声明 $json = ['value'] 与下面的 set/getValueAttr。
|
||||
* Think 会对 JSON 字段再编码一次,导致入库后结构损坏,部分键(如 defaultEnterpriseId)丢失或读不出。
|
||||
* 统一由修改器负责数组 ⇄ JSON 字符串。
|
||||
*/
|
||||
/**
|
||||
* 配置值修改器(自动转换为JSON)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user