feat: 第三方渠道上下文与开放平台服务接入

1、修复了企业版/个人版在第三方入口下的人脸与测评跳转问题。

2、新增 ThirdPartyChannel/OpenPlatformService 及上下文 thirdPartyContext,补充相关迁移。

3、调整 Analyze/Auth/Test 等接口与小程序入口逻辑。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-03 14:18:34 +08:00
parent 48c4d551d2
commit 1a66e77ab7
25 changed files with 1401 additions and 368 deletions

View File

@@ -0,0 +1,271 @@
<?php
namespace app\common\service;
use app\common\PdpDiscResultText;
use think\facade\Db;
use think\facade\Log;
/**
* 第三方开放平台按手机号回写测评结果POST /api/open
* 仅当用户存在渠道绑定字段且配置了 OPEN_PLATFORM_URL / OPEN_PLATFORM_API_KEY 时推送
*/
class OpenPlatformService
{
public static function hasThirdPartyBinding(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$u = Db::name('wechat_users')
->where('id', $userId)
->field('ext_uid,third_party_phone')
->find();
if (!$u) {
return false;
}
foreach (['ext_uid', 'third_party_phone'] as $k) {
if (trim((string) ($u[$k] ?? '')) !== '') {
return true;
}
}
return false;
}
/**
* 开放平台要求的 phone**优先 third_party_phone**,无则回落主库 phone
*/
public static function resolveNotifyPhone(array $wechatRow): ?string
{
$tp = trim((string) ($wechatRow['third_party_phone'] ?? ''));
$main = trim((string) ($wechatRow['phone'] ?? ''));
$norm = ThirdPartyChannelService::normalizePhone($tp !== '' ? $tp : $main);
if ($norm !== null) {
return $norm;
}
$digits = preg_replace('/\D+/', '', $tp !== '' ? $tp : $main);
if ($digits !== '' && strlen($digits) >= 5 && strlen($digits) <= 20) {
return $digits;
}
return null;
}
/**
* 从本次提交的 result 数组生成开放平台单字段摘要
*
* @return array<string,string> 如 ['mbti'=>'INTJ'],失败返回 []
*/
public static function buildAssessmentPayload(string $testType, array $result): array
{
$testType = strtolower(trim($testType));
if (!in_array($testType, ['mbti', 'disc', 'pdp', 'face', 'ai'], true)) {
return [];
}
if ($testType === 'face' || $testType === 'ai') {
$mbtiShort = '';
if (isset($result['mbti']['type'])) {
$mbtiShort = trim((string) $result['mbti']['type']);
} elseif (isset($result['mbti']) && !is_array($result['mbti'])) {
$mbtiShort = trim((string) $result['mbti']);
}
if ($mbtiShort !== '') {
$mbtiShort = self::formatMbtiForOpenPlatform($mbtiShort);
if ($mbtiShort === '') {
return [];
}
if (mb_strlen($mbtiShort) > 500) {
$mbtiShort = mb_substr($mbtiShort, 0, 500) . '…';
}
return ['mbti' => $mbtiShort];
}
$fa = $result['faceAnalysis'] ?? '';
if (is_string($fa) && trim($fa) !== '') {
$fa = preg_replace('/\s+/u', ' ', trim($fa));
$snippet = mb_strlen($fa) > 500 ? mb_substr($fa, 0, 500) . '…' : $fa;
return ['mbti' => $snippet];
}
return [];
}
$text = '';
switch ($testType) {
case 'mbti':
$t = $result['mbtiType'] ?? $result['mbti'] ?? '';
$raw = is_string($t) ? trim($t) : (is_numeric($t) ? (string) $t : '');
$text = self::formatMbtiForOpenPlatform($raw);
break;
case 'disc':
$text = PdpDiscResultText::discTopTwo($result);
if ($text === '') {
$dominantType = $result['dominantType'] ?? $result['disc'] ?? '';
$text = (is_string($dominantType) || is_numeric($dominantType) ? (string) $dominantType : '') . '型';
}
$text = self::formatDiscForOpenPlatform($text);
break;
case 'pdp':
$text = PdpDiscResultText::pdpTopTwo($result);
if ($text === '') {
$text = (string) ($result['description']['type'] ?? $result['pdp'] ?? '');
}
$text = self::formatPdpForOpenPlatform($text);
break;
}
$text = trim($text);
if ($text === '') {
return [];
}
if (mb_strlen($text) > 500) {
$text = mb_substr($text, 0, 500) . '…';
}
return [$testType => $text];
}
/**
* 测评结果写入成功后调用mbti/disc/pdp/face(ai)且用户有第三方绑定ext_uid 或 third_party_phone
*
* 开放平台仅接收 mbti/disc/pdp人脸结果优先推推断的 MBTI无则推 faceAnalysis 摘要到 mbti 字段
*
* @param array<string,mixed> $result 与写入 test_results 前相同的数组
*/
public static function notifyQuestionnaireIfNeeded(int $userId, string $testType, $result): void
{
if ($userId <= 0 || !is_array($result)) {
return;
}
$testType = strtolower(trim($testType));
if (!in_array($testType, ['mbti', 'disc', 'pdp', 'face', 'ai'], true)) {
return;
}
$baseUrl = trim((string) env('OPEN_PLATFORM_URL', ''));
$apiKey = trim((string) env('OPEN_PLATFORM_API_KEY', ''));
if ($baseUrl === '' || $apiKey === '') {
return;
}
if (!self::hasThirdPartyBinding($userId)) {
return;
}
$wechatRow = Db::name('wechat_users')
->where('id', $userId)
->field('phone,third_party_phone')
->find();
if (!$wechatRow) {
return;
}
$phone = self::resolveNotifyPhone($wechatRow);
if ($phone === null || $phone === '') {
Log::warning('OpenPlatform skip: no phone', ['userId' => $userId]);
return;
}
$assessment = self::buildAssessmentPayload($testType, $result);
if ($assessment === []) {
return;
}
$url = rtrim(trim($baseUrl), '/');
if (!preg_match('#/api/open/user/profile$#', $url)) {
$url .= '/api/open/user/profile';
}
$body = array_merge(['phone' => $phone], $assessment);
$headers = [
// requestCurl 对 SSL 默认会关闭校验,避免「找不到本地 issuer certificate」阻断对接
'Content-Type:application/json',
'Authorization: Bearer ' . $apiKey
];
try {
$respBody = \requestCurl($url, $body, 'POST', $headers, 'json');
} catch (\Throwable $e) {
Log::warning('OpenPlatform requestCurl exception: ' . $e->getMessage(), [
'userId' => $userId,
'url' => $url,
]);
return;
}
if (!is_string($respBody)) {
$respBody = '';
}
if ($respBody === '') {
// requestCurl 没返回 http code这里只做响应为空的弱提示
Log::warning('OpenPlatform empty response', [
'userId' => $userId,
'url' => $url,
'body' => [
'phone' => $phone,
// 只回显 keys避免日志把完整结果打爆
'keys' => array_keys($assessment),
],
]);
}
}
/**
* MBTI四字母类型如 ENFJ兼容文案中带 ENFJ-A、括号等
*/
private static function formatMbtiForOpenPlatform(string $raw): string
{
$raw = trim($raw);
if ($raw === '') {
return '';
}
if (preg_match('/\b([EI][NS][FT][JP])\b/i', $raw, $m)) {
return strtoupper($m[1]);
}
$compact = strtoupper(preg_replace('/[^EINSFTPJ]/i', '', $raw));
if (strlen($compact) >= 4 && preg_match('/^[EI][NS][FT][JP]$/', substr($compact, 0, 4))) {
return substr($compact, 0, 4);
}
$letters = strtoupper(preg_replace('/[^A-Z]/', '', $raw));
if (strlen($letters) >= 4) {
return substr($letters, 0, 4);
}
return $raw;
}
/**
* DISCD+C去掉尾缀「型」
*/
private static function formatDiscForOpenPlatform(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
return preg_replace('/型$/u', '', $text);
}
/**
* PDP无尾熊+变色龙(各段去掉尾缀「型」)
*/
private static function formatPdpForOpenPlatform(string $text): string
{
$text = trim($text);
if ($text === '') {
return '';
}
$parts = preg_split('/\+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$parts = array_map(static function ($p) {
return preg_replace('/型$/u', '', trim($p));
}, $parts);
$parts = array_values(array_filter($parts, static function ($p) {
return $p !== '';
}));
return implode('+', $parts);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 第三方渠道透传userid / phone / tid 解析与 wechat_users 写入(仅存用户表)
*/
class ThirdPartyChannelService
{
public static function parseFromRequestArray(array $input): array
{
$tp = $input['thirdParty'] ?? null;
if (!is_array($tp)) {
return ['userid' => '', 'phone' => '', 'tid' => ''];
}
$userid = isset($tp['userid']) ? trim((string) $tp['userid']) : '';
$phone = isset($tp['phone']) ? trim((string) $tp['phone']) : '';
$tid = isset($tp['tid']) ? trim((string) $tp['tid']) : '';
return [
'userid' => $userid,
'phone' => $phone,
'tid' => $tid,
];
}
public static function normalizePhone(?string $raw): ?string
{
if ($raw === null || $raw === '') {
return null;
}
$digits = preg_replace('/\D+/', '', (string) $raw);
if ($digits === '') {
return null;
}
if (strlen($digits) > 11 && substr($digits, 0, 2) === '86') {
$digits = substr($digits, -11);
}
if (strlen($digits) < 5 || strlen($digits) > 20) {
return null;
}
return $digits;
}
/**
* 按主手机号列查找用户(规范化后比对)
*
* @return array|null 表行
*/
public static function findUserByNormalizedPhone(string $norm): ?array
{
if ($norm === '') {
return null;
}
$row = Db::name('wechat_users')->where('phone', $norm)->find();
if ($row) {
return $row;
}
$candidates = Db::name('wechat_users')
->whereNotNull('phone')
->where('phone', '<>', '')
->where('phone', 'like', '%' . $norm)
->limit(50)
->select()
->toArray();
foreach ($candidates as $r) {
if (self::normalizePhone($r['phone'] ?? '') === $norm) {
return $r;
}
}
return null;
}
/**
* 合并第三方字段到指定用户行(有传则写)
* - 有渠道 phone始终更新 third_party_phone含老用户命中手机号与透传一致时
* - 主字段 phone 为空时:用渠道号码写入 phone新号、未绑手机用户
*/
public static function applyToUserId(int $userId, array $thirdParty): void
{
if ($userId <= 0) {
return;
}
$data = [];
if ($thirdParty['userid'] !== '') {
$data['ext_uid'] = mb_substr($thirdParty['userid'], 0, 191);
}
if ($thirdParty['tid'] !== '') {
$data['third_party_tid'] = mb_substr($thirdParty['tid'], 0, 191);
}
$norm = null;
if ($thirdParty['phone'] !== '') {
$norm = self::normalizePhone($thirdParty['phone']);
}
if ($norm !== null) {
$data['third_party_phone'] = mb_substr($norm, 0, 32);
$row = Db::name('wechat_users')->where('id', $userId)->field('phone')->find();
$mainPhone = trim((string) ($row['phone'] ?? ''));
if ($mainPhone === '') {
$data['phone'] = mb_substr($norm, 0, 20);
}
}
if (empty($data)) {
return;
}
$data['updatedAt'] = time();
Db::name('wechat_users')->where('id', $userId)->update($data);
}
}

View File

@@ -208,6 +208,14 @@ class Analyze extends BaseController
} catch (\Throwable $e) {
// 上报失败不阻断
}
try {
if (is_array($storePayload)) {
\app\common\service\OpenPlatformService::notifyQuestionnaireIfNeeded($userId, 'face', $storePayload);
}
} catch (\Throwable $e) {
// 第三方开放平台失败不阻断
}
}
} catch (\Throwable $e) {
// 写入失败不影响返回分析结果

View File

@@ -7,6 +7,7 @@ use app\model\WechatUser;
use app\common\service\JwtService;
use app\common\service\WechatService;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\ThirdPartyChannelService;
use think\facade\Request;
use think\facade\Db;
@@ -206,35 +207,104 @@ class Auth extends BaseController
/**
* 微信小程序登录code 换 openid查/建用户,返回 token 与用户信息
* POST api/auth/wechat body: { "code": "xxx" }
* POST api/auth/wechat body: { "code", "enterpriseId"?, "thirdParty"?: { "userid","phone","tid" } }
* 第三方 phone 命中已有主手机号用户时不新建行openid 绑到该用户(详见 ThirdPartyChannelService
* @return \think\response\Json
*/
public function wechatLogin()
{
$code = Request::param('code', '');
$rawContent = Request::getContent();
$input = [];
if ($rawContent !== '') {
$input = json_decode($rawContent, true) ?: [];
}
if ($input === []) {
$input = Request::post() ?: Request::param();
}
$code = $input['code'] ?? Request::param('code', '');
if ($code === '') {
return error('缺少 code', 400);
}
$thirdParty = ThirdPartyChannelService::parseFromRequestArray(is_array($input) ? $input : []);
$loginEnterpriseId = isset($input['enterpriseId']) && (int) $input['enterpriseId'] > 0
? (int) $input['enterpriseId']
: null;
$session = WechatService::jscode2session($code);
if (isset($session['errcode']) && $session['errcode'] !== 0) {
return error($session['errmsg'] ?? '微信登录失败', 400);
}
$openid = $session['openid'];
//$openid = 'oucCB15WDKCdwfNo-fpyS72iY5IQ';
$openid = $session['openid'];
$sessionKey = $session['session_key'] ?? '';
$unionid = $session['unionid'] ?? null;
$unionid = $session['unionid'] ?? null;
$wechatUser = Db::name('wechat_users')->where('openid', $openid)->find();
$now = time();
$ip = Request::ip();
$ip = Request::ip();
$loginEnterpriseId = isset($input['enterpriseId']) && (int) $input['enterpriseId'] > 0
? (int) $input['enterpriseId']
$userByOpenid = Db::name('wechat_users')->where('openid', $openid)->find();
$phoneNorm = ThirdPartyChannelService::normalizePhone($thirdParty['phone']);
$userByPhone = $phoneNorm !== null
? ThirdPartyChannelService::findUserByNormalizedPhone($phoneNorm)
: null;
if ($wechatUser) {
$wechatUser = null;
if ($userByOpenid && $userByPhone && (int) $userByOpenid['id'] !== (int) $userByPhone['id']) {
$b = $userByPhone;
$a = $userByOpenid;
$bOpenid = trim((string) ($b['openid'] ?? ''));
if ($bOpenid !== '') {
return error('该手机号已绑定其他微信号,请使用原微信打开', 409);
}
$orphanOpenid = 'orphan_' . $a['id'] . '_' . $now;
if (strlen($orphanOpenid) > 64) {
$orphanOpenid = substr($orphanOpenid, 0, 64);
}
Db::name('wechat_users')->where('id', $a['id'])->update([
'openid' => $orphanOpenid,
'updatedAt' => $now,
]);
$mergeUpdate = [
'openid' => $openid,
'unionid' => $unionid,
'sessionKey' => $sessionKey,
'lastLoginAt' => $now,
'lastLoginIp' => $ip,
'updatedAt' => $now,
];
$existingEidB = $b['enterpriseId'] ?? null;
if (($existingEidB === null || $existingEidB === '' || (int) $existingEidB === 0) && $loginEnterpriseId !== null) {
$mergeUpdate['enterpriseId'] = $loginEnterpriseId;
}
Db::name('wechat_users')->where('id', $b['id'])->update($mergeUpdate);
ThirdPartyChannelService::applyToUserId((int) $b['id'], $thirdParty);
$wechatUser = Db::name('wechat_users')->where('id', $b['id'])->find();
} elseif (!$userByOpenid && $userByPhone) {
$b = $userByPhone;
$bOpenid = trim((string) ($b['openid'] ?? ''));
if ($bOpenid !== '') {
return error('该手机号已绑定其他微信号,请使用原微信打开', 409);
}
$mergeUpdate = [
'openid' => $openid,
'unionid' => $unionid,
'sessionKey' => $sessionKey,
'lastLoginAt' => $now,
'lastLoginIp' => $ip,
'updatedAt' => $now,
];
$existingEidB = $b['enterpriseId'] ?? null;
if (($existingEidB === null || $existingEidB === '' || (int) $existingEidB === 0) && $loginEnterpriseId !== null) {
$mergeUpdate['enterpriseId'] = $loginEnterpriseId;
}
Db::name('wechat_users')->where('id', $b['id'])->update($mergeUpdate);
ThirdPartyChannelService::applyToUserId((int) $b['id'], $thirdParty);
$wechatUser = Db::name('wechat_users')->where('id', $b['id'])->find();
} elseif ($userByOpenid) {
$wechatUser = $userByOpenid;
$updateFields = [
'sessionKey' => $sessionKey,
'unionid' => $unionid,
@@ -242,12 +312,12 @@ class Auth extends BaseController
'lastLoginIp' => $ip,
'updatedAt' => $now,
];
// 老用户未绑定企业时,从本次登录上下文补写
$existingEid = $wechatUser['enterpriseId'] ?? null;
if (($existingEid === null || $existingEid === '' || (int) $existingEid === 0) && $loginEnterpriseId !== null) {
$updateFields['enterpriseId'] = $loginEnterpriseId;
}
Db::name('wechat_users')->where('id', $wechatUser['id'])->update($updateFields);
ThirdPartyChannelService::applyToUserId((int) $wechatUser['id'], $thirdParty);
$wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find();
} else {
$insertData = [
@@ -255,26 +325,28 @@ class Auth extends BaseController
'unionid' => $unionid,
'sessionKey' => $sessionKey,
'nickname' => null,
'avatar' => null,
'phone' => null,
'gender' => 0,
'country' => null,
'province' => null,
'city' => null,
'status' => 1,
'avatar' => null,
'phone' => null,
'gender' => 0,
'country' => null,
'province' => null,
'city' => null,
'status' => 1,
'lastLoginAt' => $now,
'lastLoginIp' => $ip,
'createdAt' => $now,
'updatedAt' => $now,
'createdAt' => $now,
'updatedAt' => $now,
];
if ($loginEnterpriseId !== null) {
$insertData['enterpriseId'] = $loginEnterpriseId;
}
$id = Db::name('wechat_users')->insertGetId($insertData);
$id = Db::name('wechat_users')->insertGetId($insertData);
$wechatUser = Db::name('wechat_users')->where('id', $id)->find();
ThirdPartyChannelService::applyToUserId((int) $id, $thirdParty);
$wechatUser = Db::name('wechat_users')->where('id', $id)->find();
}
if (($wechatUser['status'] ?? 1) != 1) {
if (!$wechatUser || ($wechatUser['status'] ?? 1) != 1) {
return error('账号已被禁用', 403);
}
@@ -285,7 +357,6 @@ class Auth extends BaseController
$token = JwtService::generateToken($payload);
$userId = (int) $wechatUser['id'];
// 企业绑定取自 wechat_users.enterpriseId企业分享测试链接时更新个人分享不更新
$enterpriseId = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null
? (int) $wechatUser['enterpriseId']
: null;

View File

@@ -694,6 +694,15 @@ class Test extends BaseController
} catch (\Throwable $e) {
// 上报失败不阻断
}
// 第三方开放平台ext_uid 或 third_party_phone 有值且配置 URL/Key 时推送(含人脸走 mbti 字段)
try {
if (is_array($result)) {
\app\common\service\OpenPlatformService::notifyQuestionnaireIfNeeded($userId, $testType, $result);
}
} catch (\Throwable $e) {
// 对接失败不阻断
}
}
} catch (\Throwable $e) {
return error('保存测试结果失败', 500);
@@ -1021,5 +1030,6 @@ class Test extends BaseController
'usingSuperAdminBank' => $resolvedEnterpriseId === null,
]);
}
}

View File

@@ -17,7 +17,10 @@ class WechatUser extends Model
'sessionKey' => 'string',
'nickname' => 'string',
'avatar' => 'string',
'phone' => 'string',
'phone' => 'string',
'ext_uid' => 'string',
'third_party_phone' => 'string',
'third_party_tid' => 'string',
'gender' => 'int',
'country' => 'string',
'province' => 'string',