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',

View File

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

View File

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

View File

@@ -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 时触发

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ Page({
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
hasReloadedAfterPay: false,
},
onLoad(options) {

View File

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

View File

@@ -27,7 +27,7 @@ Page({
},
testResultId: null,
hasReloadedAfterPay: false,
hasPhone: false
hasPhone: false,
},
onLoad(options) {

View File

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

View File

@@ -44,7 +44,7 @@ Page({
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
hasReloadedAfterPay: false,
},
onLoad(options) {

View File

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

View File

@@ -9,7 +9,10 @@ Page({
permDisc: true
},
onLoad() {
onLoad(options) {
try {
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
} catch (e) {}
this._syncPerms()
},

View File

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

View File

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

View File

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

View File

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

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

View 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**