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:
Ghost
2026-04-01 17:16:20 +08:00
parent 8abc5182af
commit ef19f6860e
63 changed files with 2718 additions and 1190 deletions

View File

@@ -10,7 +10,7 @@ class PdpDiscResultText
private const PDP_EN_TO_CN = [
'Tiger' => '老虎型',
'Peacock' => '孔雀型',
'Koala' => '考拉型',
'Koala' => '无尾熊型',
'Owl' => '猫头鹰型',
'Chameleon' => '变色龙型',
];

View 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;
}
}
}

View File

@@ -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);

View File

@@ -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处理可能的多重编码
*/

View File

@@ -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/CdiscAux=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/CdiscAux=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');
}
/**

View File

@@ -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 类目配置中的存客宝KEYconsultWechat字段
* @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 类目配置中的存客宝KEYconsultWechat字段
* @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());
}
}
/**
* 读取某企业某测评类型的 reportTimingafter_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,
];
}
}

View File

@@ -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 一致:头像、昵称、手机号必填
*/

View File

@@ -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],

View File

@@ -35,9 +35,11 @@ class SystemConfig extends Model
'updatedAt' => 'integer',
];
// JSON字段自动转换
protected $json = ['value'];
/**
* 注意:不要同时声明 $json = ['value'] 与下面的 set/getValueAttr。
* Think 会对 JSON 字段再编码一次,导致入库后结构损坏,部分键(如 defaultEnterpriseId丢失或读不出。
* 统一由修改器负责数组 ⇄ JSON 字符串。
*/
/**
* 配置值修改器自动转换为JSON
*/