diff --git a/api/app/common/service/OpenPlatformService.php b/api/app/common/service/OpenPlatformService.php new file mode 100644 index 0000000..3bed5b7 --- /dev/null +++ b/api/app/common/service/OpenPlatformService.php @@ -0,0 +1,271 @@ +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 如 ['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 $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); + } +} diff --git a/api/app/common/service/ThirdPartyChannelService.php b/api/app/common/service/ThirdPartyChannelService.php new file mode 100644 index 0000000..51f26a3 --- /dev/null +++ b/api/app/common/service/ThirdPartyChannelService.php @@ -0,0 +1,113 @@ + '', '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); + } + +} diff --git a/api/app/controller/api/Analyze.php b/api/app/controller/api/Analyze.php index 712755c..04818c2 100644 --- a/api/app/controller/api/Analyze.php +++ b/api/app/controller/api/Analyze.php @@ -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) { // 写入失败不影响返回分析结果 diff --git a/api/app/controller/api/Auth.php b/api/app/controller/api/Auth.php index f28a158..a5a7c6e 100644 --- a/api/app/controller/api/Auth.php +++ b/api/app/controller/api/Auth.php @@ -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; diff --git a/api/app/controller/api/Test.php b/api/app/controller/api/Test.php index 1be847c..cedc669 100644 --- a/api/app/controller/api/Test.php +++ b/api/app/controller/api/Test.php @@ -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, ]); } + } diff --git a/api/app/model/WechatUser.php b/api/app/model/WechatUser.php index d5104ae..f974cf1 100644 --- a/api/app/model/WechatUser.php +++ b/api/app/model/WechatUser.php @@ -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', diff --git a/api/database/migrations/add_third_party_channel_to_wechat_users.sql b/api/database/migrations/add_third_party_channel_to_wechat_users.sql new file mode 100644 index 0000000..0d52f43 --- /dev/null +++ b/api/database/migrations/add_third_party_channel_to_wechat_users.sql @@ -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`); diff --git a/miniprogram/app.js b/miniprogram/app.js index 89b1015..b3f0d68 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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 diff --git a/miniprogram/pages/enterprise/index.js b/miniprogram/pages/enterprise/index.js index 71d91ee..e217c33 100644 --- a/miniprogram/pages/enterprise/index.js +++ b/miniprogram/pages/enterprise/index.js @@ -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 时触发 diff --git a/miniprogram/pages/index/camera.js b/miniprogram/pages/index/camera.js index 0079c92..3d993e8 100644 --- a/miniprogram/pages/index/camera.js +++ b/miniprogram/pages/index/camera.js @@ -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) { diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index c47e405..c14f8fb 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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) diff --git a/miniprogram/pages/index/upload.js b/miniprogram/pages/index/upload.js index 4b0980e..7f517be 100644 --- a/miniprogram/pages/index/upload.js +++ b/miniprogram/pages/index/upload.js @@ -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 }) diff --git a/miniprogram/pages/result/disc.js b/miniprogram/pages/result/disc.js index d4513a8..d85b3c3 100644 --- a/miniprogram/pages/result/disc.js +++ b/miniprogram/pages/result/disc.js @@ -41,7 +41,7 @@ Page({ ], payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }, testResultId: null, - hasReloadedAfterPay: false + hasReloadedAfterPay: false, }, onLoad(options) { diff --git a/miniprogram/pages/result/disc.wxml b/miniprogram/pages/result/disc.wxml index 127e3ce..4e89e27 100644 --- a/miniprogram/pages/result/disc.wxml +++ b/miniprogram/pages/result/disc.wxml @@ -1,91 +1,91 @@ - - - - - - - 您的DISC性格类型 - {{typeSummaryLine}} - {{result.description.description}} - - - - - - 完整DISC报告 - • 四维得分详情 - • 性格特征与优劣势 - • 职业匹配建议 - - - - 解锁完整报告 - ¥{{payInfo.amountYuan}} / 次 - - - - - - - 完整报告需完善资料 - 请补全头像、昵称并绑定手机号后查看完整 DISC 报告。 - - 去完善资料 - - - - - - DISC得分详情 - - - {{item.label}} - {{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} - - - - - - - - - - 主要性格特征分析 - - 优势 - - - {{item}} - - - - 需要注意的方面 - - - {{item}} - - - - - - 职业匹配度分析 - - - {{item}} - - - - - - - + + + + + + + 您的DISC性格类型 + {{typeSummaryLine}} + {{result.description.description}} + + + + + + 完整DISC报告 + • 四维得分详情 + • 性格特征与优劣势 + • 职业匹配建议 + + + + 解锁完整报告 + ¥{{payInfo.amountYuan}} / 次 + + + + + + + 完整报告需完善资料 + 请补全头像、昵称并绑定手机号后查看完整 DISC 报告。 + + 去完善资料 + + + + + + DISC得分详情 + + + {{item.label}} + {{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} + + + + + + + + + + 主要性格特征分析 + + 优势 + + + {{item}} + + + + 需要注意的方面 + + + {{item}} + + + + + + 职业匹配度分析 + + + {{item}} + + + + + + + diff --git a/miniprogram/pages/result/mbti.js b/miniprogram/pages/result/mbti.js index aabf703..16e65a9 100644 --- a/miniprogram/pages/result/mbti.js +++ b/miniprogram/pages/result/mbti.js @@ -27,7 +27,7 @@ Page({ }, testResultId: null, hasReloadedAfterPay: false, - hasPhone: false + hasPhone: false, }, onLoad(options) { diff --git a/miniprogram/pages/result/mbti.wxml b/miniprogram/pages/result/mbti.wxml index b041e39..8c21893 100644 --- a/miniprogram/pages/result/mbti.wxml +++ b/miniprogram/pages/result/mbti.wxml @@ -1,117 +1,117 @@ - - - - - - - 您的MBTI性格类型 - {{result.mbtiType}} - {{mbtiDesc.title}} - {{mbtiDesc.description}} - - - - - - - 完整性格分析 - • 四维得分与主导倾向 - • 优势与需要注意的方面 - • 职业匹配与人际关系建议 - - - - - - - - - - - - 完整报告需完善资料 - 请补全头像、昵称并绑定手机号后查看完整性格分析。 - - - - - - - - {{item.left}} - {{item.right}} - - - - - - {{item.dominant}} - {{item.percentage}}% - - - - - - - 性格特征分析 - - 优势 - - - {{item}} - - - - - 需要注意的方面 - - - {{item}} - - - - - - 职业匹配度分析 - - - {{item}} - - - - - 人际关系分析 - {{mbtiDesc.relationships}} - - - - - - + + + + + + + 您的MBTI性格类型 + {{result.mbtiType}} + {{mbtiDesc.title}} + {{mbtiDesc.description}} + + + + + + + 完整性格分析 + • 四维得分与主导倾向 + • 优势与需要注意的方面 + • 职业匹配与人际关系建议 + + + + + + + + + + + + 完整报告需完善资料 + 请补全头像、昵称并绑定手机号后查看完整性格分析。 + + + + + + + + {{item.left}} + {{item.right}} + + + + + + {{item.dominant}} + {{item.percentage}}% + + + + + + + 性格特征分析 + + 优势 + + + {{item}} + + + + + 需要注意的方面 + + + {{item}} + + + + + + 职业匹配度分析 + + + {{item}} + + + + + 人际关系分析 + {{mbtiDesc.relationships}} + + + + + + diff --git a/miniprogram/pages/result/pdp.js b/miniprogram/pages/result/pdp.js index 1f41cc7..cbb6a45 100644 --- a/miniprogram/pages/result/pdp.js +++ b/miniprogram/pages/result/pdp.js @@ -44,7 +44,7 @@ Page({ ], payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }, testResultId: null, - hasReloadedAfterPay: false + hasReloadedAfterPay: false, }, onLoad(options) { diff --git a/miniprogram/pages/result/pdp.wxml b/miniprogram/pages/result/pdp.wxml index 93fd5d1..cd55370 100644 --- a/miniprogram/pages/result/pdp.wxml +++ b/miniprogram/pages/result/pdp.wxml @@ -1,97 +1,97 @@ - - - - - - - PDP性格类型 - {{typeSummaryLine}} - {{result.description.title}} - {{result.description.description}} - - - - - - 完整PDP报告 - • 五维得分详情 - • 性格特征与团队角色 - • 推荐职业 - - - - 解锁完整报告 - ¥{{payInfo.amountYuan}} / 次 - - - - - - - 完整报告需完善资料 - 请补全头像、昵称并绑定手机号后查看完整 PDP 报告。 - - 去完善资料 - - - - - - PDP得分详情 - - - {{item.emoji}} {{item.label}} - {{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} - - - - - - - - - - 性格特征 - - 优势 - - - {{item}} - - - - 需要注意的方面 - - - {{item}} - - - - - - 团队角色 - {{result.description.teamRole}} - - - - 推荐职业 - - - {{item}} - - - - - - - + + + + + + + PDP性格类型 + {{typeSummaryLine}} + {{result.description.title}} + {{result.description.description}} + + + + + + 完整PDP报告 + • 五维得分详情 + • 性格特征与团队角色 + • 推荐职业 + + + + 解锁完整报告 + ¥{{payInfo.amountYuan}} / 次 + + + + + + + 完整报告需完善资料 + 请补全头像、昵称并绑定手机号后查看完整 PDP 报告。 + + 去完善资料 + + + + + + PDP得分详情 + + + {{item.emoji}} {{item.label}} + {{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} + + + + + + + + + + 性格特征 + + 优势 + + + {{item}} + + + + 需要注意的方面 + + + {{item}} + + + + + + 团队角色 + {{result.description.teamRole}} + + + + 推荐职业 + + + {{item}} + + + + + + + diff --git a/miniprogram/pages/test-select/index.js b/miniprogram/pages/test-select/index.js index 32f2527..3c082ca 100644 --- a/miniprogram/pages/test-select/index.js +++ b/miniprogram/pages/test-select/index.js @@ -9,7 +9,10 @@ Page({ permDisc: true }, - onLoad() { + onLoad(options) { + try { + require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app) + } catch (e) {} this._syncPerms() }, diff --git a/miniprogram/pages/test/disc.js b/miniprogram/pages/test/disc.js index fa97422..71d0740 100644 --- a/miniprogram/pages/test/disc.js +++ b/miniprogram/pages/test/disc.js @@ -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) { diff --git a/miniprogram/pages/test/mbti.js b/miniprogram/pages/test/mbti.js index ddadee8..d9f0add 100644 --- a/miniprogram/pages/test/mbti.js +++ b/miniprogram/pages/test/mbti.js @@ -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 diff --git a/miniprogram/pages/test/pdp.js b/miniprogram/pages/test/pdp.js index bc69889..d1006ee 100644 --- a/miniprogram/pages/test/pdp.js +++ b/miniprogram/pages/test/pdp.js @@ -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) { diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json index 0eb536b..334a8bf 100644 --- a/miniprogram/project.private.config.json +++ b/miniprogram/project.private.config.json @@ -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 } + ] } + } } \ No newline at end of file diff --git a/miniprogram/utils/thirdPartyContext.js b/miniprogram/utils/thirdPartyContext.js new file mode 100644 index 0000000..8a4e945 --- /dev/null +++ b/miniprogram/utils/thirdPartyContext.js @@ -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, +} diff --git a/开发文档/第三方跳转测评参数方案.md b/开发文档/第三方跳转测评参数方案.md new file mode 100644 index 0000000..45ef013 --- /dev/null +++ b/开发文档/第三方跳转测评参数方案.md @@ -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**。 + +