feat: 第三方渠道上下文与开放平台服务接入
1、修复了企业版/个人版在第三方入口下的人脸与测评跳转问题。 2、新增 ThirdPartyChannel/OpenPlatformService 及上下文 thirdPartyContext,补充相关迁移。 3、调整 Analyze/Auth/Test 等接口与小程序入口逻辑。 Made-with: Cursor
This commit is contained in:
271
api/app/common/service/OpenPlatformService.php
Normal file
271
api/app/common/service/OpenPlatformService.php
Normal 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* DISC:D+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);
|
||||
}
|
||||
}
|
||||
113
api/app/common/service/ThirdPartyChannelService.php
Normal file
113
api/app/common/service/ThirdPartyChannelService.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
// 写入失败不影响返回分析结果
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 第三方跳转渠道字段(仅存 wechat_users)
|
||||
-- 执行前请确认表前缀:ThinkPHP 配置为 mbti_ 时物理表为 mbti_wechat_users
|
||||
|
||||
ALTER TABLE `mbti_wechat_users`
|
||||
ADD COLUMN `ext_uid` varchar(191) NULL DEFAULT NULL COMMENT '合作方用户ID(入参userid)' AFTER `phone`,
|
||||
ADD COLUMN `third_party_phone` varchar(32) NULL DEFAULT NULL COMMENT '渠道透传手机号(不落主.phone时记录)' AFTER `ext_uid`,
|
||||
ADD COLUMN `third_party_tid` varchar(191) NULL DEFAULT NULL COMMENT '合作方任务/活动ID' AFTER `third_party_phone`,
|
||||
ADD INDEX `idx_ext_uid` (`ext_uid`(64)),
|
||||
ADD INDEX `idx_phone_lookup` (`phone`);
|
||||
@@ -68,6 +68,11 @@ App({
|
||||
// 记录场景值供埋点使用
|
||||
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
|
||||
|
||||
try {
|
||||
const { mergeThirdPartyFromQuery } = require('./utils/thirdPartyContext.js')
|
||||
mergeThirdPartyFromQuery((launchOptions && launchOptions.query) || {})
|
||||
} catch (e) {}
|
||||
|
||||
// 加载本地存储的数据
|
||||
this.loadStoredData()
|
||||
|
||||
@@ -223,12 +228,21 @@ App({
|
||||
const eid = getEffectiveEnterpriseId()
|
||||
if (eid) loginData.enterpriseId = eid
|
||||
} catch (e) {}
|
||||
try {
|
||||
const { getThirdPartyForLoginBody } = require('./utils/thirdPartyContext.js')
|
||||
Object.assign(loginData, getThirdPartyForLoginBody())
|
||||
} catch (e) {}
|
||||
wx.request({
|
||||
url,
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: loginData,
|
||||
success: (response) => {
|
||||
if (response.statusCode === 200 && response.data && response.data.code === 409 && response.data.message) {
|
||||
try {
|
||||
wx.showToast({ title: String(response.data.message), icon: 'none', duration: 3000 })
|
||||
} catch (e) {}
|
||||
}
|
||||
if (response.statusCode === 200 && response.data && response.data.code === 200) {
|
||||
const data = response.data.data || {}
|
||||
const { token, user } = data
|
||||
|
||||
@@ -48,6 +48,10 @@ Page({
|
||||
eid
|
||||
})
|
||||
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
|
||||
if (eid > 0) app.globalData.enterpriseIdFromScene = eid
|
||||
|
||||
// 企业版分销绑定:uid > 0 且 eid > 0 时触发
|
||||
|
||||
@@ -17,7 +17,10 @@ Page({
|
||||
return !!(app.globalData.reviewMode || app.globalData.maintenanceMode)
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
this.setData({ reviewMode: this._audit() })
|
||||
const tc = app.globalData.textConfig
|
||||
if (tc && tc.aiAnalysisText) {
|
||||
|
||||
@@ -75,6 +75,10 @@ Page({
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
|
||||
const uid = parseInt(sceneParams.uid || (options && options.uid) || 0, 10)
|
||||
const eid = parseInt(sceneParams.eid || (options && options.eid) || 0, 10)
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ Page({
|
||||
aiAnalysisText: '分析'
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
const tc = app.globalData.textConfig
|
||||
if (tc && tc.aiAnalysisText) {
|
||||
this.setData({ aiAnalysisText: tc.aiAnalysisText })
|
||||
|
||||
@@ -41,7 +41,7 @@ Page({
|
||||
],
|
||||
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
|
||||
testResultId: null,
|
||||
hasReloadedAfterPay: false
|
||||
hasReloadedAfterPay: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
<!--pages/result/disc.wxml - DISC结果(按旧版模板重构)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">您的DISC性格类型</text>
|
||||
<text class="type-value">{{typeSummaryLine}}</text>
|
||||
<text class="type-description" wx:if="{{result.description && result.description.description}}">{{result.description.description}}</text>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整DISC报告</text>
|
||||
<text class="paywall-fake-line">• 四维得分详情</text>
|
||||
<text class="paywall-fake-line">• 性格特征与优劣势</text>
|
||||
<text class="paywall-fake-line">• 职业匹配建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 DISC 报告。</text>
|
||||
<view class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="section-title">DISC得分详情</text>
|
||||
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
|
||||
<view class="score-header">
|
||||
<text class="score-label">{{item.label}}</text>
|
||||
<text class="score-value">{{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">主要性格特征分析</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">职业匹配度分析</text>
|
||||
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!--pages/result/disc.wxml - DISC结果(按旧版模板重构)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">您的DISC性格类型</text>
|
||||
<text class="type-value">{{typeSummaryLine}}</text>
|
||||
<text class="type-description" wx:if="{{result.description && result.description.description}}">{{result.description.description}}</text>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整DISC报告</text>
|
||||
<text class="paywall-fake-line">• 四维得分详情</text>
|
||||
<text class="paywall-fake-line">• 性格特征与优劣势</text>
|
||||
<text class="paywall-fake-line">• 职业匹配建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 DISC 报告。</text>
|
||||
<view class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="section-title">DISC得分详情</text>
|
||||
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
|
||||
<view class="score-header">
|
||||
<text class="score-label">{{item.label}}</text>
|
||||
<text class="score-value">{{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">主要性格特征分析</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">职业匹配度分析</text>
|
||||
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
@@ -27,7 +27,7 @@ Page({
|
||||
},
|
||||
testResultId: null,
|
||||
hasReloadedAfterPay: false,
|
||||
hasPhone: false
|
||||
hasPhone: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
<!--pages/result/mbti.wxml - MBTI结果页面(支持付费墙)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">您的MBTI性格类型</text>
|
||||
<text class="type-value">{{result.mbtiType}}</text>
|
||||
<text class="type-title">{{mbtiDesc.title}}</text>
|
||||
<text class="type-description">{{mbtiDesc.description}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 付费墙:未解锁时显示 -->
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整性格分析</text>
|
||||
<text class="paywall-fake-line">• 四维得分与主导倾向</text>
|
||||
<text class="paywall-fake-line">• 优势与需要注意的方面</text>
|
||||
<text class="paywall-fake-line">• 职业匹配与人际关系建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<!-- 未有手机号:使用微信系统手机号授权 -->
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForMbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<!-- 已有手机号:普通按钮,直接解锁 -->
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整性格分析。</text>
|
||||
<button class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="dimensions-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<view class="dimension-item" wx:for="{{dimensions}}" wx:key="key">
|
||||
<view class="dimension-labels">
|
||||
<text class="label-left">{{item.left}}</text>
|
||||
<text class="label-right">{{item.right}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar" style="width: {{item.percentage}}%"></view>
|
||||
</view>
|
||||
<view class="dimension-values">
|
||||
<text class="value-left">{{item.dominant}}</text>
|
||||
<text class="value-right">{{item.percentage}}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">性格特征分析</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{mbtiDesc.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{mbtiDesc.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
|
||||
<text class="card-title">职业匹配度分析</text>
|
||||
<view class="career-item" wx:for="{{mbtiDesc.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid) && mbtiDesc.relationships}}">
|
||||
<text class="card-title">人际关系分析</text>
|
||||
<text class="relationship-text">{{mbtiDesc.relationships}}</text>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!--pages/result/mbti.wxml - MBTI结果页面(支持付费墙)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">您的MBTI性格类型</text>
|
||||
<text class="type-value">{{result.mbtiType}}</text>
|
||||
<text class="type-title">{{mbtiDesc.title}}</text>
|
||||
<text class="type-description">{{mbtiDesc.description}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 付费墙:未解锁时显示 -->
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整性格分析</text>
|
||||
<text class="paywall-fake-line">• 四维得分与主导倾向</text>
|
||||
<text class="paywall-fake-line">• 优势与需要注意的方面</text>
|
||||
<text class="paywall-fake-line">• 职业匹配与人际关系建议</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<!-- 未有手机号:使用微信系统手机号授权 -->
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:if="{{!hasPhone}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="onGetPhoneNumberForMbtiPay"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
<!-- 已有手机号:普通按钮,直接解锁 -->
|
||||
<button
|
||||
class="paywall-btn"
|
||||
wx:elif="{{hasPhone}}"
|
||||
bindtap="unlockFullReport"
|
||||
>
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整性格分析。</text>
|
||||
<button class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="dimensions-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<view class="dimension-item" wx:for="{{dimensions}}" wx:key="key">
|
||||
<view class="dimension-labels">
|
||||
<text class="label-left">{{item.left}}</text>
|
||||
<text class="label-right">{{item.right}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar" style="width: {{item.percentage}}%"></view>
|
||||
</view>
|
||||
<view class="dimension-values">
|
||||
<text class="value-left">{{item.dominant}}</text>
|
||||
<text class="value-right">{{item.percentage}}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">性格特征分析</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{mbtiDesc.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{mbtiDesc.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
|
||||
<text class="card-title">职业匹配度分析</text>
|
||||
<view class="career-item" wx:for="{{mbtiDesc.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid) && mbtiDesc.relationships}}">
|
||||
<text class="card-title">人际关系分析</text>
|
||||
<text class="relationship-text">{{mbtiDesc.relationships}}</text>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
@@ -44,7 +44,7 @@ Page({
|
||||
],
|
||||
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
|
||||
testResultId: null,
|
||||
hasReloadedAfterPay: false
|
||||
hasReloadedAfterPay: false,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
<!--pages/result/pdp.wxml - PDP结果(按旧版模板重构)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">PDP性格类型</text>
|
||||
<text class="type-value">{{typeSummaryLine}}</text>
|
||||
<text class="type-title" wx:if="{{result.description.title}}">{{result.description.title}}</text>
|
||||
<text class="type-description" wx:if="{{result.description.description}}">{{result.description.description}}</text>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整PDP报告</text>
|
||||
<text class="paywall-fake-line">• 五维得分详情</text>
|
||||
<text class="paywall-fake-line">• 性格特征与团队角色</text>
|
||||
<text class="paywall-fake-line">• 推荐职业</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 PDP 报告。</text>
|
||||
<view class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="section-title">PDP得分详情</text>
|
||||
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
|
||||
<view class="score-header">
|
||||
<text class="score-label">{{item.emoji}} {{item.label}}</text>
|
||||
<text class="score-value">{{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">性格特征</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">团队角色</text>
|
||||
<text class="relationship-text">{{result.description.teamRole}}</text>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">推荐职业</text>
|
||||
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!--pages/result/pdp.wxml - PDP结果(按旧版模板重构)-->
|
||||
<view class="result-page">
|
||||
<scroll-view class="content-scroll" scroll-y>
|
||||
<view class="content-container">
|
||||
<view class="type-card">
|
||||
<view class="type-header">
|
||||
<text class="type-label">PDP性格类型</text>
|
||||
<text class="type-value">{{typeSummaryLine}}</text>
|
||||
<text class="type-title" wx:if="{{result.description.title}}">{{result.description.title}}</text>
|
||||
<text class="type-description" wx:if="{{result.description.description}}">{{result.description.description}}</text>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
|
||||
<view class="paywall-content">
|
||||
<view class="paywall-blur">
|
||||
<text class="paywall-fake-title">完整PDP报告</text>
|
||||
<text class="paywall-fake-line">• 五维得分详情</text>
|
||||
<text class="paywall-fake-line">• 性格特征与团队角色</text>
|
||||
<text class="paywall-fake-line">• 推荐职业</text>
|
||||
</view>
|
||||
<view class="paywall-mask"></view>
|
||||
<view class="paywall-btn" bindtap="unlockFullReport">
|
||||
<text class="paywall-btn-main">解锁完整报告</text>
|
||||
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="paywall-card" wx:elif="{{result && result.locked}}">
|
||||
<view class="paywall-content">
|
||||
<text class="paywall-fake-title">完整报告需完善资料</text>
|
||||
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 PDP 报告。</text>
|
||||
<view class="paywall-btn" bindtap="goCompleteProfile">
|
||||
<text class="paywall-btn-main">去完善资料</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="section-title">PDP得分详情</text>
|
||||
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
|
||||
<view class="score-header">
|
||||
<text class="score-label">{{item.emoji}} {{item.label}}</text>
|
||||
<text class="score-value">{{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">性格特征</text>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">优势</text>
|
||||
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trait-section">
|
||||
<text class="trait-title">需要注意的方面</text>
|
||||
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
|
||||
<text class="trait-bullet">•</text>
|
||||
<text class="trait-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">团队角色</text>
|
||||
<text class="relationship-text">{{result.description.teamRole}}</text>
|
||||
</view>
|
||||
|
||||
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
|
||||
<text class="card-title">推荐职业</text>
|
||||
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
|
||||
<view class="career-dot"></view>
|
||||
<text class="career-text">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="action-section">
|
||||
<button class="btn btn-primary" open-type="share">
|
||||
<text class="btn-text">分享结果</text>
|
||||
</button>
|
||||
<view class="btn btn-outline" bindtap="retakeTest">
|
||||
<text class="btn-text-outline">重新测试</text>
|
||||
</view>
|
||||
<view class="btn btn-outline" bindtap="goHome">
|
||||
<text class="btn-text-outline">返回首页</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
@@ -9,7 +9,10 @@ Page({
|
||||
permDisc: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
this._syncPerms()
|
||||
},
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ Page({
|
||||
|
||||
timer: null,
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
loadQuestions('disc', {})
|
||||
.then((questions) => {
|
||||
if (!questions.length) {
|
||||
|
||||
@@ -26,7 +26,10 @@ Page({
|
||||
|
||||
timer: null,
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
loadQuestions('mbti', {})
|
||||
.then((questions) => {
|
||||
const total = questions.length
|
||||
|
||||
@@ -24,7 +24,10 @@ Page({
|
||||
|
||||
timer: null,
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
loadQuestions('pdp', {})
|
||||
.then((questions) => {
|
||||
if (!questions.length) {
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
{
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "Mbti",
|
||||
"setting": {
|
||||
"compileHotReLoad": true
|
||||
},
|
||||
"condition": {
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=uid%253D6%2526eid%253D6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=e_6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=e_6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
}
|
||||
]
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "Mbti",
|
||||
"setting": {
|
||||
"compileHotReLoad": true
|
||||
},
|
||||
"condition": {
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "MBTI",
|
||||
"pathName": "pages/test/mbti",
|
||||
"query": "phone=18649947301",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
},
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=uid%253D6%2526eid%253D6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=e_6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/enterprise/index",
|
||||
"pathName": "pages/enterprise/index",
|
||||
"query": "scene=e_6",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
126
miniprogram/utils/thirdPartyContext.js
Normal file
126
miniprogram/utils/thirdPartyContext.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 第三方渠道参数:query / scene 中的 userid、phone、tid(与分销 uid 分流)
|
||||
* 仅存 globalData,登录时透传 thirdParty
|
||||
*/
|
||||
|
||||
function safeDecode(v) {
|
||||
if (v == null || v === '') return ''
|
||||
const s = String(v).trim()
|
||||
if (!s) return ''
|
||||
try {
|
||||
return decodeURIComponent(s)
|
||||
} catch (e) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChannel(app) {
|
||||
if (!app.globalData.thirdPartyChannel) {
|
||||
app.globalData.thirdPartyChannel = {
|
||||
userid: '',
|
||||
phone: '',
|
||||
tid: '',
|
||||
capturedAt: 0,
|
||||
}
|
||||
}
|
||||
return app.globalData.thirdPartyChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 query 对象合并(同字段以首次非空为准)
|
||||
*/
|
||||
function mergeThirdPartyFromQuery(q) {
|
||||
if (!q || typeof q !== 'object') return false
|
||||
const app = getApp()
|
||||
const ch = ensureChannel(app)
|
||||
let changed = false
|
||||
const touch = (key, raw) => {
|
||||
const v = safeDecode(raw)
|
||||
if (!v) return
|
||||
if (!ch[key]) {
|
||||
ch[key] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
touch('userid', q.userid)
|
||||
touch('phone', q.phone)
|
||||
touch('tid', q.tid)
|
||||
if (changed) ch.capturedAt = Date.now()
|
||||
return changed
|
||||
}
|
||||
|
||||
/** App.onLaunch 使用 */
|
||||
function ingestThirdPartyFromLaunch(launchOptions) {
|
||||
const q = (launchOptions && launchOptions.query) || {}
|
||||
mergeThirdPartyFromQuery(q)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面 onLoad:合并 options + 已解码的 sceneParams
|
||||
* @returns {boolean} 是否有新字段写入(便于触发二次 silentLogin)
|
||||
*/
|
||||
function mergeFromPageOptions(options, sceneParams) {
|
||||
const o = {}
|
||||
if (options && typeof options === 'object') {
|
||||
if (options.userid != null) o.userid = options.userid
|
||||
if (options.phone != null) o.phone = options.phone
|
||||
if (options.tid != null) o.tid = options.tid
|
||||
}
|
||||
const c1 = mergeThirdPartyFromQuery(o)
|
||||
const c2 = mergeThirdPartyFromQuery(sceneParams || {})
|
||||
return !!(c1 || c2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意可作为外链入口的页面 onLoad 首行调用:解析 options.query + options.scene,合并第三方参数并视情况 silentLogin
|
||||
* @param {object} [options] Page.onLoad 第一个参数
|
||||
* @param {object} [appInst] App 实例,默认 getApp()
|
||||
*/
|
||||
function ingestThirdPartyOnPageLoad(options, appInst) {
|
||||
options = options || {}
|
||||
let rawScene = ''
|
||||
try {
|
||||
rawScene = options.scene ? decodeURIComponent(String(options.scene)) : ''
|
||||
} catch (e) {
|
||||
rawScene = options.scene ? String(options.scene) : ''
|
||||
}
|
||||
const sceneParams = {}
|
||||
if (rawScene) {
|
||||
rawScene.split('&').forEach((pair) => {
|
||||
const [k, v] = pair.split('=')
|
||||
if (k) sceneParams[k] = v || ''
|
||||
})
|
||||
}
|
||||
const changed = mergeFromPageOptions(options, sceneParams)
|
||||
const app = appInst || getApp()
|
||||
if (changed && app && typeof app.silentLogin === 'function') {
|
||||
app.silentLogin().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录 POST 体片段 */
|
||||
function getThirdPartyForLoginBody() {
|
||||
try {
|
||||
const app = getApp()
|
||||
const ch = app.globalData.thirdPartyChannel || {}
|
||||
const userid = String(ch.userid || '').trim()
|
||||
const phone = String(ch.phone || '').trim()
|
||||
const tid = String(ch.tid || '').trim()
|
||||
if (!userid && !phone && !tid) return {}
|
||||
const thirdParty = {}
|
||||
if (userid) thirdParty.userid = userid
|
||||
if (phone) thirdParty.phone = phone
|
||||
if (tid) thirdParty.tid = tid
|
||||
return { thirdParty }
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ingestThirdPartyFromLaunch,
|
||||
ingestThirdPartyOnPageLoad,
|
||||
mergeFromPageOptions,
|
||||
mergeThirdPartyFromQuery,
|
||||
getThirdPartyForLoginBody,
|
||||
}
|
||||
375
开发文档/第三方跳转测评参数方案.md
Normal file
375
开发文档/第三方跳转测评参数方案.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# 第三方跳转小程序测评 — 参数与记录方案
|
||||
|
||||
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
|
||||
|
||||
第三方系统(合作方 H5 / App / 短信链接等)引导用户进入**本小程序**完成 MBTI / DISC / PDP / 人脸等测评时,会在跳转链接中带业务参数。需要:
|
||||
|
||||
|
||||
|
||||
- 统一约定参数含义与传递方式;
|
||||
|
||||
- **识别**本次会话是否来自第三方;
|
||||
|
||||
- **持久化**第三方标识(**仅存 `wechat_users`**,不按每次测评拆行),便于对账、分润、回访或和合作方系统关联。
|
||||
|
||||
|
||||
|
||||
## 1.1 完整功能一览(端到端)
|
||||
|
||||
|
||||
|
||||
| 环节 | 行为 |
|
||||
|------|------|
|
||||
| 入口 | 合作方链接 / 码带 `userid`、`phone`、`tid`(及既有 `uid` 分销、`eid` 等);小程序解析后缓存并随请求上报 `thirdParty`。 |
|
||||
| 第三方识别 | `phone` 与 `userid` **任一有效**即视为第三方渠道。 |
|
||||
| 落库范围 | **只在 `wechat_users`** 写第三方字段;**不按测评次数**拆行;**不写 `test_results`。** |
|
||||
| 字段 | 入参 `userid` → **`ext_uid`(`extUid`)**;`phone`、`tid` → 同表约定列(如 `third_party_phone`、`third_party_tid`)。 |
|
||||
| **手机已存在** | 按规范化规则在 **`wechat_users` 查到该手机号已有用户**:**不注册新用户**;业务上**直接使用该账号**继续登录后流程与测评;库表对该用户**以更新合作方 `userid` 为主**,即 **仅/优先 `UPDATE ext_uid`**(见 6.4),**不因重复手机新增第二条用户**。 |
|
||||
| 手机不存在 / 仅 `userid` | 走现有注册或当前微信用户落库逻辑;收到 `thirdParty` 后照常 **`UPDATE`** 当前用户行上第三方列。 |
|
||||
| 微信侧 | openid 与「按手机命中的用户」如何绑定 / 合并,须单独约定,避免串号(6.4)。 |
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 2. 与「现状」的关系(避免混淆)
|
||||
|
||||
|
||||
|
||||
当前小程序已支持从 **扫码 `scene` / 分享 `query`** 解析例如 `uid`、`eid`,用于**站内分销邀请**(`wechat_users.id` 作为邀请人、`eid` 为企业),登录后走 `/api/distribution/bind`。
|
||||
|
||||
|
||||
|
||||
| 场景 | 参数含义 | 用途 |
|
||||
|
||||
|------|-----------|------|
|
||||
|
||||
| **现有** `uid` | 平台内邀请人用户 ID(数字) | 分销绑定 |
|
||||
|
||||
| **本方案** `userid` | **第三方侧用户标识**(由合作方定义,建议字符串) | 第三方对账、关联合作方账号 |
|
||||
|
||||
| **本方案** `phone` | **第三方透传的手机号**(需合规授权) | 同上,与第三方用户主档对齐 |
|
||||
|
||||
| **本方案** `tid`(可选) | 第三方任务单号 / 活动批次 / 项目 ID 等 | 细粒度归因、防重 |
|
||||
|
||||
|
||||
|
||||
**命名与冲突**:第三方链路统一使用 **`userid`(合作方用户)**,与站内分销 **`uid`(邀请人,平台用户 ID)** 并用不同参数名,**互不覆盖、语义分离**。合作方合同若仍写「对方 uid」,对接时**映射为 query/API 里的 `userid`** 即可。
|
||||
|
||||
|
||||
|
||||
下文第三方字段统称 **`phone`、`userid`、`tid`**(无 `ext_` 等业务前缀)。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 3. 第三方判定规则(业务约定)
|
||||
|
||||
|
||||
|
||||
满足以下**任一**条件,即视为**第三方渠道会话**:
|
||||
|
||||
|
||||
|
||||
- `phone` **有有效值**(非空、通过基础格式校验),或
|
||||
|
||||
- `userid` **有有效值**(非空字符串或非 0 数字,按合作方约定)
|
||||
|
||||
|
||||
|
||||
二者**可以同时存在**;存在几个有效字段就**记录几个**(不要求强制成对)。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 4. 参数传递方式(推荐)
|
||||
|
||||
|
||||
|
||||
### 4.1 小程序入口
|
||||
|
||||
|
||||
|
||||
1. **普通 URL 打开小程序**(短链 / 服务商跳转)
|
||||
|
||||
- `path` + `query`,例如:
|
||||
|
||||
`pages/index/index?eid=6&userid=xxx&phone=...&tid=yyy`
|
||||
|
||||
- 长度与字符集需符合微信限制;`phone` 建议 **URL 编码**。
|
||||
|
||||
|
||||
|
||||
2. **扫普通链接二维码**
|
||||
|
||||
- 与 4.1 相同,`options` 中直接带 query。
|
||||
|
||||
|
||||
|
||||
3. **扫小程序码(带 scene)**
|
||||
|
||||
- `scene` 长度受限(≤ 32 字符等,以微信文档为准),仅适合短参。
|
||||
|
||||
- 长参方案:**scene 只放 token**,小程序 `onLaunch/onLoad` 用 token **换全量参数**(你们服务端 `GET /api/channel/resolve?scene=xxx`)。
|
||||
|
||||
|
||||
|
||||
### 4.2 参数列表(建议)
|
||||
|
||||
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|
||||
|------|------|------|
|
||||
|
||||
| `userid` | 否* | 第三方用户 ID;与 `phone` 二选一至少一个才判第三方 |
|
||||
|
||||
| `phone` | 否* | 第三方提供的手机号;须已获用户授权或合作方合法来源 |
|
||||
|
||||
| `tid` | 否 | 第三方任务/订单/活动 ID |
|
||||
|
||||
| `uid` | 否 | **仅**表示站内分销邀请人,沿用现有逻辑 |
|
||||
|
||||
| `eid` | 否 | 本平台企业 ID,与现有企业版逻辑兼容 |
|
||||
|
||||
|
||||
|
||||
\* 第三方判定见第 3 节。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 5. 客户端(小程序)新方案
|
||||
|
||||
|
||||
|
||||
### 5.1 解析与缓存
|
||||
|
||||
|
||||
|
||||
1. 在 **冷启动** `App.onLaunch`、**热启动** `App.onShow`,以及 **落地页** `onLoad`(如 `pages/index/index`、`pages/test-select`、`pages/enterprise/index`)统一解析 `options` / `scene`。
|
||||
|
||||
2. 若解析出 `phone` / `userid` / `tid`,写入 `globalData`,例如:
|
||||
|
||||
|
||||
|
||||
```text
|
||||
|
||||
globalData.thirdPartyChannel = {
|
||||
|
||||
userid: string | null,
|
||||
|
||||
phone: string | null,
|
||||
|
||||
tid: string | null,
|
||||
|
||||
capturedAt: number, // 时间戳
|
||||
|
||||
rawQuery: string // 可选:审计
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
3. **优先级**:同名字段以「首次成功解析」为准,避免后续页面覆盖(或允许合作方约定「最后一次覆盖」——需在文档中二选一并写死)。
|
||||
|
||||
4. 与分销:`query.uid` 仍只用于 **`_pendingInviterId`(站内邀请人)**;**`userid` 不得写入** `_pendingInviterId`,二者分流解析。
|
||||
|
||||
|
||||
|
||||
### 5.2 随关键请求上报
|
||||
|
||||
|
||||
|
||||
在以下请求 **body 或 header** 中附带第三方块(仅非空字段),由后端**仅更新当前登录用户对应的 `wechat_users` 行**(不在 `test_results` 等表落第三方字段):
|
||||
|
||||
|
||||
|
||||
- **必选路径**:静默登录 / 会话建立 / 绑定手机 等能确定 `wechat_users.id` 的接口(收到即写库)。
|
||||
|
||||
- **补充路径**(可选):`POST /api/test/submit`、`POST /api/analyze`、`POST /api/payment/create` 若仍携带 `thirdParty`,仅用于**再次覆盖/补全** `wechat_users` 上同一套字段(与是否产生测评记录无关)。
|
||||
|
||||
|
||||
|
||||
建议统一 JSON 结构:
|
||||
|
||||
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
|
||||
"thirdParty": {
|
||||
|
||||
"userid": "string|null",
|
||||
|
||||
"phone": "string|null",
|
||||
|
||||
"tid": "string|null"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
后端对 `thirdParty` 做白名单字段 + 长度截断,防止脏数据;**仅此一处持久化**:将 `userid` 写入 **`wechat_users.ext_uid`(模型 `extUid`)**,`phone`、`tid` 写入同表约定列(见 6.1)。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 6. 服务端(API / 库表)新方案
|
||||
|
||||
|
||||
|
||||
### 6.1 存储原则(仅 `wechat_users`)
|
||||
|
||||
|
||||
|
||||
- **唯一落库位置**:第三方参数**只记录在 `wechat_users`**,不在 `test_results` 建扩展字段,也不建独立归因表。
|
||||
|
||||
- **建议列**(实现时以迁移为准):
|
||||
|
||||
- **`ext_uid` / 模型 `extUid`**:对应入参 **`userid`**(合作方用户标识);
|
||||
|
||||
- **`third_party_phone`(或你们统一命名的 nullable 列)**:对应 **`phone`**(若与站内已验证手机号分储,勿覆盖用户真实登录手机号列,除非产品明确等同);
|
||||
|
||||
- **`third_party_tid`(或你们统一命名)**:对应 **`tid`**。
|
||||
|
||||
- **更新策略**:同一用户多次带参进入时,可按「**有传则更新对应列**」或「**仅首次写入不覆盖**」二选一定死;文档推荐 **有传则更新**,便于合作方纠单。
|
||||
|
||||
|
||||
|
||||
### 6.2 写入时机
|
||||
|
||||
|
||||
|
||||
- 用户已能关联到 `wechat_users` 后(登录、code 换 session、绑定等),接口收到 **`thirdParty`** 即 **`UPDATE wechat_users`**。
|
||||
|
||||
- **不要求**用户必须完成任一测评才写入;测评提交相关接口若携带 `thirdParty`,只做对 `wechat_users` 的同字段更新,**不写 `test_results`**。
|
||||
|
||||
- 若入参含 **`phone`**,处理流程中应先按产品与表结构定义之**主手机号字段**(如已验证 `mobile`)做 **lookup**:若已存在用户,则进入 **6.4**,**禁止**再为该号创建新 `wechat_users` 行。
|
||||
|
||||
|
||||
|
||||
### 6.3 与 CRM / 存客宝
|
||||
|
||||
|
||||
|
||||
- 存客宝上报仍以**手机号、昵称、openid** 等**平台侧**字段为主;
|
||||
|
||||
- 第三方 **`userid`(`ext_uid`)/ `tid`** 等可从 **`wechat_users`** 读出,再写入存客宝 **remark / tags / 扩展字段**(若接口支持),便于运营检索合作方用户。
|
||||
|
||||
|
||||
|
||||
### 6.4 手机号已在用户表存在(复用账号、不注册新号)
|
||||
|
||||
|
||||
|
||||
**触发条件**:`thirdParty.phone` 有效,且与库中某条 **`wechat_users`** 的**主手机号**(或与业务判定「同一用户」的号码列)**规范化后一致**。
|
||||
|
||||
**业务与数据规则**:
|
||||
|
||||
1. **不注册新用户**:不得再 `INSERT` 一条仅因第三方跳转而产生的新用户行。
|
||||
|
||||
2. **会话与测评主体**:后续静默登录、发 token、做测评均应**落到该行对应的真实用户**上,用户**可直接用该账号继续测试**(与现有下单、历史记录一致)。
|
||||
|
||||
3. **库表更新范围(本场景)**:以合作方 **`userid` → `ext_uid` 的 `UPDATE` 为主**(有传则写/覆盖 `ext_uid`)。**不在本场景因「重复手机」去改主手机号列**(该列已标识用户)。
|
||||
|
||||
4. **`tid`**:若本次带 `tid`,建议**同时 `UPDATE` `third_party_tid`**,便于本次活动归因;无则可不动。
|
||||
|
||||
5. **`third_party_phone`**:与主手机号已相同或可省略写入;若需留痕「本次渠道透传号码」,可择一:**写入相同值**或与主号一致时**跳过**,由实现约定。
|
||||
|
||||
6. **微信 openid**:若当前微信为新 openid、按手机命中老用户,必须约定 **openid 与该 `wechat_users.id` 的绑定或合并策略**(例如合并到已有用户、或拒绝并引导用手机登录),避免同一物理用户两套账号或串号。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 7. 安全与合规
|
||||
|
||||
|
||||
|
||||
1. **手机号**:仅接收已获授权或合作方合规提供的号码;日志脱敏展示。
|
||||
|
||||
2. **防篡改**(可选):对 `userid` + `tid` + `timestamp` 使用 **HMAC 或 RSA 签名**,服务端验签后再信。
|
||||
|
||||
3. **有效期**:`scene` token 换参接口可设 TTL(如 15 分钟),防止链接被长期滥用。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 8. 落地步骤(建议顺序)
|
||||
|
||||
|
||||
|
||||
1. 定稿对外 query:`userid`、`phone`、`tid`,与分销 `uid` 共存规则。
|
||||
|
||||
2. 小程序:封装 `utils/thirdPartyContext.js`(解析、合并、挂到 `globalData`、拼请求体)。
|
||||
|
||||
3. 后端:仅在 **`wechat_users`** 增加 `ext_uid`、`third_party_phone`、`third_party_tid`(列名以最终迁移为准);登录等接口解析 `thirdParty`;**含 `phone` 时先 lookup 主手机号**,命中则走 **6.4**(复用账号、不新建行、以更新 `ext_uid` 为主)。
|
||||
|
||||
4. 管理端 / 超管:在「用户」详情展示 **`extUid` / 第三方 phone / tid**(权限控制);测评列表**无需**多展示一套第三方列(除非从用户侧跳转查看)。
|
||||
|
||||
5. 与合作方联调:短链 → 小程序 → **登录成功** → 查 **`wechat_users`** 对应行。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 9. 小结
|
||||
|
||||
|
||||
|
||||
| 项目 | 约定 |
|
||||
|
||||
|------|------|
|
||||
|
||||
| 第三方判定 | `phone` 或 `userid` **其一有有效值**即第三方 |
|
||||
|
||||
| 记录规则 | **有则必记**;两者都有则两条信息都存 |
|
||||
|
||||
| 与现有分销 | 分销用 **`uid`**,第三方用 **`userid`**,参数名分流、不混用 |
|
||||
|
||||
| 库表 | **仅 `wechat_users`**:`userid` → **`ext_uid`(`extUid`)**;`phone`、`tid` 用同表约定列(见 6.1) |
|
||||
|
||||
| `tid` | 可选,任务/订单级归因 |
|
||||
|
||||
| 落地 | 小程序解析 → 登录等接口带 `thirdParty` → **更新 `wechat_users`** |
|
||||
|
||||
| 手机已存在 | **不建新号**,**复用该用户**继续测评;**以更新 `ext_uid`(合作方 `userid`)为主**;`tid` 建议同步;openid 绑定见 6.4 |
|
||||
|
||||
|
||||
|
||||
本文档为**新方案说明**:对外仍为 **`userid` / `phone` / `tid`**;**库表只动 `wechat_users`**,合作方用户 id 列为 **`ext_uid` / 模型 `extUid`**;**手机号已存在时只走账号复用 + `ext_uid`(及可选 `tid`)更新**,见 **6.4**。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user