From c4ac0f8a0876ad1dcc7c12b9da64083113017f60 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Sat, 25 Apr 2026 16:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=AB=98=E8=80=83=E5=BF=97=E6=84=BF?= =?UTF-8?q?=E4=B8=8E=E5=AE=9A=E4=BB=B7=E5=88=86=E9=94=80=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=88=E7=AE=A1=E7=90=86=E7=AB=AF/API/?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增高考志愿核心服务、模型、控制器与迁移脚本 - 同步管理端定价/分销/设置与小程序入口、历史、测试选择和支付逻辑 - 补充相关开发文档 Made-with: Cursor --- admin/src/views/admin/Distribution.vue | 12 +- admin/src/views/admin/Pricing.vue | 15 +- admin/src/views/admin/Settings.vue | 3 +- admin/src/views/superadmin/Distribution.vue | 12 +- admin/src/views/superadmin/Enterprises.vue | 4 +- admin/src/views/superadmin/Pricing.vue | 44 +- .../service/EnterpriseBillingService.php | 13 +- api/app/common/service/GaokaoService.php | 1143 +++++++++++++++++ api/app/common/service/TestProductPricing.php | 54 + api/app/controller/admin/AppUser.php | 78 ++ api/app/controller/admin/Distribution.php | 31 +- api/app/controller/admin/GaokaoUser.php | 96 ++ api/app/controller/admin/Pricing.php | 4 +- api/app/controller/api/Distribution.php | 95 ++ api/app/controller/api/Gaokao.php | 120 ++ api/app/controller/api/Payment.php | 176 ++- api/app/controller/api/Test.php | 79 +- api/app/controller/superadmin/AppUser.php | 77 ++ .../controller/superadmin/Distribution.php | 2 +- api/app/controller/superadmin/GaokaoUser.php | 105 ++ api/app/controller/superadmin/Pricing.php | 62 +- api/app/model/Enterprise.php | 2 + .../model/GaokaoDistributionAttribution.php | 31 + .../model/GaokaoDistributionCommission.php | 35 + api/app/model/GaokaoOrder.php | 38 + api/app/model/GaokaoPricing.php | 60 + api/app/model/GaokaoReport.php | 30 + api/app/model/GaokaoUserProfile.php | 40 + .../migrations/add_gaokao_core_tables.sql | 30 + .../migrate_gaokao_legacy_to_unified.sql | 3 + .../rollback_gaokao_core_tables.sql | 4 + api/route/api.php | 11 + miniprogram/app.js | 4 +- miniprogram/app.json | 3 + miniprogram/pages/gaokao/form.js | 387 ++++++ miniprogram/pages/gaokao/form.json | 4 + miniprogram/pages/gaokao/form.wxml | 72 ++ miniprogram/pages/gaokao/form.wxss | 86 ++ miniprogram/pages/gaokao/index.js | 107 ++ miniprogram/pages/gaokao/index.json | 4 + miniprogram/pages/gaokao/index.wxml | 90 ++ miniprogram/pages/gaokao/index.wxss | 142 ++ miniprogram/pages/gaokao/report.js | 690 ++++++++++ miniprogram/pages/gaokao/report.json | 6 + miniprogram/pages/gaokao/report.wxml | 262 ++++ miniprogram/pages/gaokao/report.wxss | 698 ++++++++++ miniprogram/pages/history/index.js | 36 +- miniprogram/pages/history/index.wxss | 12 + miniprogram/pages/test-select/index.js | 17 +- miniprogram/pages/test-select/index.wxml | 15 +- miniprogram/pages/test-select/index.wxss | 5 + miniprogram/utils/gaokao.js | 87 ++ miniprogram/utils/gaokaoJourneyState.js | 63 + miniprogram/utils/payment.js | 64 +- 开发文档/MBTI定价支付与分销逻辑.md | 228 ++++ 高考志愿MBTI开发文档.md | 577 +++++++++ 56 files changed, 6100 insertions(+), 68 deletions(-) create mode 100644 api/app/common/service/GaokaoService.php create mode 100644 api/app/common/service/TestProductPricing.php create mode 100644 api/app/controller/admin/GaokaoUser.php create mode 100644 api/app/controller/api/Gaokao.php create mode 100644 api/app/controller/superadmin/GaokaoUser.php create mode 100644 api/app/model/GaokaoDistributionAttribution.php create mode 100644 api/app/model/GaokaoDistributionCommission.php create mode 100644 api/app/model/GaokaoOrder.php create mode 100644 api/app/model/GaokaoPricing.php create mode 100644 api/app/model/GaokaoReport.php create mode 100644 api/app/model/GaokaoUserProfile.php create mode 100644 api/database/migrations/add_gaokao_core_tables.sql create mode 100644 api/database/migrations/migrate_gaokao_legacy_to_unified.sql create mode 100644 api/database/migrations/rollback_gaokao_core_tables.sql create mode 100644 miniprogram/pages/gaokao/form.js create mode 100644 miniprogram/pages/gaokao/form.json create mode 100644 miniprogram/pages/gaokao/form.wxml create mode 100644 miniprogram/pages/gaokao/form.wxss create mode 100644 miniprogram/pages/gaokao/index.js create mode 100644 miniprogram/pages/gaokao/index.json create mode 100644 miniprogram/pages/gaokao/index.wxml create mode 100644 miniprogram/pages/gaokao/index.wxss create mode 100644 miniprogram/pages/gaokao/report.js create mode 100644 miniprogram/pages/gaokao/report.json create mode 100644 miniprogram/pages/gaokao/report.wxml create mode 100644 miniprogram/pages/gaokao/report.wxss create mode 100644 miniprogram/utils/gaokao.js create mode 100644 miniprogram/utils/gaokaoJourneyState.js create mode 100644 开发文档/MBTI定价支付与分销逻辑.md create mode 100644 高考志愿MBTI开发文档.md diff --git a/admin/src/views/admin/Distribution.vue b/admin/src/views/admin/Distribution.vue index f720b8e..788f789 100644 --- a/admin/src/views/admin/Distribution.vue +++ b/admin/src/views/admin/Distribution.vue @@ -579,6 +579,7 @@ const testTypeItems = [ { key: 'sbti', label: 'SBTI 测试' }, { key: 'disc', label: 'DISC 测试' }, { key: 'pdp', label: 'PDP 测试' }, + { key: 'gaokao', label: '高考志愿报告' }, ] type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean } const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false }) @@ -588,6 +589,7 @@ const testSettings = reactive>({ sbti: makeDefaultTs(), disc: makeDefaultTs(), pdp: makeDefaultTs(), + gaokao: makeDefaultTs(), }) // 加载数据概览 @@ -1175,7 +1177,10 @@ onMounted(() => { .card-desc { font-size: 12px; color: #6b7280; margin: 0 0 20px; } .ts-grid { - display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; + display: grid; + /* 一行最多 4 个测试佣金卡片 */ + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; .ts-card { background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; .ts-head { @@ -1270,5 +1275,10 @@ onMounted(() => { .stats-grid { grid-template-columns: repeat(2, 1fr); } .charts-grid { grid-template-columns: 1fr; } .settings-card .form-grid.three-cols { grid-template-columns: 1fr; } + .settings-card .ts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 640px) { + .settings-card .ts-grid { grid-template-columns: 1fr; } } diff --git a/admin/src/views/admin/Pricing.vue b/admin/src/views/admin/Pricing.vue index 9e1df49..fba9899 100644 --- a/admin/src/views/admin/Pricing.vue +++ b/admin/src/views/admin/Pricing.vue @@ -46,6 +46,10 @@ +
+ + +
@@ -83,6 +87,10 @@
+
+ + +
@@ -115,8 +123,8 @@ const tabs = [ ] const activeTab = ref('personal') -const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 }) -const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 }) +const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 }) +const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 }) const loading = ref(false) const isUsingSuperAdminPersonalConfig = ref(false) @@ -170,7 +178,8 @@ const saveEnterprise = async () => { mbti: enterprise.mbti, disc: enterprise.disc, pdp: enterprise.pdp, - sbti: enterprise.sbti + sbti: enterprise.sbti, + gaokao: enterprise.gaokao } }) if (response.code === 200) { diff --git a/admin/src/views/admin/Settings.vue b/admin/src/views/admin/Settings.vue index 6b1e060..53adcf8 100644 --- a/admin/src/views/admin/Settings.vue +++ b/admin/src/views/admin/Settings.vue @@ -319,11 +319,12 @@ const permItems = [ { key: 'sbti', label: 'SBTI' }, { key: 'pdp', label: 'PDP' }, { key: 'disc', label: 'DISC' }, + { key: 'gaokao', label: '高考志愿' }, { key: 'distribution', label: '分销推广' } ] as const const defaultAdminPermissions = () => - ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true }) as Record + ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, gaokao: true, distribution: true }) as Record const permLoading = ref(false) const permSaving = ref(false) diff --git a/admin/src/views/superadmin/Distribution.vue b/admin/src/views/superadmin/Distribution.vue index 8ed9e20..60c694d 100644 --- a/admin/src/views/superadmin/Distribution.vue +++ b/admin/src/views/superadmin/Distribution.vue @@ -397,6 +397,7 @@ const testTypeItems = [ { key: 'sbti', label: 'SBTI 测试' }, { key: 'disc', label: 'DISC 测试' }, { key: 'pdp', label: 'PDP 测试' }, + { key: 'gaokao', label: '高考志愿报告' }, ] type TestSetting = { enabled: boolean; commissionType: 'ratio' | 'amount'; commissionRate: number; commissionAmount: number; noPayment: boolean } const makeDefaultTs = (): TestSetting => ({ enabled: true, commissionType: 'ratio', commissionRate: 90, commissionAmount: 0, noPayment: false }) @@ -406,6 +407,7 @@ const testSettings = reactive>({ sbti: makeDefaultTs(), disc: makeDefaultTs(), pdp: makeDefaultTs(), + gaokao: makeDefaultTs(), }) // 拒绝弹窗 @@ -703,7 +705,10 @@ onMounted(() => { loadOverview(); loadTopAgents() }) &.full-width-card { grid-column: 1 / -1; } .setting-list { display: flex; flex-direction: column; gap: 12px; } .ts-grid { - display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; + display: grid; + /* 一行最多 4 个测试佣金卡片 */ + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; .ts-card { background: #f9fafb; border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; .ts-head { @@ -752,6 +757,11 @@ onMounted(() => { loadOverview(); loadTopAgents() }) @media (max-width: 1200px) { .overview-section .stats-grid { grid-template-columns: repeat(2, 1fr); } .overview-section .two-cols { grid-template-columns: 1fr; } + .settings-card .ts-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 640px) { + .settings-card .ts-grid { grid-template-columns: 1fr; } } .page-container.is-embedded { diff --git a/admin/src/views/superadmin/Enterprises.vue b/admin/src/views/superadmin/Enterprises.vue index 001a206..59f2edb 100644 --- a/admin/src/views/superadmin/Enterprises.vue +++ b/admin/src/views/superadmin/Enterprises.vue @@ -1088,10 +1088,12 @@ const permItems = [ { key: 'sbti', label: 'SBTI' }, { key: 'pdp', label: 'PDP' }, { key: 'disc', label: 'DISC' }, + { key: 'gaokao', label: '高考志愿' }, { key: 'distribution', label: '分销' }, ] -const defaultPermissions = () => ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, distribution: true }) +const defaultPermissions = () => + ({ face: true, mbti: true, sbti: true, pdp: true, disc: true, gaokao: true, distribution: true }) /** 列表/展示:超管授权上限(兼容未返回 permissionsCeiling 的旧接口) */ const permCeilingVal = (row: Record, key: string) => { diff --git a/admin/src/views/superadmin/Pricing.vue b/admin/src/views/superadmin/Pricing.vue index 6d00be8..dfd045e 100644 --- a/admin/src/views/superadmin/Pricing.vue +++ b/admin/src/views/superadmin/Pricing.vue @@ -85,6 +85,16 @@ class="w-full" />
+
+ + +
@@ -149,6 +159,16 @@ />
+ + +
+
{ if (response.code === 200 && response.data) { // 更新个人版配置 if (response.data.personal) { - Object.assign(personal, response.data.personal) + Object.assign(personal, { gaokao: 0 }, response.data.personal) } - + // 更新企业版配置 if (response.data.enterprise) { - Object.assign(enterprise, response.data.enterprise) + Object.assign(enterprise, { gaokao: 0, minRecharge: enterprise.minRecharge }, response.data.enterprise) } // 更新深度服务配置(个人/企业类目) @@ -624,7 +646,14 @@ const savePersonal = async () => { try { const response: any = await request.put('/superadmin/pricing', { type: 'personal', - config: personal + config: { + face: personal.face, + mbti: personal.mbti, + disc: personal.disc, + pdp: personal.pdp, + sbti: personal.sbti, + gaokao: personal.gaokao + } }) if (response.code === 200) { @@ -648,6 +677,7 @@ const saveEnterprise = async () => { pdp: enterprise.pdp, disc: enterprise.disc, sbti: enterprise.sbti, + gaokao: enterprise.gaokao, minRecharge: enterprise.minRecharge } }) @@ -865,6 +895,10 @@ onMounted(() => { @media (max-width: 768px) { grid-template-columns: 1fr; } + + .form-item--span-full { + grid-column: 1 / -1; + } } .form-item { diff --git a/api/app/common/service/EnterpriseBillingService.php b/api/app/common/service/EnterpriseBillingService.php index 6ffc877..fb3f9e6 100644 --- a/api/app/common/service/EnterpriseBillingService.php +++ b/api/app/common/service/EnterpriseBillingService.php @@ -112,12 +112,13 @@ class EnterpriseBillingService private static function getTestTypeLabel(string $testType): string { $map = [ - 'face' => '人脸', - 'mbti' => 'MBTI', - 'sbti' => 'SBTI', - 'disc' => 'DISC', - 'pdp' => 'PDP', - 'resume' => '简历', + 'face' => '人脸', + 'mbti' => 'MBTI', + 'sbti' => 'SBTI', + 'disc' => 'DISC', + 'pdp' => 'PDP', + 'resume' => '简历', + 'gaokao' => '高考志愿', ]; return $map[$testType] ?? strtoupper($testType); diff --git a/api/app/common/service/GaokaoService.php b/api/app/common/service/GaokaoService.php new file mode 100644 index 0000000..099c4f0 --- /dev/null +++ b/api/app/common/service/GaokaoService.php @@ -0,0 +1,1143 @@ +where('id', $userId)->value('enterpriseId'); + return $eid > 0 ? $eid : 0; + } + + public static function getOrInitProfile(int $userId): GaokaoUserProfile + { + $tenantId = self::resolveTenantIdByWechatUserId($userId); + $row = GaokaoUserProfile::where('userId', $userId)->find(); + if ($row) { + if ((int) ($row->tenantId ?? 0) !== $tenantId) { + $row->tenantId = $tenantId; + $row->save(); + } + return $row; + } + + $now = time(); + $row = new GaokaoUserProfile([ + 'userId' => $userId, + 'tenantId' => $tenantId, + 'entryStatus' => 0, + 'mbtiStatus' => 0, + 'pdpStatus' => 0, + 'discStatus' => 0, + 'formStatus' => 0, + 'analyzeStatus' => 0, + 'lastAnalyzeAt' => null, + 'latestReportId' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + $row->save(); + return $row; + } + + public static function markEntry(int $userId, array $entry = []): void + { + $profile = self::getOrInitProfile($userId); + $profile->entryStatus = 1; + $profile->save(); + + $referrerId = (int) ($entry['referrerId'] ?? 0); + if ($referrerId <= 0) { + return; + } + + $tenantId = (int) ($profile->tenantId ?? 0); + $enterpriseId = $tenantId > 0 ? $tenantId : null; + Distribution::applyInviteBindingFromGaokao($userId, $referrerId, $enterpriseId); + } + + /** 是否存在已支付的高考 test_results(用于解锁分析) */ + public static function hasGaokaoPaid(int $userId): bool + { + if ($userId <= 0) { + return false; + } + $id = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->where('isPaid', 1) + ->value('id'); + + return !empty($id); + } + + /** + * 定价为 0 元时无需微信支付:补一条已付占位 test_results,避免任务中心一直「待支付」且 analyze 找不到 tid + */ + public static function ensureFreeGaokaoPaidIfZeroPrice(int $userId): void + { + if ($userId <= 0) { + return; + } + $pr = self::resolvePricing($userId, 'gaokao_single_report', 'personal', null); + if ((int) ($pr['priceSale'] ?? 0) > 0) { + return; + } + if (self::hasGaokaoPaid($userId)) { + return; + } + $last = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->order('id', 'desc') + ->find(); + $now = time(); + $ctx = self::resolveGaokaoReportSaleContext($userId, 'personal', null); + $writeEid = $ctx['writeEnterpriseId'] ?? null; + $testScope = (($ctx['pricingTier'] ?? 'personal') === 'enterprise') ? 'enterprise' : 'personal'; + if ($last) { + Db::name('test_results') + ->where('id', (int) $last['id']) + ->where('userId', $userId) + ->update([ + 'requiresPayment' => 0, + 'isPaid' => 1, + 'paidAmount' => 0, + 'paidAt' => $now, + 'updatedAt' => $now, + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + ]); + + return; + } + Db::name('test_results')->insert([ + 'userId' => $userId, + 'testType' => 'gaokao', + 'resultData' => json_encode([ + 'kind' => 'gaokao', + 'state' => 'free_unlock', + 'version' => 'v1', + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + 'score' => null, + 'orderId' => null, + 'requiresPayment' => 0, + 'isPaid' => 1, + 'paidAmount' => 0, + 'paidAt' => $now, + 'createdAt' => $now, + 'updatedAt' => $now, + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + ]); + } + + public static function loadTaskStatus(int $userId): array + { + $profile = self::getOrInitProfile($userId); + $latest = self::latestTestMap($userId); + + $mbtiDone = !empty($latest['mbti']); + $pdpDone = !empty($latest['pdp']); + $discDone = !empty($latest['disc']); + $faceDone = !empty($latest['face']); + $formDone = (int) ($profile->formStatus ?? 0) === 1; + $allDone = $mbtiDone && $pdpDone && $discDone && $faceDone && $formDone; + $gaokaoPaid = self::hasGaokaoPaid($userId); + $analyzed = (int) ($profile->analyzeStatus ?? 0) === 1; + + // 同步写回状态,避免前后端状态不一致 + $profile->mbtiStatus = $mbtiDone ? 1 : 0; + $profile->pdpStatus = $pdpDone ? 1 : 0; + $profile->discStatus = $discDone ? 1 : 0; + $profile->entryStatus = $allDone ? ($analyzed ? 2 : 1) : 1; + $profile->save(); + + $tasks = [ + 'mbti' => self::buildTaskItem('mbti', $mbtiDone, $latest['mbti'] ?? null), + 'pdp' => self::buildTaskItem('pdp', $pdpDone, $latest['pdp'] ?? null), + 'disc' => self::buildTaskItem('disc', $discDone, $latest['disc'] ?? null), + 'face' => self::buildTaskItem('face', $faceDone, $latest['face'] ?? null), + 'form' => self::buildFormTaskItem($formDone, $profile), + ]; + + $missing = []; + foreach ($tasks as $k => $v) { + if (($v['status'] ?? 'todo') !== 'done') { + $missing[] = $k; + } + } + + return [ + 'tasks' => $tasks, + /** 任务齐即可生成分析;付费在报告页解锁全文(与 MBTI/人脸一致) */ + 'canAnalyze' => $allDone, + 'missingItems' => $missing, + 'analyzeStatus' => (int) ($profile->analyzeStatus ?? 0), + 'latestReportId' => (int) ($profile->latestReportId ?? 0), + 'gaokaoPaid' => $gaokaoPaid, + 'needGaokaoPayment' => false, + ]; + } + + /** + * @param string $pricingScope personal|enterprise + */ + public static function loadTaskStatusWithPricing( + int $userId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): array { + $out = self::loadTaskStatus($userId); + $out['gaokaoPricing'] = self::resolvePricing($userId, 'gaokao_single_report', $pricingScope, $requestEnterpriseId); + + return $out; + } + + public static function saveForm(int $userId, array $form): GaokaoUserProfile + { + $profile = self::getOrInitProfile($userId); + $profile->name = trim((string) ($form['name'] ?? $profile->name ?? '')); + $profile->province = trim((string) ($form['province'] ?? $profile->province ?? '')); + $profile->streamSubjects = trim((string) ($form['streamOrSubjects'] ?? $profile->streamSubjects ?? '')); + $profile->estimatedScore = isset($form['estimatedScore']) ? (int) $form['estimatedScore'] : null; + $profile->formJson = $form; + $profile->formStatus = 1; + if ((int) ($profile->entryStatus ?? 0) === 0) { + $profile->entryStatus = 1; + } + $now = time(); + $profile->updatedAt = $now; + $profile->save(); + return $profile; + } + + /** + * formJson 落库可能为 JSON 列/文本;读出口可能是 string、stdClass 或 array,统一为数组供接口返回 + */ + public static function formJsonAsArray(GaokaoUserProfile $profile): array + { + return self::jsonLikeToArray($profile->formJson ?? null); + } + + /** + * @param mixed $raw + * @return array + */ + private static function jsonLikeToArray($raw): array + { + if (is_array($raw)) { + return $raw; + } + if ($raw instanceof \stdClass) { + $a = json_decode(json_encode($raw, JSON_UNESCAPED_UNICODE), true); + return is_array($a) ? $a : []; + } + if (is_string($raw)) { + $t = trim($raw); + if ($t === '') { + return []; + } + $a = json_decode($t, true); + return is_array($a) ? $a : []; + } + return []; + } + + /** + * @param string $pricingScope personal|enterprise(与小程序 appScope 一致) + */ + public static function resolvePricing( + int $userId, + string $productCode = 'gaokao_single_report', + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): array { + $tenantId = self::resolveTenantIdByWechatUserId($userId); + $ctx = self::resolveGaokaoReportSaleContext($userId, $pricingScope, $requestEnterpriseId); + $fen = (int) ($ctx['fen'] ?? 0); + + return [ + 'hasPricing' => $fen > 0, + 'productCode' => $productCode, + 'pricingId' => 0, + 'priceOriginal' => $fen, + 'priceSale' => $fen, + 'currency' => 'CNY', + 'tenantId' => $tenantId, + 'pricingType' => (string) ($ctx['pricingType'] ?? 'personal'), + 'pricingScope' => (string) ($ctx['pricingTier'] ?? 'personal'), + ]; + } + + /** + * 支付侧重算高考应付(与当前 Tab / 请求参数一致) + * + * @param string $pricingScope personal|enterprise + * @return array{0:int,1:string} [amountFen, pricingType] + */ + public static function gaokaoSaleAmountForPaymentRecalc( + int $wechatUserId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): array { + if ($wechatUserId <= 0) { + return [0, 'personal']; + } + $ctx = self::resolveGaokaoReportSaleContext($wechatUserId, $pricingScope, $requestEnterpriseId); + + return [(int) ($ctx['fen'] ?? 0), (string) ($ctx['pricingType'] ?? 'personal')]; + } + + /** + * 发起支付或打开报告前:按当前 Tab 刷新未付高考记录的 paidAmount / enterpriseId(与订单金额一致) + * + * @param string $pricingScope personal|enterprise + */ + public static function refreshGaokaoTestResultForPayment( + int $userId, + int $testResultId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): void { + if ($userId <= 0 || $testResultId <= 0) { + return; + } + $row = Db::name('test_results') + ->where('id', $testResultId) + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->find(); + if (!$row || (int) ($row['isPaid'] ?? 0) !== 0) { + return; + } + + $ctx = self::resolveGaokaoReportSaleContext($userId, $pricingScope, $requestEnterpriseId); + $now = time(); + $writeEid = $ctx['writeEnterpriseId'] ?? null; + $testScope = (($ctx['pricingTier'] ?? 'personal') === 'enterprise') ? 'enterprise' : 'personal'; + + $orderId = (int) ($row['orderId'] ?? 0); + if ($orderId > 0) { + Db::name('test_results') + ->where('id', $testResultId) + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->where('isPaid', 0) + ->update([ + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + 'updatedAt' => $now, + ]); + + return; + } + + $fen = (int) ($ctx['fen'] ?? 0); + $audit = miniprogram_audit_mode_on(); + $requiresPayment = (!$audit && $fen > 0) ? 1 : 0; + $isPaid = $requiresPayment ? 0 : 1; + $paidAmount = ($requiresPayment && $fen > 0) ? $fen : ($isPaid ? 0 : null); + $paidAt = $isPaid ? $now : null; + + Db::name('test_results') + ->where('id', $testResultId) + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->where('isPaid', 0) + ->update([ + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + 'requiresPayment' => $requiresPayment, + 'isPaid' => $isPaid, + 'paidAmount' => $paidAmount, + 'paidAt' => $paidAt, + 'updatedAt' => $now, + ]); + } + + /** + * 子测评最新一条是否带企业归属(与问卷提交时 enterpriseId 一致) + */ + private static function inferEnterpriseIdFromPrerequisiteTests(int $userId): int + { + if ($userId <= 0) { + return 0; + } + $latest = self::latestTestMap($userId); + foreach (['mbti', 'pdp', 'disc', 'face'] as $k) { + $row = $latest[$k] ?? null; + if (!is_array($row)) { + continue; + } + $e = (int) ($row['enterpriseId'] ?? 0); + if ($e > 0) { + return $e; + } + } + + return 0; + } + + /** + * 报告生成后扣平台费:优先定价上下文中的企业,再绑定企业,再子测评 enterpriseId。 + */ + private static function resolveGaokaoPlatformFeeEnterpriseId(int $userId, ?int $preferredFromPricing): int + { + $e = (int) ($preferredFromPricing ?? 0); + if ($e > 0) { + return $e; + } + if ($userId > 0) { + $e = self::resolveTenantIdByWechatUserId($userId); + } + if ($e > 0) { + return $e; + } + + return self::inferEnterpriseIdFromPrerequisiteTests($userId); + } + + /** + * @param string $pricingScope personal|enterprise + * @return array{fen:int, pricingType:string, writeEnterpriseId:?int, pricingTier:string} + */ + private static function resolveGaokaoReportSaleContext( + int $userId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): array { + $pricingScope = ($pricingScope === 'enterprise') ? 'enterprise' : 'personal'; + $bound = self::resolveTenantIdByWechatUserId($userId); + $eFromTests = self::inferEnterpriseIdFromPrerequisiteTests($userId); + + $eidForEnterprise = (int) ($requestEnterpriseId ?? 0); + if ($eidForEnterprise <= 0) { + $eidForEnterprise = $bound > 0 ? $bound : $eFromTests; + } + + if ($pricingScope === 'enterprise' && $eidForEnterprise > 0) { + [$fen, $pricingType] = TestProductPricing::amountFenForTestProduct( + 'gaokao', + $userId, + $eidForEnterprise, + 1, + 'enterprise' + ); + + return [ + 'fen' => (int) $fen, + 'pricingType' => $pricingType, + 'writeEnterpriseId' => $eidForEnterprise, + 'pricingTier' => 'enterprise', + ]; + } + + [$fen, $pricingType] = TestProductPricing::amountFenForTestProduct( + 'gaokao', + $userId, + null, + 1, + 'personal' + ); + + $write = 0; + if ((int) ($requestEnterpriseId ?? 0) > 0) { + $write = (int) $requestEnterpriseId; + } elseif ($bound > 0) { + $write = $bound; + } elseif ($eFromTests > 0) { + $write = $eFromTests; + } + + return [ + 'fen' => (int) $fen, + 'pricingType' => $pricingType, + 'writeEnterpriseId' => $write > 0 ? $write : null, + 'pricingTier' => 'personal', + ]; + } + + /** + * 每次「生成分析」都新建一行 gaokao test_results: + * - 与 MBTI / PDP / DISC / Face 行为对齐(每次提交即一条记录) + * - 平台费按 testResultId 幂等,不复用旧行 → 重新生成会按企业版单价再次扣费 + * - 旧的未付占位仅作为历史保留,不阻碍新报告 + * + * @param string $pricingScope personal|enterprise + */ + private static function acquireGaokaoAnalysisTestResultId( + int $userId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): int { + if ($userId <= 0) { + return 0; + } + + $ctx = self::resolveGaokaoReportSaleContext($userId, $pricingScope, $requestEnterpriseId); + $fen = (int) ($ctx['fen'] ?? 0); + $audit = miniprogram_audit_mode_on(); + $requiresPayment = (!$audit && $fen > 0) ? 1 : 0; + $isPaid = $requiresPayment ? 0 : 1; + $now = time(); + $writeEid = $ctx['writeEnterpriseId'] ?? null; + $testScope = (($ctx['pricingTier'] ?? 'personal') === 'enterprise') ? 'enterprise' : 'personal'; + + $id = Db::name('test_results')->insertGetId([ + 'userId' => $userId, + 'testType' => 'gaokao', + 'resultData' => json_encode([ + 'kind' => 'gaokao', + 'state' => 'generating', + 'version' => 'v1', + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + 'score' => null, + 'orderId' => null, + 'requiresPayment' => $requiresPayment, + 'isPaid' => $isPaid, + 'paidAmount' => ($requiresPayment && $fen > 0) ? $fen : ($isPaid ? 0 : null), + 'paidAt' => $isPaid ? $now : null, + 'createdAt' => $now, + 'updatedAt' => $now, + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + ]); + + return (int) $id; + } + + /** + * @param string $pricingScope personal|enterprise 与小程序 appScope 一致 + */ + public static function createAnalysis( + int $userId, + string $pricingScope = 'personal', + ?int $requestEnterpriseId = null + ): array { + $task = self::loadTaskStatus($userId); + if (empty($task['canAnalyze'])) { + return [ + 'ok' => false, + 'message' => '请先完成测试与表单后再分析', + 'missingItems' => $task['missingItems'] ?? [], + ]; + } + + $profile = self::getOrInitProfile($userId); + $latest = self::latestTestMap($userId); + $form = self::formJsonAsArray($profile); + + $input = [ + 'name' => (string) ($profile->name ?? ''), + 'province' => (string) ($profile->province ?? ''), + 'streamSubjects' => (string) ($profile->streamSubjects ?? ''), + 'estimatedScore' => (int) ($profile->estimatedScore ?? 0), + 'mbti' => self::extractSummaryType('mbti', is_array($latest['mbti'] ?? null) ? ($latest['mbti']['resultData'] ?? []) : []), + 'pdp' => self::extractSummaryType('pdp', is_array($latest['pdp'] ?? null) ? ($latest['pdp']['resultData'] ?? []) : []), + 'disc' => self::extractSummaryType('disc', is_array($latest['disc'] ?? null) ? ($latest['disc']['resultData'] ?? []) : []), + 'faceMbti' => self::extractSummaryType('face', is_array($latest['face'] ?? null) ? ($latest['face']['resultData'] ?? []) : []), + 'form' => $form, + ]; + + $tid = self::acquireGaokaoAnalysisTestResultId($userId, $pricingScope, $requestEnterpriseId); + if ($tid <= 0) { + return [ + 'ok' => false, + 'message' => '创建高考测评记录失败,请稍后重试', + 'missingItems' => [], + ]; + } + + $promptSystem = '你是高考志愿分析助手。输出严格 JSON,字段至少包含 overview, schoolRecommend, majorRecommend, personalityReason, disclaimers, searchMeta。searchMeta.queryCount 必须是数字。'; + $promptUser = '请根据以下用户数据,输出高考志愿建议:' . json_encode($input, JSON_UNESCAPED_UNICODE); + + $content = ''; + for ($i = 0; $i < 2; $i++) { + $res = AiCallService::chat([ + ['role' => 'system', 'content' => $promptSystem], + ['role' => 'user', 'content' => $promptUser], + ], ['temperature' => 0.2, 'maxTokens' => 2200]); + $content = trim((string) ($res['content'] ?? '')); + if ($content !== '') { + break; + } + } + + $json = json_decode($content, true); + if (!is_array($json)) { + $json = self::fallbackReport($input); + } + $json = self::normalizeReport($json, $input); + + $now = time(); + $resultPayload = [ + 'version' => 'v1', + 'kind' => 'gaokao', + 'state' => 'ready', + 'inputSnapshot' => $input, + 'report' => $json, + 'overview' => (string) ($json['overview'] ?? ''), + 'searchMeta' => $json['searchMeta'] ?? [], + ]; + + $ctx = self::resolveGaokaoReportSaleContext($userId, $pricingScope, $requestEnterpriseId); + $writeEid = $ctx['writeEnterpriseId'] ?? null; + $testScope = (($ctx['pricingTier'] ?? 'personal') === 'enterprise') ? 'enterprise' : 'personal'; + + Db::startTrans(); + try { + Db::name('test_results') + ->where('id', $tid) + ->where('userId', $userId) + ->update([ + 'resultData' => json_encode($resultPayload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + 'enterpriseId' => $writeEid, + 'testScope' => $testScope, + 'updatedAt' => $now, + ]); + + $profile->analyzeStatus = 1; + $profile->lastAnalyzeAt = $now; + $profile->latestReportId = $tid; + $profile->entryStatus = 2; + $profile->save(); + + Db::commit(); + + // 报告生成成功即扣企业平台费(与 Test::saveResult 一致);ctx 未带企业时回落绑定/子测,避免漏扣 + try { + $eidBill = self::resolveGaokaoPlatformFeeEnterpriseId($userId, $writeEid); + if ($eidBill > 0) { + $rowE = (int) Db::name('test_results')->where('id', $tid)->value('enterpriseId'); + if ($rowE <= 0) { + Db::name('test_results')->where('id', $tid)->update([ + 'enterpriseId' => $eidBill, + 'updatedAt' => time(), + ]); + } + EnterpriseBillingService::chargePlatformFeeForTestResult($tid, 'gaokao', $eidBill); + } + } catch (\Throwable $e) { + // 扣费失败不阻断已生成的报告 + } + + return [ + 'ok' => true, + 'reportId' => $tid, + 'report' => $json, + ]; + } catch (\Throwable $e) { + Db::rollback(); + return [ + 'ok' => false, + 'message' => '生成失败:' . $e->getMessage(), + ]; + } + } + + public static function myLatestReport(int $userId): ?array + { + $row = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->order('id', 'desc') + ->find(); + if (!$row) { + return null; + } + $raw = $row['resultData'] ?? ''; + $rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []); + $requiresPayment = (int) ($row['requiresPayment'] ?? 0); + $isPaid = (int) ($row['isPaid'] ?? 0); + if ($requiresPayment && !$isPaid && !miniprogram_audit_mode_on()) { + $filtered = TestApiController::filterResultToPartialStatic('gaokao', $rd); + $filtered = is_array($filtered) ? $filtered : []; + $ov = (string) ($filtered['overview'] ?? ''); + $inputSnap = is_array($filtered['inputSnapshot'] ?? null) + ? $filtered['inputSnapshot'] + : (is_array($rd['inputSnapshot'] ?? null) ? $rd['inputSnapshot'] : []); + $report = self::normalizeReport([ + 'overview' => $ov, + 'personalityReason' => '', + 'disclaimers' => '', + 'majorRecommend' => [], + 'schoolRecommend' => [], + 'locked' => true, + 'inputEcho' => [ + 'name' => (string) ($inputSnap['name'] ?? ''), + 'province' => (string) ($inputSnap['province'] ?? ''), + 'streamSubjects' => (string) ($inputSnap['streamSubjects'] ?? ''), + 'estimatedScore' => isset($inputSnap['estimatedScore']) ? (int) $inputSnap['estimatedScore'] : 0, + 'mbti' => (string) ($inputSnap['mbti'] ?? ''), + 'pdp' => (string) ($inputSnap['pdp'] ?? ''), + 'disc' => (string) ($inputSnap['disc'] ?? ''), + ], + ], $inputSnap); + return [ + 'id' => (int) $row['id'], + 'createdAt' => (int) ($row['createdAt'] ?? 0), + 'overview' => $ov, + 'report' => $report, + ]; + } + + $report = $rd['report'] ?? null; + if (is_string($report)) { + $report = json_decode(trim($report), true); + } + if (!is_array($report) || $report === []) { + return null; + } + $input = is_array($rd['inputSnapshot'] ?? null) ? $rd['inputSnapshot'] : []; + $report = self::normalizeReport($report, $input); + $overview = (string) ($rd['overview'] ?? $report['overview'] ?? ''); + + return [ + 'id' => (int) $row['id'], + 'createdAt' => (int) ($row['createdAt'] ?? 0), + 'overview' => $overview, + 'report' => $report, + ]; + } + + /** + * 与 api Test::decodeResultDataPayload 一致:解析 resultData 并展开内层 result(PDP/DISC 分数在嵌套里时任务摘要才正确) + * + * @param mixed $raw + * @return array + */ + private static function decodeResultDataPayloadForTask($raw): array + { + if ($raw === null || $raw === '') { + return []; + } + if (is_array($raw)) { + $data = $raw; + } elseif (is_string($raw)) { + $decoded = json_decode(trim($raw), true); + $data = is_array($decoded) ? $decoded : []; + } else { + return []; + } + if (isset($data['result']) && is_array($data['result'])) { + $inner = $data['result']; + if (isset($inner['percentages']) || isset($inner['scores']) || isset($inner['dominantType']) + || isset($inner['description']) || isset($inner['mbtiType'])) { + $data = array_merge($data, $inner); + } + } + + return $data; + } + + private static function latestTestMap(int $userId): array + { + $rows = Db::name('test_results') + ->where('userId', $userId) + ->whereIn('testType', ['mbti', 'pdp', 'disc', 'face', 'ai']) + ->order('createdAt', 'desc') + ->field('id,testType,resultData,createdAt,enterpriseId') + ->select() + ->toArray(); + $out = []; + foreach ($rows as $r) { + $t = (string) ($r['testType'] ?? ''); + if ($t === '') { + continue; + } + if ($t === 'face' || $t === 'ai') { + if (isset($out['face'])) { + continue; + } + $r['resultData'] = self::decodeResultDataPayloadForTask($r['resultData'] ?? null); + $out['face'] = $r; + + continue; + } + if (isset($out[$t])) { + continue; + } + $r['resultData'] = self::decodeResultDataPayloadForTask($r['resultData'] ?? null); + $out[$t] = $r; + } + + return $out; + } + + /** + * 与 /api/test/recent 单条结构对齐,便于小程序「我的测评」同款卡片渲染 + * + * @return array + */ + private static function buildTaskItem(string $code, bool $done, ?array $row): array + { + $labels = [ + 'mbti' => ['testType' => 'mbti', 'typeName' => 'MBTI性格', 'emoji' => '🧠'], + 'pdp' => ['testType' => 'pdp', 'typeName' => 'PDP行为', 'emoji' => '🦁'], + 'disc' => ['testType' => 'disc', 'typeName' => 'DISC测评', 'emoji' => '📊'], + 'face' => ['testType' => 'ai', 'typeName' => '拍照面相', 'emoji' => '📷'], + ]; + $lb = $labels[$code] ?? ['testType' => $code, 'typeName' => $code, 'emoji' => '📋']; + + $resultText = ''; + $updatedAt = 0; + $data = []; + $recordTestType = $lb['testType']; + if ($row) { + $updatedAt = (int) ($row['createdAt'] ?? 0); + $data = is_array($row['resultData'] ?? null) ? $row['resultData'] : []; + $resultText = self::extractSummaryType($code, $data); + if ($code === 'face') { + $rt = strtolower((string) ($row['testType'] ?? 'ai')); + $recordTestType = ($rt === 'face' || $rt === 'ai') ? $rt : 'ai'; + } + } + $tid = 0; + if ($done && $row && !empty($row['id'])) { + $tid = (int) $row['id']; + } + + $testTime = ($done && $updatedAt > 0) ? date('Y-m-d', $updatedAt) : ''; + + $resultMeta = null; + if ($done && $code === 'disc' && $data !== []) { + $resultMeta = [ + 'scores' => $data['scores'] ?? null, + 'percentages' => $data['percentages'] ?? null, + 'dominantType' => $data['dominantType'] ?? null, + 'secondaryType' => $data['secondaryType'] ?? null, + 'description' => $data['description'] ?? null, + 'disc' => $data['disc'] ?? null, + ]; + } elseif ($done && $code === 'pdp' && $data !== []) { + $resultMeta = [ + 'scores' => $data['scores'] ?? null, + 'percentages' => $data['percentages'] ?? null, + 'dominantType' => $data['dominantType'] ?? null, + 'secondaryType' => $data['secondaryType'] ?? null, + 'description' => $data['description'] ?? null, + 'pdp' => $data['pdp'] ?? null, + ]; + } + + $out = [ + 'code' => $code, + 'status' => $done ? 'done' : 'todo', + 'resultText' => $done ? $resultText : '', + 'updatedAt' => $updatedAt, + 'testResultId' => $tid, + 'id' => $tid, + 'testType' => $lb['testType'], + 'typeName' => $lb['typeName'], + 'emoji' => $lb['emoji'], + 'testTime' => $testTime, + 'recordTestType' => $recordTestType, + ]; + if ($resultMeta !== null) { + $out['resultMeta'] = $resultMeta; + } + + return $out; + } + + /** + * @return array + */ + private static function buildFormTaskItem(bool $done, GaokaoUserProfile $profile): array + { + $updatedAt = (int) ($profile->updatedAt ?? 0); + $createdAt = (int) ($profile->createdAt ?? 0); + $ts = max($updatedAt, $createdAt); + // 避免未写入时间戳时 date(0) 变成 1970-01-01 + $testTime = ($done && $ts >= 946684800) ? date('Y-m-d', $ts) : ''; + + return [ + 'code' => 'form', + 'status' => $done ? 'done' : 'todo', + 'resultText' => $done ? ((string) ($profile->province ?? '')) : '', + 'updatedAt' => $ts, + 'testResultId' => 0, + 'id' => 0, + 'testType' => 'form', + 'typeName' => '高考信息表单', + 'emoji' => '📝', + 'testTime' => $testTime, + ]; + } + + /** + * 任务摘要文案:PDP/DISC 与 PdpDiscResultText、/api/test/recent 一致 + * + * @param array $data + */ + private static function extractSummaryType(string $code, array $data): string + { + $code = strtolower(trim($code)); + if ($code === 'mbti') { + if (isset($data['mbtiType']) && $data['mbtiType'] !== '') { + return (string) $data['mbtiType']; + } + + return ''; + } + if ($code === 'pdp') { + $two = PdpDiscResultText::pdpTopTwo($data); + if ($two !== '') { + return $two; + } + $desc = $data['description'] ?? null; + $dType = is_array($desc) ? ($desc['type'] ?? null) : null; + if (is_string($dType) && trim($dType) !== '') { + return trim($dType); + } + + return isset($data['pdp']) ? (string) $data['pdp'] : ''; + } + if ($code === 'disc') { + $two = PdpDiscResultText::discTopTwo($data); + if ($two !== '') { + return $two; + } + $dominantType = $data['dominantType'] ?? $data['disc'] ?? ''; + if ($dominantType !== '' && $dominantType !== null) { + return (is_string($dominantType) || is_numeric($dominantType) ? (string) $dominantType : '未知') . '型'; + } + + return ''; + } + if ($code === 'face' || $code === 'ai') { + if (isset($data['mbti']['type']) && $data['mbti']['type'] !== '') { + return (string) $data['mbti']['type']; + } + if (isset($data['mbti']) && !is_array($data['mbti']) && $data['mbti'] !== '') { + return (string) $data['mbti']; + } + + return ''; + } + + return ''; + } + + /** + * @param array $a + */ + private static function isListArray(array $a): bool + { + if ($a === []) { + return true; + } + + return array_keys($a) === range(0, count($a) - 1); + } + + /** + * @param mixed $v + * @return array|null + */ + private static function tryJsonDecodeArray($v): ?array + { + if (is_array($v)) { + return $v; + } + if (!is_string($v)) { + return null; + } + $s = trim($v); + if ($s === '') { + return null; + } + $d = json_decode($s, true); + + return is_array($d) ? $d : null; + } + + /** + * @param array $report + * @return array + */ + private static function mergeCanonicalReportKeys(array $report): array + { + $pairs = [ + 'school_recommend' => 'schoolRecommend', + 'schoolRecommendations' => 'schoolRecommend', + 'major_recommend' => 'majorRecommend', + 'majorRecommends' => 'majorRecommend', + 'majors' => 'majorRecommend', + 'personality_reason' => 'personalityReason', + ]; + foreach ($pairs as $from => $to) { + if (!array_key_exists($to, $report) && array_key_exists($from, $report)) { + $report[$to] = $report[$from]; + } + } + if (!array_key_exists('disclaimers', $report) && array_key_exists('disclaimer', $report)) { + $d = $report['disclaimer']; + $report['disclaimers'] = is_string($d) ? $d : (is_array($d) ? json_encode($d, JSON_UNESCAPED_UNICODE) : ''); + } + + return $report; + } + + /** + * @param mixed $raw + * @return array + */ + private static function normalizeSchoolRecommendStructure($raw): array + { + $arr = self::tryJsonDecodeArray($raw); + if ($arr === null) { + return ['chong' => [], 'wen' => [], 'bao' => []]; + } + if (isset($arr['chong']) || isset($arr['wen']) || isset($arr['bao'])) { + return [ + 'chong' => isset($arr['chong']) && is_array($arr['chong']) ? array_values($arr['chong']) : [], + 'wen' => isset($arr['wen']) && is_array($arr['wen']) ? array_values($arr['wen']) : [], + 'bao' => isset($arr['bao']) && is_array($arr['bao']) ? array_values($arr['bao']) : [], + ]; + } + if (isset($arr['冲']) || isset($arr['稳']) || isset($arr['保'])) { + return [ + 'chong' => isset($arr['冲']) && is_array($arr['冲']) ? array_values($arr['冲']) : [], + 'wen' => isset($arr['稳']) && is_array($arr['稳']) ? array_values($arr['稳']) : [], + 'bao' => isset($arr['保']) && is_array($arr['保']) ? array_values($arr['保']) : [], + ]; + } + if (isset($arr['stretch']) || isset($arr['stable']) || isset($arr['safe']) || isset($arr['safety']) || isset($arr['reach'])) { + $bao = []; + if (isset($arr['safe']) && is_array($arr['safe'])) { + $bao = array_values($arr['safe']); + } elseif (isset($arr['safety']) && is_array($arr['safety'])) { + $bao = array_values($arr['safety']); + } + + return [ + 'chong' => isset($arr['stretch']) && is_array($arr['stretch']) + ? array_values($arr['stretch']) + : (isset($arr['reach']) && is_array($arr['reach']) ? array_values($arr['reach']) : []), + 'wen' => isset($arr['stable']) && is_array($arr['stable']) + ? array_values($arr['stable']) + : (isset($arr['match']) && is_array($arr['match']) ? array_values($arr['match']) : []), + 'bao' => $bao, + ]; + } + foreach (['schools', 'list', 'items'] as $k) { + if (isset($arr[$k]) && is_array($arr[$k]) && self::isListArray($arr[$k])) { + return array_values($arr[$k]); + } + } + if (self::isListArray($arr)) { + return array_values($arr); + } + + return ['chong' => [], 'wen' => [], 'bao' => []]; + } + + /** + * @param mixed $raw + * @return array> + */ + private static function normalizeMajorRecommendStructure($raw): array + { + $arr = self::tryJsonDecodeArray($raw); + if ($arr === null) { + return []; + } + if (!self::isListArray($arr)) { + foreach (['items', 'majors', 'list', '专业'] as $k) { + if (isset($arr[$k]) && is_array($arr[$k]) && self::isListArray($arr[$k])) { + $arr = $arr[$k]; + break; + } + } + if (!self::isListArray($arr)) { + return []; + } + } + $out = []; + foreach ($arr as $item) { + if (is_string($item)) { + $t = trim($item); + if ($t !== '') { + $out[] = ['majorName' => $t]; + } + continue; + } + if (!is_array($item)) { + continue; + } + $name = $item['majorName'] ?? $item['name'] ?? $item['title'] ?? $item['major'] ?? $item['major_name'] + ?? $item['专业'] ?? $item['专业名称'] ?? $item['majorChinese'] ?? ''; + $name = is_string($name) ? trim($name) : (is_numeric($name) ? (string) $name : ''); + $row = $item; + $row['majorName'] = $name; + $out[] = $row; + } + + return $out; + } + + private static function normalizeReport(array $report, array $input): array + { + $report = self::mergeCanonicalReportKeys($report); + $report['schoolRecommend'] = self::normalizeSchoolRecommendStructure($report['schoolRecommend'] ?? null); + $report['majorRecommend'] = self::normalizeMajorRecommendStructure($report['majorRecommend'] ?? null); + + if (!isset($report['overview']) || !is_string($report['overview'])) { + $report['overview'] = '基于你的测评与分数信息,建议先采用冲稳保梯度填报,并结合目标地区与专业方向进行二次筛选。'; + } + if (!isset($report['personalityReason']) || !is_string($report['personalityReason'])) { + $report['personalityReason'] = '建议结合 MBTI/PDP/DISC 综合判断,优先选择与你认知风格和执行偏好一致的专业。'; + } + if (!isset($report['disclaimers']) || !is_string($report['disclaimers'])) { + $report['disclaimers'] = '本报告为估测建议,非录取保证,最终请以各省考试院与院校官方信息为准。'; + } + if (!isset($report['searchMeta']) || !is_array($report['searchMeta'])) { + $report['searchMeta'] = []; + } + if (!isset($report['searchMeta']['queryCount'])) { + $report['searchMeta']['queryCount'] = 0; + } + if (!isset($report['searchMeta']['coverage'])) { + $report['searchMeta']['coverage'] = 'none'; + } + if (!isset($report['inputEcho'])) { + $report['inputEcho'] = $input; + } + return $report; + } + + private static function fallbackReport(array $input): array + { + return [ + 'overview' => '当前使用降级分析结果:建议先按冲稳保策略建立志愿梯度,再由老师结合最新政策进行人工复核。', + 'schoolRecommend' => ['chong' => [], 'wen' => [], 'bao' => []], + 'majorRecommend' => [], + 'personalityReason' => '你的测评结果显示具备稳定的性格偏好,可优先选择与优势能力匹配的学科方向。', + 'disclaimers' => '上下文不完整,仅供方向参考,非录取保证,请以官方数据为准。', + 'searchMeta' => ['queryCount' => 0, 'queries' => [], 'coverage' => 'none'], + 'inputEcho' => $input, + ]; + } +} diff --git a/api/app/common/service/TestProductPricing.php b/api/app/common/service/TestProductPricing.php new file mode 100644 index 0000000..17a8da5 --- /dev/null +++ b/api/app/common/service/TestProductPricing.php @@ -0,0 +1,54 @@ + 0 ? $quantity : 1; + + $pricingEnterpriseId = null; + if ($enterpriseId !== null && (int) $enterpriseId > 0) { + $pricingEnterpriseId = (int) $enterpriseId; + } elseif ($wechatUserId > 0) { + $userEid = (int) Db::name('wechat_users')->where('id', $wechatUserId)->value('enterpriseId'); + if ($userEid > 0) { + $pricingEnterpriseId = $userEid; + } + } + + $pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId); + $config = []; + if ($pricingConfig && !empty($pricingConfig->config)) { + $raw = $pricingConfig->config; + $config = is_array($raw) ? $raw : (array) $raw; + } + + $keyMap = ['team_analysis' => 'teamAnalysis']; + $key = $keyMap[$productType] ?? $productType; + $unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0; + $amountFen = (int) round($unitPriceYuan * 100 * $quantity); + + return [$amountFen, $pricingType]; + } +} diff --git a/api/app/controller/admin/AppUser.php b/api/app/controller/admin/AppUser.php index e6942d7..3e6368c 100644 --- a/api/app/controller/admin/AppUser.php +++ b/api/app/controller/admin/AppUser.php @@ -172,6 +172,8 @@ class AppUser extends BaseController $enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId); } $coopMap = []; + $gaokaoMap = []; + $gaokaoReportMap = []; if (!empty($ids)) { // 测试统计严格按 test_results.enterpriseId 归属企业过滤 $trBase = Db::name('test_results')->where('userId', 'in', $ids); @@ -313,6 +315,47 @@ class AppUser extends BaseController $coopMap = []; } } + + // 高考任务状态与最近报告摘要 + try { + $gqRows = Db::name('gaokao_user_profile') + ->whereIn('userId', $ids) + ->where('tenantId', (int) $enterpriseId) + ->field('id,userId,entryStatus,mbtiStatus,pdpStatus,discStatus,formStatus,analyzeStatus,lastAnalyzeAt,latestReportId,tagsJson') + ->select() + ->toArray(); + $reportIds = []; + foreach ($gqRows as $gr) { + $uid = (int) ($gr['userId'] ?? 0); + if ($uid > 0) { + $gaokaoMap[$uid] = $gr; + if (!empty($gr['latestReportId'])) { + $reportIds[] = (int) $gr['latestReportId']; + } + } + } + $reportIds = array_values(array_unique(array_filter($reportIds))); + if ($reportIds) { + $rRows = Db::name('test_results') + ->whereIn('id', $reportIds) + ->where('testType', 'gaokao') + ->field('id,resultData') + ->select() + ->toArray(); + foreach ($rRows as $rr) { + $raw = $rr['resultData'] ?? ''; + $rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []); + $ov = (string) ($rd['overview'] ?? ''); + if ($ov === '' && isset($rd['report']['overview'])) { + $ov = (string) $rd['report']['overview']; + } + $gaokaoReportMap[(int) $rr['id']] = $ov; + } + } + } catch (\Throwable $e) { + $gaokaoMap = []; + $gaokaoReportMap = []; + } } foreach ($list as &$row) { @@ -358,6 +401,19 @@ class AppUser extends BaseController $row['cooperationModeTitle'] = null; $row['cooperationChosenAt'] = null; } + + $gq = $gaokaoMap[$id] ?? null; + $row['gaokaoEntryStatus'] = $gq ? (int) ($gq['entryStatus'] ?? 0) : 0; + $row['gaokaoAnalyzeStatus'] = $gq ? (int) ($gq['analyzeStatus'] ?? 0) : 0; + $row['gaokaoFormStatus'] = $gq ? (int) ($gq['formStatus'] ?? 0) : 0; + $row['gaokaoTaskStatus'] = [ + 'mbti' => $gq ? (int) ($gq['mbtiStatus'] ?? 0) : 0, + 'pdp' => $gq ? (int) ($gq['pdpStatus'] ?? 0) : 0, + 'disc' => $gq ? (int) ($gq['discStatus'] ?? 0) : 0, + ]; + $row['gaokaoLastAnalyzeAt'] = $gq ? (int) ($gq['lastAnalyzeAt'] ?? 0) : 0; + $rid = $gq ? (int) ($gq['latestReportId'] ?? 0) : 0; + $row['gaokaoOverview'] = $rid > 0 ? (string) ($gaokaoReportMap[$rid] ?? '') : ''; } return paginate_response($list, $total, $page, $pageSize); @@ -501,6 +557,28 @@ class AppUser extends BaseController ? ResumeUploadsAdminService::listForWechatUser((int) $id, (int) $enterpriseId) : []; + // 高考结果信息 + try { + $gq = Db::name('gaokao_user_profile') + ->where('userId', (int) $id) + ->where('tenantId', (int) $enterpriseId) + ->find(); + if ($gq) { + $data['gaokaoProfile'] = $gq; + $rid = (int) ($gq['latestReportId'] ?? 0); + if ($rid > 0) { + $data['gaokaoLatestReport'] = Db::name('test_results') + ->where('id', $rid) + ->where('testType', 'gaokao') + ->find(); + } + } else { + $data['gaokaoProfile'] = null; + } + } catch (\Throwable $e) { + $data['gaokaoProfile'] = null; + } + return success($data); } diff --git a/api/app/controller/admin/Distribution.php b/api/app/controller/admin/Distribution.php index 612b60c..b996825 100644 --- a/api/app/controller/admin/Distribution.php +++ b/api/app/controller/admin/Distribution.php @@ -753,7 +753,7 @@ class Distribution extends BaseController private static function defaultTestSettings(): array { $item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false]; - return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item]; + return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item, 'gaokao' => $item]; } private static function sanitizeTestSettings($raw): array @@ -832,12 +832,13 @@ class Distribution extends BaseController } $totals = [ - 'face' => 0, - 'mbti' => 0, - 'sbti' => 0, - 'disc' => 0, - 'pdp' => 0, - 'other' => 0, + 'face' => 0, + 'mbti' => 0, + 'sbti' => 0, + 'disc' => 0, + 'pdp' => 0, + 'gaokao' => 0, + 'other' => 0, ]; foreach ($records as $record) { @@ -860,6 +861,7 @@ class Distribution extends BaseController ['label' => 'SBTI', 'value' => round($totals['sbti'] / 100, 2)], ['label' => 'DISC', 'value' => round($totals['disc'] / 100, 2)], ['label' => 'PDP', 'value' => round($totals['pdp'] / 100, 2)], + ['label' => '高考志愿', 'value' => round($totals['gaokao'] / 100, 2)], ['label' => '其他', 'value' => round($totals['other'] / 100, 2)], ]; } @@ -870,7 +872,7 @@ class Distribution extends BaseController if ($normalized === 'ai') { return 'face'; } - if (in_array($normalized, ['face', 'mbti', 'sbti', 'disc', 'pdp'], true)) { + if (in_array($normalized, ['face', 'mbti', 'sbti', 'disc', 'pdp', 'gaokao'], true)) { return $normalized; } return 'other'; @@ -879,12 +881,13 @@ class Distribution extends BaseController private static function getTestTypeLabel(string $testType): string { $map = [ - 'face' => '人脸', - 'mbti' => 'MBTI', - 'sbti' => 'SBTI', - 'disc' => 'DISC', - 'pdp' => 'PDP', - 'other' => '其他', + 'face' => '人脸', + 'mbti' => 'MBTI', + 'sbti' => 'SBTI', + 'disc' => 'DISC', + 'pdp' => 'PDP', + 'gaokao' => '高考志愿', + 'other' => '其他', ]; return $map[$testType] ?? strtoupper($testType ?: '其他'); diff --git a/api/app/controller/admin/GaokaoUser.php b/api/app/controller/admin/GaokaoUser.php new file mode 100644 index 0000000..6a90368 --- /dev/null +++ b/api/app/controller/admin/GaokaoUser.php @@ -0,0 +1,96 @@ +request->user ?? null; + if (!$user || !in_array(($user['role'] ?? ''), ['admin', 'enterprise_admin'], true)) { + return 0; + } + $eid = (int) ($user['enterpriseId'] ?? 0); + if ($eid > 0) { + return $eid; + } + $adminRow = Db::name('users')->where('id', (int) ($user['userId'] ?? 0))->find(); + return (int) ($adminRow['enterpriseId'] ?? 0); + } + + public function index() + { + $eid = $this->currentEnterpriseId(); + if ($eid <= 0) { + return error('无权限访问', 403); + } + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, max(1, (int) Request::param('pageSize', 20))); + $keyword = trim((string) Request::param('keyword', '')); + $analyzeStatus = Request::param('analyzeStatus', ''); + + $q = Db::name('gaokao_user_profile')->alias('g') + ->join('wechat_users w', 'w.id = g.userId') + ->where('g.tenantId', $eid); + if ($keyword !== '') { + $q->whereRaw('(w.nickname LIKE ? OR w.phone LIKE ? OR g.name LIKE ?)', ['%' . $keyword . '%', '%' . $keyword . '%', '%' . $keyword . '%']); + } + if ($analyzeStatus !== '' && $analyzeStatus !== null) { + $q->where('g.analyzeStatus', (int) $analyzeStatus); + } + + $total = (int) (clone $q)->count(); + $rows = (clone $q)->field('g.*,w.nickname,w.phone,w.avatar') + ->order('g.id', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + return paginate_response($rows, $total, $page, $pageSize); + } + + public function detail($id) + { + $eid = $this->currentEnterpriseId(); + if ($eid <= 0) { + return error('无权限访问', 403); + } + $row = Db::name('gaokao_user_profile')->alias('g') + ->join('wechat_users w', 'w.id = g.userId') + ->where('g.id', (int) $id) + ->where('g.tenantId', $eid) + ->field('g.*,w.nickname,w.phone,w.avatar') + ->find(); + if (!$row) { + return error('记录不存在', 404); + } + $report = null; + if (!empty($row['latestReportId'])) { + $tr = Db::name('test_results') + ->where('id', (int) $row['latestReportId']) + ->where('testType', 'gaokao') + ->find(); + if ($tr) { + $report = $tr; + } + } + $orders = Db::name('orders') + ->where('userId', (int) $row['userId']) + ->where('productType', 'gaokao') + ->order('id', 'desc') + ->limit(20) + ->select() + ->toArray(); + return success([ + 'profile' => $row, + 'latestReport' => $report, + 'orders' => $orders, + ]); + } +} + diff --git a/api/app/controller/admin/Pricing.php b/api/app/controller/admin/Pricing.php index 18344cd..e7d00d5 100644 --- a/api/app/controller/admin/Pricing.php +++ b/api/app/controller/admin/Pricing.php @@ -104,7 +104,7 @@ class Pricing extends BaseController if (!is_array($personalConfig)) { return error('个人版定价格式错误', 400); } - foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) { + foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) { if (!array_key_exists($field, $personalConfig)) { return error("个人版定价缺少字段:{$field}", 400); } @@ -131,7 +131,7 @@ class Pricing extends BaseController if (!is_array($enterpriseConfig)) { return error('企业版定价格式错误', 400); } - foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) { + foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) { if (!array_key_exists($field, $enterpriseConfig)) { return error("企业版定价缺少字段:{$field}", 400); } diff --git a/api/app/controller/api/Distribution.php b/api/app/controller/api/Distribution.php index dd38725..ed96c55 100644 --- a/api/app/controller/api/Distribution.php +++ b/api/app/controller/api/Distribution.php @@ -154,6 +154,101 @@ class Distribution extends BaseController return success(['expireAt' => $expireAt], '绑定成功'); } + /** + * 高考入口等:与 POST /api/distribution/bind 一致地写入 distribution_bindings(不含邀请码解析) + */ + public static function applyInviteBindingFromGaokao(int $inviteeId, int $inviterId, ?int $enterpriseId): void + { + if ($inviteeId <= 0 || $inviterId <= 0 || $inviterId === $inviteeId) { + return; + } + + $scope = $enterpriseId ? 'enterprise' : 'personal'; + + if ($scope === 'enterprise') { + $inviter = Db::name('wechat_users') + ->where('id', $inviterId) + ->field('id, enterpriseId') + ->find(); + if (!$inviter || (int) $inviter['enterpriseId'] !== $enterpriseId) { + return; + } + } + + $now = time(); + + $reverseExists = Db::name('distribution_bindings') + ->where('inviterId', $inviteeId) + ->where('inviteeId', $inviterId) + ->where('scope', $scope) + ->where('status', 'active') + ->where('expireAt', '>', $now) + ->where(function ($query) use ($enterpriseId) { + if ($enterpriseId) { + $query->where('enterpriseId', $enterpriseId); + } else { + $query->whereNull('enterpriseId'); + } + }) + ->find(); + if ($reverseExists) { + return; + } + + $expireAt = $now + self::BINDING_TTL; + + $existing = Db::name('distribution_bindings') + ->where('inviteeId', $inviteeId) + ->where('scope', $scope) + ->where(function ($query) use ($enterpriseId) { + if ($enterpriseId) { + $query->where('enterpriseId', $enterpriseId); + } else { + $query->whereNull('enterpriseId'); + } + }) + ->find(); + + if (!$existing) { + Db::name('distribution_bindings')->insert([ + 'inviterId' => $inviterId, + 'inviteeId' => $inviteeId, + 'scope' => $scope, + 'enterpriseId' => $enterpriseId, + 'expireAt' => $expireAt, + 'status' => 'active', + 'prevInviterId' => null, + 'overriddenAt' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } elseif ((int) $existing['inviterId'] === $inviterId) { + Db::name('distribution_bindings') + ->where('id', $existing['id']) + ->update([ + 'expireAt' => $expireAt, + 'status' => 'active', + 'updatedAt' => $now, + ]); + } else { + $exExpire = (int) ($existing['expireAt'] ?? 0); + $exStatus = (string) ($existing['status'] ?? ''); + if ($exStatus === 'active' && $exExpire > $now) { + return; + } + Db::name('distribution_bindings') + ->where('id', $existing['id']) + ->update([ + 'prevInviterId' => (int) $existing['inviterId'], + 'inviterId' => $inviterId, + 'expireAt' => $expireAt, + 'status' => 'active', + 'overriddenAt' => $now, + 'updatedAt' => $now, + ]); + } + } + /** * GET /api/distribution/my-invite-code * 返回当前用户名下可用邀请码;若无记录则自动生成一条(便于前端直接展示) diff --git a/api/app/controller/api/Gaokao.php b/api/app/controller/api/Gaokao.php new file mode 100644 index 0000000..4d9e9ff --- /dev/null +++ b/api/app/controller/api/Gaokao.php @@ -0,0 +1,120 @@ +request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return 0; + } + return (int) ($user['user_id'] ?? $user['userId'] ?? 0); + } + + public function taskStatus() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $entry = [ + 'referrerId' => (int) Request::param('referrerId', 0), + 'channelCode' => (string) Request::param('channelCode', ''), + 'scene' => (string) Request::param('scene', 'entry'), + ]; + GaokaoService::markEntry($uid, $entry); + $pricingScope = trim((string) Request::param('pricingScope', 'personal')); + $eidParam = (int) Request::param('enterpriseId', 0); + + return success(GaokaoService::loadTaskStatusWithPricing( + $uid, + $pricingScope === 'enterprise' ? 'enterprise' : 'personal', + $eidParam > 0 ? $eidParam : null + )); + } + + public function saveForm() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $form = Request::post(); + if (!is_array($form)) { + $form = []; + } + GaokaoService::saveForm($uid, $form); + return success(GaokaoService::loadTaskStatus($uid), '保存成功'); + } + + public function myForm() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $profile = GaokaoService::getOrInitProfile($uid); + return success([ + 'form' => GaokaoService::formJsonAsArray($profile), + 'status' => (int) ($profile->formStatus ?? 0), + ]); + } + + public function analyze() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $pricingScope = trim((string) Request::param('pricingScope', 'personal')); + $eidParam = (int) Request::param('enterpriseId', 0); + $res = GaokaoService::createAnalysis( + $uid, + $pricingScope === 'enterprise' ? 'enterprise' : 'personal', + $eidParam > 0 ? $eidParam : null + ); + if (empty($res['ok'])) { + return error((string) ($res['message'] ?? '分析失败'), 400); + } + return success($res, '分析成功'); + } + + public function latestReport() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $row = GaokaoService::myLatestReport($uid); + if (!$row) { + return error('暂无报告', 404); + } + return success($row); + } + + public function pricing() + { + $uid = $this->wechatUserId(); + if ($uid <= 0) { + return error('未登录', 401); + } + $productCode = trim((string) Request::param('productCode', 'gaokao_single_report')); + $pricingScope = trim((string) Request::param('pricingScope', 'personal')); + $eidParam = (int) Request::param('enterpriseId', 0); + + return success(GaokaoService::resolvePricing( + $uid, + $productCode, + $pricingScope === 'enterprise' ? 'enterprise' : 'personal', + $eidParam > 0 ? $eidParam : null + )); + } +} diff --git a/api/app/controller/api/Payment.php b/api/app/controller/api/Payment.php index 710383d..7d1e13d 100644 --- a/api/app/controller/api/Payment.php +++ b/api/app/controller/api/Payment.php @@ -2,6 +2,8 @@ namespace app\controller\api; use app\BaseController; +use app\common\service\GaokaoService; +use app\common\service\TestProductPricing; use app\model\PricingConfig as PricingConfigModel; use app\model\UserProfile as UserProfileModel; use app\common\service\JwtService; @@ -77,6 +79,7 @@ class Payment extends BaseController 'disc' => 'disc', 'pdp' => 'pdp', 'resume' => 'resume', + 'gaokao' => 'gaokao', ]; if (isset($testTypeMap[$productType])) { $latestTest = Db::name('test_results') @@ -90,6 +93,17 @@ class Payment extends BaseController } } + // 高考:按当前请求 Tab 刷新未付记录的 paidAmount/enterpriseId,再读库定价(与小程序切换一致) + if ($productType === 'gaokao' && $testResultId > 0) { + $ps = trim((string) Request::param('pricingScope', 'personal')); + GaokaoService::refreshGaokaoTestResultForPayment( + $userId, + $testResultId, + $ps === 'enterprise' ? 'enterprise' : 'personal', + $enterpriseIdParam > 0 ? $enterpriseIdParam : null + ); + } + if ($testResultId > 0) { $tr = Db::name('test_results') ->where('id', $testResultId) @@ -138,7 +152,7 @@ class Payment extends BaseController ); } - if ($amountFenCalculated <= 0) { + if ($amountFenCalculated < 0 || ($amountFenCalculated === 0 && $productType !== 'gaokao')) { return error('订单金额无效,请检查定价配置或请求参数', 400); } @@ -201,6 +215,7 @@ class Payment extends BaseController 'disc' => 'disc', 'pdp' => 'pdp', 'resume' => 'resume', + 'gaokao' => 'gaokao', ]; if (isset($testTypeMap[$productType])) { $testType = $testTypeMap[$productType]; @@ -221,6 +236,35 @@ class Payment extends BaseController } } + if ($productType === 'gaokao') { + $this->ensureGaokaoTestResultForOrder($userId, $orderIdDb, $enterpriseId, $now); + } + + // 高考志愿 0 元:不调微信统一下单,直接标记已付(避免「金额无效」又卡在任务中心) + if ($productType === 'gaokao' && $amountFenCalculated === 0) { + $orderRow = Db::name('orders')->where('id', $orderIdDb)->find(); + if ($orderRow) { + $this->completeGaokaoZeroAmountOrder((int) $orderIdDb, $orderRow, $now); + } + + return success([ + 'skipWxPay' => true, + 'orderId' => $orderId, + 'orderDbId' => $orderIdDb, + 'amount' => 0, + 'productType' => $productType, + 'pricingType' => $pricingType, + 'description' => $description, + 'enterpriseId' => $enterpriseId, + 'timeStamp' => (string) time(), + 'nonceStr' => '', + 'package' => '', + 'signType' => 'MD5', + 'paySign' => '', + 'prepayId' => '', + ], '订单已自动完成(0元)'); + } + // 真实对接微信统一下单,生成 prepay_id 等参数 $wechatConfig = [ 'appid' => env('WECHAT_APPID', ''), // 小程序 AppID @@ -617,6 +661,102 @@ class Payment extends BaseController return (int) $row['enterpriseId']; } + /** + * 高考 0 元单:标记订单与关联 test_results 已付(不调微信) + * + * @param array $orderRow + */ + protected function completeGaokaoZeroAmountOrder(int $orderIdDb, array $orderRow, int $now): void + { + Db::name('orders') + ->where('id', $orderIdDb) + ->update([ + 'status' => 'paid', + 'payTime' => $now, + 'updatedAt' => $now, + ]); + + Db::name('test_results') + ->where('orderId', $orderIdDb) + ->update([ + 'isPaid' => 1, + 'paidAmount' => 0, + 'paidAt' => $now, + 'updatedAt' => $now, + 'requiresPayment' => 0, + ]); + + $order = array_merge($orderRow, ['status' => 'paid', 'payTime' => $now]); + try { + FeishuLeadWebhookService::onOrderPaid($orderIdDb, (int) ($order['userId'] ?? 0)); + } catch (\Throwable $e) { + } + try { + $this->creditEnterpriseBalanceForOrder($order, 0, $now); + } catch (\Throwable $e) { + } + try { + \app\controller\api\Distribution::settleCommission($orderIdDb); + } catch (\Exception $e) { + } + } + + /** + * 高考订单:保证存在 testType=gaokao 且 orderId 已绑定的占位行,供支付回调与分销按 orderId 解析 testType + */ + protected function ensureGaokaoTestResultForOrder(int $userId, int $orderIdDb, ?int $enterpriseId, int $now): void + { + if ($userId <= 0 || $orderIdDb <= 0) { + return; + } + $exists = Db::name('test_results') + ->where('orderId', $orderIdDb) + ->where('testType', 'gaokao') + ->find(); + if ($exists) { + return; + } + $bind = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', 'gaokao') + ->where('isPaid', 0) + ->whereRaw('(orderId IS NULL OR orderId = 0)') + ->order('id', 'desc') + ->find(); + if ($bind) { + Db::name('test_results') + ->where('id', (int) $bind['id']) + ->where('userId', $userId) + ->update([ + 'orderId' => $orderIdDb, + 'updatedAt' => $now, + ]); + + return; + } + $scope = $enterpriseId ? 'enterprise' : 'personal'; + $placeholder = [ + 'kind' => 'gaokao', + 'state' => 'awaiting_report', + 'version' => 'v1', + ]; + Db::name('test_results')->insert([ + 'userId' => $userId, + 'testType' => 'gaokao', + 'resultData' => json_encode($placeholder, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), + 'score' => null, + 'orderId' => $orderIdDb, + 'requiresPayment' => 1, + 'isPaid' => 0, + 'paidAmount' => null, + 'paidAt' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + 'enterpriseId' => $enterpriseId, + 'testScope' => $scope, + ]); + } + /** * 企业四项测试支付成功后,将订单金额计入企业余额。 * 使用 finance_records + orderId 做幂等,避免 notify/query 重复入账。 @@ -725,20 +865,30 @@ class Payment extends BaseController $quantity = $quantity > 0 ? $quantity : 1; // 1)测试类产品:定价配置中为元,转为分(企业用户按企业ID取价) - $testProductTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis']; - if (in_array($productType, $testProductTypes, true)) { - $pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId ?? $enterpriseId); - $config = []; - if ($pricingConfig && !empty($pricingConfig->config)) { - $raw = $pricingConfig->config; - $config = is_array($raw) ? $raw : (array) $raw; + if (in_array($productType, TestProductPricing::TEST_PRODUCT_TYPES, true)) { + $userId = 0; + if ($user && ($user['source'] ?? '') === 'wechat') { + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + } + // 高考:与 GaokaoService 一致,按子测评是否带 enterpriseId 区分个人档/企业档售价,避免仅因订单带 eid 误用企业版价 + if ($productType === 'gaokao' && $userId > 0) { + $ps = trim((string) Request::param('pricingScope', 'personal')); + $pe = (int) Request::param('enterpriseId', 0); + + return GaokaoService::gaokaoSaleAmountForPaymentRecalc( + $userId, + $ps === 'enterprise' ? 'enterprise' : 'personal', + $pe > 0 ? $pe : null + ); } - $keyMap = ['team_analysis' => 'teamAnalysis']; - $key = $keyMap[$productType] ?? $productType; - $unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0; - $amountFen = (int) round($unitPriceYuan * 100 * $quantity); - return [$amountFen, $pricingType]; + return TestProductPricing::amountFenForTestProduct( + $productType, + $userId, + $pricingEnterpriseId ?? $enterpriseId, + $quantity, + $pricingType + ); } // 2)深度服务:定价配置为元,转为分 diff --git a/api/app/controller/api/Test.php b/api/app/controller/api/Test.php index ee6c481..6fd7635 100644 --- a/api/app/controller/api/Test.php +++ b/api/app/controller/api/Test.php @@ -4,6 +4,7 @@ namespace app\controller\api; use app\BaseController; use app\common\PdpDiscResultText; use app\common\service\EnterpriseBillingService; +use app\common\service\GaokaoService; use app\model\Enterprise as EnterpriseModel; use app\model\PricingConfig as PricingConfigModel; use app\model\Question as QuestionModel; @@ -250,6 +251,28 @@ class Test extends BaseController 'data' => null, ], $paymentFields); break; + case 'gaokao': + $gOverview = ''; + if (is_array($data)) { + $gOverview = (string) ($data['overview'] ?? ''); + if ($gOverview === '' && isset($data['report']['overview'])) { + $gOverview = (string) $data['report']['overview']; + } + } + $hasAnalysis = $gOverview !== '' + || (is_array($data) && isset($data['report']) && is_array($data['report']) && ($data['report'] ?? []) !== []); + $list[] = array_merge([ + 'id' => $id, + 'testType' => 'gaokao', + 'type' => 'gaokao', + 'key' => 'gaokao_' . $id, + 'emoji' => '🎓', + 'typeName' => '高考志愿', + 'resultText' => $hasAnalysis ? '已生成' : '志愿报告', + 'testTime' => $timeLabel, + 'data' => null, + ], $paymentFields); + break; default: break; } @@ -285,7 +308,7 @@ class Test extends BaseController $allowedAll = $this->wechatAllowedTestTypes($userId); // 「我的」卡片不含简历,但 totalCount 与列表需与 history 权限一致 - $allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'sbti', 'pdp', 'disc', 'face', 'ai'])); + $allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'sbti', 'pdp', 'disc', 'face', 'ai', 'gaokao'])); if ($allowedForRecent === []) { return success([ 'records' => new \stdClass(), @@ -315,13 +338,13 @@ class Test extends BaseController // face 和 ai 视为同一种类型 $effectiveType = in_array($type, ['face', 'ai']) ? 'ai' : $type; - if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'sbti', 'disc', 'pdp', 'ai'])) { + if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'sbti', 'disc', 'pdp', 'ai', 'gaokao'])) { $records[$effectiveType] = $this->_formatRecentRow($row); $foundTypes[$effectiveType] = true; } - // 如果四个类型都找到了,且不需要总数(或者已经有了),可以提前结束 - if (count($foundTypes) >= 4) { + // 如果主要类型都找到了,且不需要总数(或者已经有了),可以提前结束 + if (count($foundTypes) >= 5) { // 如果不需要精确的总数统计,这里可以 break // 但为了保持接口兼容性,我们继续循环或者已经拿到了 count } @@ -376,6 +399,9 @@ class Test extends BaseController if ($boundEnterpriseId > 0) { $allowed[] = 'resume'; } + if (!in_array('gaokao', $allowed, true)) { + $allowed[] = 'gaokao'; + } return array_values(array_unique($allowed)); } @@ -489,6 +515,17 @@ class Test extends BaseController $emoji = '👁️'; $typeName = '面相分析'; break; + case 'gaokao': + $ov = (string) ($data['overview'] ?? ''); + if ($ov === '' && isset($data['report']['overview'])) { + $ov = (string) $data['report']['overview']; + } + $hasReport = $ov !== '' + || (isset($data['report']) && is_array($data['report']) && ($data['report'] ?? []) !== []); + $resultText = $hasReport ? '已生成' : '志愿报告'; + $emoji = '🎓'; + $typeName = '高考志愿'; + break; } if (in_array($testType, ['face', 'ai'], true)) { @@ -579,6 +616,24 @@ class Test extends BaseController return error('记录不存在', 404); } + if (($row['testType'] ?? '') === 'gaokao' && (int) ($row['isPaid'] ?? 0) === 0) { + $ps = trim((string) Request::param('pricingScope', 'personal')); + $eid = (int) Request::param('enterpriseId', 0); + GaokaoService::refreshGaokaoTestResultForPayment( + $userId, + $id, + $ps === 'enterprise' ? 'enterprise' : 'personal', + $eid > 0 ? $eid : null + ); + $row = Db::name('test_results') + ->where('id', $id) + ->where('userId', $userId) + ->find(); + if (!$row) { + return error('记录不存在', 404); + } + } + $out = $this->buildTestDetailPayload($row); return success(array_merge($out, [ @@ -610,6 +665,9 @@ class Test extends BaseController if ($rowTestType === 'resume') { return error('该类型不支持分享查看', 403); } + if ($rowTestType === 'gaokao' && $st === '') { + return error('该类型不支持公开分享', 403); + } if ($st !== '') { if (!$this->verifyShareToken($row, $st)) { @@ -982,6 +1040,19 @@ class Test extends BaseController '_structured' => false, ]; } + if ($testType === 'gaokao') { + $ov = (string) ($data['overview'] ?? ''); + if ($ov === '' && isset($data['report']['overview'])) { + $ov = (string) $data['report']['overview']; + } + $inputSnap = is_array($data['inputSnapshot'] ?? null) ? $data['inputSnapshot'] : []; + + return [ + 'overview' => $ov, + 'inputSnapshot' => $inputSnap, + 'locked' => true, + ]; + } return $data; } diff --git a/api/app/controller/superadmin/AppUser.php b/api/app/controller/superadmin/AppUser.php index 7da3c4f..515559b 100644 --- a/api/app/controller/superadmin/AppUser.php +++ b/api/app/controller/superadmin/AppUser.php @@ -377,6 +377,8 @@ class AppUser extends BaseController $testTypes = []; $payStats = []; $enterpriseNames = []; + $gaokaoMap = []; + $gaokaoReportMap = []; if (!empty($ids)) { $trBase = Db::name('test_results')->where('userId', 'in', $ids); $trAggRows = (clone $trBase) @@ -466,6 +468,45 @@ class AppUser extends BaseController } catch (\Throwable $e) { $payStats = []; } + + try { + $gqRows = Db::name('gaokao_user_profile') + ->whereIn('userId', $ids) + ->field('id,userId,tenantId,entryStatus,mbtiStatus,pdpStatus,discStatus,formStatus,analyzeStatus,lastAnalyzeAt,latestReportId') + ->select() + ->toArray(); + $reportIds = []; + foreach ($gqRows as $gr) { + $uid = (int) ($gr['userId'] ?? 0); + if ($uid > 0) { + $gaokaoMap[$uid] = $gr; + if (!empty($gr['latestReportId'])) { + $reportIds[] = (int) $gr['latestReportId']; + } + } + } + $reportIds = array_values(array_unique(array_filter($reportIds))); + if ($reportIds) { + $rRows = Db::name('test_results') + ->whereIn('id', $reportIds) + ->where('testType', 'gaokao') + ->field('id,resultData') + ->select() + ->toArray(); + foreach ($rRows as $rr) { + $raw = $rr['resultData'] ?? ''; + $rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []); + $ov = (string) ($rd['overview'] ?? ''); + if ($ov === '' && isset($rd['report']['overview'])) { + $ov = (string) $rd['report']['overview']; + } + $gaokaoReportMap[(int) $rr['id']] = $ov; + } + } + } catch (\Throwable $e) { + $gaokaoMap = []; + $gaokaoReportMap = []; + } } foreach ($list as &$row) { @@ -493,6 +534,15 @@ class AppUser extends BaseController $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0; $row['totalPaidAmount'] = $totalPaidFen; $row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0; + + $gq = $gaokaoMap[$id] ?? null; + $row['gaokaoEntryStatus'] = $gq ? (int) ($gq['entryStatus'] ?? 0) : 0; + $row['gaokaoAnalyzeStatus'] = $gq ? (int) ($gq['analyzeStatus'] ?? 0) : 0; + $row['gaokaoFormStatus'] = $gq ? (int) ($gq['formStatus'] ?? 0) : 0; + $row['gaokaoTenantId'] = $gq ? (int) ($gq['tenantId'] ?? 0) : 0; + $row['gaokaoLastAnalyzeAt'] = $gq ? (int) ($gq['lastAnalyzeAt'] ?? 0) : 0; + $rid = $gq ? (int) ($gq['latestReportId'] ?? 0) : 0; + $row['gaokaoOverview'] = $rid > 0 ? (string) ($gaokaoReportMap[$rid] ?? '') : ''; } return paginate_response($list, $total, $page, $pageSize); @@ -564,6 +614,33 @@ class AppUser extends BaseController $data['resumeUploads'] = ResumeUploadsAdminService::listForWechatUser((int) $id, null); + try { + $gq = Db::name('gaokao_user_profile')->where('userId', (int) $id)->find(); + if ($gq) { + $data['gaokaoProfile'] = $gq; + $rid = (int) ($gq['latestReportId'] ?? 0); + if ($rid > 0) { + $data['gaokaoLatestReport'] = Db::name('test_results') + ->where('id', $rid) + ->where('testType', 'gaokao') + ->find(); + } + $data['gaokaoOrders'] = Db::name('orders') + ->where('userId', (int) $id) + ->where('productType', 'gaokao') + ->order('id', 'desc') + ->limit(20) + ->select() + ->toArray(); + } else { + $data['gaokaoProfile'] = null; + $data['gaokaoOrders'] = []; + } + } catch (\Throwable $e) { + $data['gaokaoProfile'] = null; + $data['gaokaoOrders'] = []; + } + return success($data); } diff --git a/api/app/controller/superadmin/Distribution.php b/api/app/controller/superadmin/Distribution.php index 40e9146..080b15e 100644 --- a/api/app/controller/superadmin/Distribution.php +++ b/api/app/controller/superadmin/Distribution.php @@ -399,7 +399,7 @@ class Distribution extends BaseController private static function defaultTestSettings(): array { $item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false]; - return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item]; + return ['face' => $item, 'mbti' => $item, 'sbti' => $item, 'disc' => $item, 'pdp' => $item, 'gaokao' => $item]; } private static function sanitizeTestSettings($raw): array diff --git a/api/app/controller/superadmin/GaokaoUser.php b/api/app/controller/superadmin/GaokaoUser.php new file mode 100644 index 0000000..d375c86 --- /dev/null +++ b/api/app/controller/superadmin/GaokaoUser.php @@ -0,0 +1,105 @@ +request->user ?? null; + return !!($user && ($user['role'] ?? '') === 'superadmin'); + } + + public function index() + { + if (!$this->authOk()) { + return error('无权限访问', 403); + } + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, max(1, (int) Request::param('pageSize', 20))); + $keyword = trim((string) Request::param('keyword', '')); + $tenantId = (int) Request::param('tenantId', 0); + + $q = Db::name('gaokao_user_profile')->alias('g') + ->join('wechat_users w', 'w.id = g.userId') + ->leftJoin('enterprises e', 'e.id = g.tenantId'); + + if ($keyword !== '') { + $q->whereRaw('(w.nickname LIKE ? OR w.phone LIKE ? OR g.name LIKE ?)', ['%' . $keyword . '%', '%' . $keyword . '%', '%' . $keyword . '%']); + } + if ($tenantId > 0) { + $q->where('g.tenantId', $tenantId); + } + $total = (int) (clone $q)->count(); + $rows = (clone $q) + ->field('g.*,w.nickname,w.phone,w.avatar,e.name as tenantName') + ->order('g.id', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + return paginate_response($rows, $total, $page, $pageSize); + } + + public function detail($id) + { + if (!$this->authOk()) { + return error('无权限访问', 403); + } + $row = Db::name('gaokao_user_profile')->alias('g') + ->join('wechat_users w', 'w.id = g.userId') + ->leftJoin('enterprises e', 'e.id = g.tenantId') + ->where('g.id', (int) $id) + ->field('g.*,w.nickname,w.phone,w.avatar,e.name as tenantName') + ->find(); + if (!$row) { + return error('记录不存在', 404); + } + $uid = (int) $row['userId']; + $reports = Db::name('test_results') + ->where('userId', $uid) + ->where('testType', 'gaokao') + ->order('id', 'desc') + ->limit(20) + ->select() + ->toArray(); + $orders = Db::name('orders') + ->where('userId', $uid) + ->where('productType', 'gaokao') + ->order('id', 'desc') + ->limit(20) + ->select() + ->toArray(); + $orderIds = array_map(static function ($o) { + return (int) ($o['id'] ?? 0); + }, $orders); + $orderIds = array_values(array_filter($orderIds)); + $commissions = []; + if ($orderIds !== []) { + $commissions = Db::name('commission_records') + ->whereIn('orderId', $orderIds) + ->order('id', 'desc') + ->select() + ->toArray(); + } + $binding = Db::name('distribution_bindings') + ->where('inviteeId', $uid) + ->where('status', 'active') + ->where('expireAt', '>', time()) + ->order('id', 'desc') + ->find(); + return success([ + 'profile' => $row, + 'reports' => $reports, + 'orders' => $orders, + 'commissions' => $commissions, + 'distributionBinding' => $binding, + ]); + } +} + diff --git a/api/app/controller/superadmin/Pricing.php b/api/app/controller/superadmin/Pricing.php index 4e5191b..d0edd37 100644 --- a/api/app/controller/superadmin/Pricing.php +++ b/api/app/controller/superadmin/Pricing.php @@ -42,10 +42,19 @@ class Pricing extends BaseController if (!$config) { return error('定价配置不存在', 404); } + $cfg = $config->config; + if (is_array($cfg)) { + if ($type === 'personal') { + $cfg = self::normalizePersonalPricingConfig($cfg); + } elseif ($type === 'enterprise') { + $cfg = self::normalizeEnterprisePricingConfig($cfg); + } + } + return success([ 'type' => $config->type, 'enterpriseId' => $config->enterpriseId, - 'config' => $config->config + 'config' => $cfg ]); } else { // 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表 @@ -60,6 +69,18 @@ class Pricing extends BaseController } } } + if (is_array($result['personal'])) { + $result['personal'] = self::normalizePersonalPricingConfig($result['personal']); + } + if (is_array($result['enterprise'])) { + $result['enterprise'] = self::normalizeEnterprisePricingConfig($result['enterprise']); + } + foreach ($result['enterpriseList'] as $i => $entRow) { + if (isset($entRow['config']) && is_array($entRow['config'])) { + $result['enterpriseList'][$i]['config'] = self::normalizeEnterprisePricingConfig($entRow['config']); + } + } + return success($result); } } @@ -196,5 +217,44 @@ class Pricing extends BaseController return success(null, "成功保存 {$successCount} 个配置"); } + + /** + * 旧库 JSON 可能缺少高考等字段,合并默认值便于管理端展示与保存 + * + * @param array $cfg + * @return array + */ + private static function normalizePersonalPricingConfig(array $cfg): array + { + $defaults = [ + 'face' => 0, + 'mbti' => 0, + 'disc' => 0, + 'pdp' => 0, + 'sbti' => 0, + 'gaokao' => 0, + ]; + + return array_merge($defaults, $cfg); + } + + /** + * @param array $cfg + * @return array + */ + private static function normalizeEnterprisePricingConfig(array $cfg): array + { + $defaults = [ + 'face' => 0, + 'mbti' => 0, + 'pdp' => 0, + 'disc' => 0, + 'sbti' => 0, + 'gaokao' => 0, + 'minRecharge' => 0, + ]; + + return array_merge($defaults, $cfg); + } } diff --git a/api/app/model/Enterprise.php b/api/app/model/Enterprise.php index 829dd15..1903f9b 100644 --- a/api/app/model/Enterprise.php +++ b/api/app/model/Enterprise.php @@ -61,6 +61,8 @@ class Enterprise extends Model 'sbti' => true, 'pdp' => true, 'disc' => true, + /** 高考志愿任务中心 / 分析报告 */ + 'gaokao' => true, /** 详细测试列表中的「AI 性格测试」聚合入口 */ 'aiHub' => true, 'distribution' => true, diff --git a/api/app/model/GaokaoDistributionAttribution.php b/api/app/model/GaokaoDistributionAttribution.php new file mode 100644 index 0000000..35f7e69 --- /dev/null +++ b/api/app/model/GaokaoDistributionAttribution.php @@ -0,0 +1,31 @@ + 'int', + 'userId' => 'int', + 'tenantId' => 'int', + 'referrerUserId' => 'int', + 'channelCode' => 'string', + 'scene' => 'string', + 'attributedAt' => 'int', + 'expireAt' => 'int', + 'isLocked' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; +} + diff --git a/api/app/model/GaokaoDistributionCommission.php b/api/app/model/GaokaoDistributionCommission.php new file mode 100644 index 0000000..bc63722 --- /dev/null +++ b/api/app/model/GaokaoDistributionCommission.php @@ -0,0 +1,35 @@ + 'int', + 'tenantId' => 'int', + 'orderId' => 'int', + 'orderNo' => 'string', + 'userId' => 'int', + 'referrerUserId' => 'int', + 'commissionRuleType' => 'string', + 'commissionRuleValue' => 'float', + 'commissionAmount' => 'int', + 'status' => 'int', + 'settledAt' => 'int', + 'reversedAt' => 'int', + 'remark' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; +} + diff --git a/api/app/model/GaokaoOrder.php b/api/app/model/GaokaoOrder.php new file mode 100644 index 0000000..eeccb72 --- /dev/null +++ b/api/app/model/GaokaoOrder.php @@ -0,0 +1,38 @@ + 'int', + 'orderNo' => 'string', + 'userId' => 'int', + 'tenantId' => 'int', + 'productCode' => 'string', + 'pricingId' => 'int', + 'amountOriginal' => 'int', + 'amountPayable' => 'int', + 'amountPaid' => 'int', + 'currency' => 'string', + 'payStatus' => 'int', + 'payChannel' => 'string', + 'paidAt' => 'int', + 'refundAt' => 'int', + 'extJson' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + protected $json = ['extJson']; +} + diff --git a/api/app/model/GaokaoPricing.php b/api/app/model/GaokaoPricing.php new file mode 100644 index 0000000..56d3dd1 --- /dev/null +++ b/api/app/model/GaokaoPricing.php @@ -0,0 +1,60 @@ + 'int', + 'tenantId' => 'int', + 'productCode' => 'string', + 'productName' => 'string', + 'priceOriginal' => 'int', + 'priceSale' => 'int', + 'priceChannel' => 'int', + 'currency' => 'string', + 'status' => 'int', + 'effectiveAt' => 'int', + 'expiredAt' => 'int', + 'extraJson' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + protected $json = ['extraJson']; + + /** + * 命中某租户、商品在当前时间生效的定价(租户优先,平台兜底) + */ + public static function resolveByTenantAndProduct(int $tenantId, string $productCode): ?self + { + $now = time(); + $base = self::where('productCode', $productCode) + ->where('status', 1) + ->where('effectiveAt', '<=', $now) + ->where(function ($q) use ($now) { + $q->whereNull('expiredAt')->whereOr('expiredAt', 0)->whereOr('expiredAt', '>', $now); + }) + ->order('id', 'desc'); + + if ($tenantId > 0) { + $row = (clone $base)->where('tenantId', $tenantId)->find(); + if ($row) { + return $row; + } + } + + return (clone $base)->where('tenantId', 0)->find(); + } +} + diff --git a/api/app/model/GaokaoReport.php b/api/app/model/GaokaoReport.php new file mode 100644 index 0000000..a7a15c0 --- /dev/null +++ b/api/app/model/GaokaoReport.php @@ -0,0 +1,30 @@ + 'int', + 'userId' => 'int', + 'tenantId' => 'int', + 'version' => 'string', + 'inputSnapshotJson' => 'string', + 'reportJson' => 'string', + 'overview' => 'string', + 'searchMetaJson' => 'string', + 'status' => 'int', + 'errorMsg' => 'string', + 'createdAt' => 'int', + ]; + + protected $autoWriteTimestamp = false; + protected $json = ['inputSnapshotJson', 'reportJson', 'searchMetaJson']; +} + diff --git a/api/app/model/GaokaoUserProfile.php b/api/app/model/GaokaoUserProfile.php new file mode 100644 index 0000000..84953cd --- /dev/null +++ b/api/app/model/GaokaoUserProfile.php @@ -0,0 +1,40 @@ + 'int', + 'userId' => 'int', + 'tenantId' => 'int', + 'entryStatus' => 'int', + 'mbtiStatus' => 'int', + 'pdpStatus' => 'int', + 'discStatus' => 'int', + 'formStatus' => 'int', + 'analyzeStatus' => 'int', + 'lastAnalyzeAt' => 'int', + 'latestReportId' => 'int', + 'name' => 'string', + 'province' => 'string', + 'streamSubjects' => 'string', + 'estimatedScore' => 'int', + 'formJson' => 'string', + 'tagsJson' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + protected $json = ['formJson', 'tagsJson']; +} + diff --git a/api/database/migrations/add_gaokao_core_tables.sql b/api/database/migrations/add_gaokao_core_tables.sql new file mode 100644 index 0000000..90a2a38 --- /dev/null +++ b/api/database/migrations/add_gaokao_core_tables.sql @@ -0,0 +1,30 @@ +-- 高考志愿:用户任务与表单档案(定价/订单/报告已统一至 PricingConfig + orders + test_results.testType=gaokao) +-- 执行前请确认表前缀,以下以 mbti_ 为例 + +CREATE TABLE IF NOT EXISTS `mbti_gaokao_user_profile` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `userId` BIGINT NOT NULL COMMENT '用户 ID', + `tenantId` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID', + `entryStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '入口/任务流:0未进入 1进行中 2已完成', + `mbtiStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'MBTI 完成状态,0 未完成 1 已完成等', + `pdpStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'PDP 完成状态', + `discStatus` TINYINT NOT NULL DEFAULT 0 COMMENT 'DISC 完成状态', + `formStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '志愿表单,0 未填/未保存 1 已保存等', + `analyzeStatus` TINYINT NOT NULL DEFAULT 0 COMMENT '综合分析:0未生成 1已生成 2失败', + `lastAnalyzeAt` INT NULL DEFAULT NULL COMMENT '最近一次分析时间,Unix 时间戳', + `latestReportId` BIGINT NULL DEFAULT NULL COMMENT '最近一份高考报告对应 mbti_test_results.id(testType=gaokao)', + `name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '学生姓名,冗余自表单', + `province` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生源省份,冗余自表单', + `streamSubjects` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '科类/选科,冗余自表单', + `estimatedScore` INT NULL DEFAULT NULL COMMENT '估分', + `formJson` JSON NULL COMMENT '志愿表单全量 JSON', + `tagsJson` JSON NULL COMMENT '业务标签等 JSON', + `createdAt` INT NOT NULL DEFAULT 0 COMMENT '创建时间,Unix 时间戳', + `updatedAt` INT NOT NULL DEFAULT 0 COMMENT '更新时间,Unix 时间戳', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user` (`userId`), + KEY `idx_tenant_status` (`tenantId`, `entryStatus`, `analyzeStatus`), + KEY `idx_last_analyze_at` (`lastAnalyzeAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高考-用户业务档案'; + +-- 已部署旧版迁移、仍存在独立高考表时,请线下执行 DROP 或见 migrate_gaokao_legacy_to_unified.sql / rollback_gaokao_core_tables.sql 说明 diff --git a/api/database/migrations/migrate_gaokao_legacy_to_unified.sql b/api/database/migrations/migrate_gaokao_legacy_to_unified.sql new file mode 100644 index 0000000..b0822c3 --- /dev/null +++ b/api/database/migrations/migrate_gaokao_legacy_to_unified.sql @@ -0,0 +1,3 @@ +-- 可选:将旧版 mbti_gaokao_report 迁入 mbti_test_results(testType=gaokao) +-- 执行前请备份数据库;按实际 MySQL 版本与 JSON 函数编写 INSERT…SELECT。 +-- 迁移完成后:用新 test_results.id 回写 mbti_gaokao_user_profile.latestReportId,再择机 DROP 旧高考业务表。 diff --git a/api/database/migrations/rollback_gaokao_core_tables.sql b/api/database/migrations/rollback_gaokao_core_tables.sql new file mode 100644 index 0000000..77d3f67 --- /dev/null +++ b/api/database/migrations/rollback_gaokao_core_tables.sql @@ -0,0 +1,4 @@ +-- 回滚:仅删除高考用户档案表(新版迁移仅创建此表) +-- 若库中仍有旧版 mbti_gaokao_report / mbti_gaokao_order 等表,请按需手动 DROP + +DROP TABLE IF EXISTS `mbti_gaokao_user_profile`; diff --git a/api/route/api.php b/api/route/api.php index 063a1d2..d5d3df2 100644 --- a/api/route/api.php +++ b/api/route/api.php @@ -116,6 +116,13 @@ Route::group('api', function () { Route::get('ai/report/:id', 'api.AiReport/show')->pattern(['id' => '\d+']); Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev'); Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate'); + // 高考志愿(小程序登录后) + Route::get('gaokao/task-status', 'api.Gaokao/taskStatus'); + Route::get('gaokao/form', 'api.Gaokao/myForm'); + Route::post('gaokao/form', 'api.Gaokao/saveForm'); + Route::post('gaokao/analyze', 'api.Gaokao/analyze'); + Route::get('gaokao/report/my-latest', 'api.Gaokao/latestReport'); + Route::get('gaokao/pricing', 'api.Gaokao/pricing'); })->middleware(['cors', 'auth']); // ==================== 兼容 /api/v1 前缀(与上方 api 组同权、同中间件)==================== @@ -166,6 +173,8 @@ Route::group('api/v1/admin', function () { Route::get('test-records/:id', 'admin.AppUser/testRecord'); Route::get('app-users/:id', 'admin.AppUser/detail'); Route::get('app-users', 'admin.AppUser/index'); + Route::get('gaokao-users/:id', 'admin.GaokaoUser/detail'); + Route::get('gaokao-users', 'admin.GaokaoUser/index'); // 订单列表(含用户与关联测试数据) Route::get('orders', 'admin.Order/index'); // 用户管理(普通管理员和企业管理员,后台账号) @@ -326,6 +335,8 @@ Route::group('api/v1/superadmin', function () { Route::get('test-records/:id', 'superadmin.AppUser/testRecord'); Route::get('app-users/:id', 'superadmin.AppUser/detail'); Route::get('app-users', 'superadmin.AppUser/index'); + Route::get('gaokao-users/:id', 'superadmin.GaokaoUser/detail'); + Route::get('gaokao-users', 'superadmin.GaokaoUser/index'); // 数据概览(超管专用,子路径放前面避免被 overview 吞掉) Route::get('overview/recent-dynamics', 'superadmin.Overview/recentDynamics'); diff --git a/miniprogram/app.js b/miniprogram/app.js index 77e1cb4..67c3e23 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -58,8 +58,8 @@ App({ defaultEnterpriseId: null, // API 基础地址:默认走线上;本机/内网调试可在开发者工具执行 // wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序 - apiBase: 'https://mbtiapi.quwanzhi.com', - //apiBase: 'http://mbti.com', + //apiBase: 'https://mbtiapi.quwanzhi.com', + apiBase: 'http://mbti.com', // VIP信息 vipInfo: null, // 测试次数 diff --git a/miniprogram/app.json b/miniprogram/app.json index 6432f2c..538dad8 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -28,6 +28,9 @@ "pages/promo/poster", "pages/promo/withdrawals", "pages/match-job/index", + "pages/gaokao/index", + "pages/gaokao/form", + "pages/gaokao/report", "pages/ai-chat/index", "pages/ai-chat/report", "pages/ai-chat/history", diff --git a/miniprogram/pages/gaokao/form.js b/miniprogram/pages/gaokao/form.js new file mode 100644 index 0000000..37a9fed --- /dev/null +++ b/miniprogram/pages/gaokao/form.js @@ -0,0 +1,387 @@ +const gaokaoApi = require('../../utils/gaokao') + +/** 科类/选科备选项:首项为占位,不可作为有效保存值 */ +const SUBJECT_PLACEHOLDER = '请选择科类/选科' +const SUBJECT_CHOICES = [ + SUBJECT_PLACEHOLDER, + '文科', + '理科', + '物化生', + '物化地', + '物化政', + '物生地', + '物生政', + '物政地', + '化生地', + '化政地', + '生政地', + '史政地', + '史化政', + '史化生', + '物化技', + '物生技', + '史地技', + '艺术类(物理向)', + '艺术类(历史向)', + '体育类(物理向)', + '体育类(历史向)', + '中职/对口/单招' +] + +/** 在意向专业中展示/保存的选项:首项表示不填 */ +const MAJOR_PLACEHOLDER = '(可选)不填' +const MAJOR_CHOICES = [ + MAJOR_PLACEHOLDER, + '哲学', + '经济学 / 金融', + '法学', + '教育学 / 师范', + '文学', + '外语 / 新传', + '理学', + '工学 / 工程', + '计算机 / 软件 / 人工智能', + '电子 / 通信 / 信息', + '医学 / 临床 / 公卫 / 中医', + '农学 / 林学 / 生科', + '历史学', + '管理学 / 商学', + '艺术学', + '交叉学科 / 暂未确定' +] + +/** 将微信 region 结果格式化为只到「市」的文案(不含区) */ +function regionToCityText(v) { + if (!v || !v.length) return '' + const p = (v[0] || '').trim() + const c = (v[1] || '').trim() + if (p && c) return p + ' ' + c + return p || c +} + +/** 展示用:只取省+市,直辖市 p===c 时只显示一个 */ +function formatRegionLine(v) { + if (!v || !v.length) return '' + const p = (v[0] || '').trim() + const c = (v[1] || '').trim() + if (!p && !c) return '' + if (c && c !== p) return p + ' · ' + c + return p || c +} + +/** 从 bindchange 取 value(部分环境 detail 结构异常时兜底) */ +function regionValueFromEvent(e) { + const d = (e && e.detail) || {} + let v = d.value + if (Array.isArray(v) && v.length) return v + return [] +} + +Page({ + data: { + form: { + name: '', + province: '', + /** 高考生源地所在市(与省同一套 region 选择结果,用于展示/扩展) */ + city: '', + streamOrSubjects: '', + /** 与微信 region picker 联动;level=city 时为 [省,市];老数据或省级可能为 3 项 */ + region: [], + /** + * 意向地区:仅省为 [p];老数据 可能 为 [p,c];与 preferredRegions 对应 + */ + preferredRegion: [], + estimatedScore: '', + scoreText: '', + wishListText: '', + preferredRegions: '', + preferredFields: '' + }, + /** 高考生源:与 level=city 一致,为 [省,市] */ + regionPickerValue: [], + /** 意向:只省 */ + intendedProvPicker: [], + /** 意向:省+市 */ + intendedCityPicker: [], + /** 0=只到省份 1=到省+市 */ + intendedModeOptions: ['只到省份', '到省+市'], + intendedModeIndex: 1, + intendedModeLine: '到省+市', + /** 地区展示文案(不依赖 wxml 里对 length 的比较,避免真机/模拟器不渲染) */ + regionLine: '', + intendedRegionLine: '', + subjectOptions: SUBJECT_CHOICES, + streamOrSubjectsIndex: 0, + majorOptions: MAJOR_CHOICES, + preferredFieldsIndex: 0, + saving: false + }, + + /** 每次页面展示拉取(含从上级页返回),避免栈内页面不触发 onLoad 时看不到已保存内容 */ + onShow() { + this.loadFormFromServer() + }, + + loadFormFromServer() { + gaokaoApi + .getForm() + .then((res) => { + const form = res.form || {} + const pr = (() => { + const a = form.preferredRegion + if (!Array.isArray(a) || a.length < 1) return [] + return a + })() + const baseForm = { + ...this.data.form, + ...form, + city: (form.city != null && form.city !== '') ? String(form.city) : (this.data.form.city || ''), + region: (() => { + const a = form.region + if (!Array.isArray(a) || a.length < 2) return [] + return a + })(), + preferredRegion: pr, + preferredRegions: (() => { + if (pr && pr.length >= 2) { + return regionToCityText([pr[0], pr[1]]) + } + if (pr && pr.length === 1) { + return (pr[0] || '').trim() + } + return form.preferredRegions != null ? String(form.preferredRegions) : '' + })(), + estimatedScore: form.estimatedScore != null ? String(form.estimatedScore) : '' + } + const rpv = baseForm.region + if (rpv && rpv.length >= 2) { + baseForm.province = rpv[0] || baseForm.province + baseForm.city = rpv[1] || baseForm.city + } + const rForPicker = (() => { + if (Array.isArray(baseForm.region) && baseForm.region.length >= 2) { + const a0 = (baseForm.region[0] || '').trim() + const a1 = (baseForm.region[1] || a0).trim() + return [a0, a1] + } + if (baseForm.province) { + const a0 = String(baseForm.province).trim() + const a1 = (baseForm.city && String(baseForm.city).trim()) || a0 + return [a0, a1] + } + return [] + })() + const regionLine = rForPicker.length + ? formatRegionLine(rForPicker) + : '' + const intendedModeOptions = this.data.intendedModeOptions + const modeIdx = (() => { + if (pr && pr.length >= 2) return 1 + if (pr && pr.length === 1) return 0 + return 1 + })() + const intendedModeLine = intendedModeOptions[modeIdx] || '到省+市' + const intendedProvPicker = (() => { + if (pr && pr.length >= 1) return [(String(pr[0] || '')).trim()] + return [] + })() + const intendedCityPicker = (() => { + if (pr && pr.length >= 2) { + return [ + (String(pr[0] || '')).trim(), + (String(pr[1] || '')).trim() + ] + } + if (pr && pr.length === 1 && modeIdx === 1) { + const p0 = (String(pr[0] || '')).trim() + return p0 ? [p0, p0] : [] + } + return [] + })() + let intendedRegionLine = (() => { + if (pr && pr.length >= 2) { + return formatRegionLine([(pr[0] || '').trim(), (pr[1] || '').trim()]) + } + if (pr && pr.length === 1) { + return (pr[0] || '').trim() + } + if (form.preferredRegions) return String(form.preferredRegions) + return '' + })() + const stream = baseForm.streamOrSubjects || '' + const opts = (() => { + if (stream && SUBJECT_CHOICES.indexOf(stream) < 0) { + return [SUBJECT_CHOICES[0], stream, ...SUBJECT_CHOICES.slice(1)] + } + return SUBJECT_CHOICES + })() + let sIdx = opts.indexOf(stream) + if (sIdx < 0) sIdx = 0 + const pField = baseForm.preferredFields || '' + const mOpts = (() => { + if (pField && MAJOR_CHOICES.indexOf(pField) < 0) { + return [MAJOR_CHOICES[0], pField, ...MAJOR_CHOICES.slice(1)] + } + return MAJOR_CHOICES + })() + let mIdx = mOpts.indexOf(pField) + if (mIdx < 0) mIdx = 0 + this.setData({ + form: baseForm, + regionPickerValue: rForPicker, + regionLine, + intendedModeIndex: modeIdx, + intendedModeLine, + intendedProvPicker, + intendedCityPicker, + intendedRegionLine, + subjectOptions: opts, + streamOrSubjectsIndex: sIdx, + majorOptions: mOpts, + preferredFieldsIndex: mIdx + }) + }) + .catch(() => { + wx.showToast({ title: '加载表单失败', icon: 'none' }) + }) + }, + + onInput(e) { + const key = e.currentTarget.dataset.key + this.setData({ [`form.${key}`]: e.detail.value }) + }, + + onRegionChange(e) { + const v = regionValueFromEvent(e) + if (!v || !v.length) return + const p = (v[0] || '').trim() + if (!p) return + const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : '' + const pair = [p, c2 || p] + this.setData({ + regionPickerValue: pair, + regionLine: formatRegionLine(pair), + 'form.province': p, + 'form.city': c2 || p, + 'form.region': pair + }) + }, + + onSubjectChange(e) { + const idx = parseInt(e.detail.value, 10) || 0 + const opts = this.data.subjectOptions + const raw = opts[idx] || '' + const val = + raw && raw !== SUBJECT_PLACEHOLDER + ? raw + : '' + this.setData({ + streamOrSubjectsIndex: idx, + 'form.streamOrSubjects': val + }) + }, + + onIntendedModeChange(e) { + const idx = parseInt(e.detail.value, 10) || 0 + const opts = this.data.intendedModeOptions + const pr = this.data.form.preferredRegion + const arr = Array.isArray(pr) ? pr : [] + if (idx === 0) { + const p = arr[0] ? String(arr[0]).trim() : '' + const next = p ? [p] : [] + this.setData({ + intendedModeIndex: idx, + intendedModeLine: opts[idx], + intendedProvPicker: next, + intendedRegionLine: p, + 'form.preferredRegion': next, + 'form.preferredRegions': p + }) + return + } + let cityPick = [] + if (arr.length >= 2) { + cityPick = [String(arr[0] || '').trim(), String(arr[1] || '').trim()] + } else if (arr.length === 1) { + const p0 = String(arr[0] || '').trim() + cityPick = p0 ? [p0, p0] : [] + } + const hasPair = arr.length >= 2 + this.setData({ + intendedModeIndex: idx, + intendedModeLine: opts[idx], + intendedCityPicker: cityPick + }) + if (hasPair) { + const line = formatRegionLine(cityPick) + this.setData({ + intendedRegionLine: line, + 'form.preferredRegion': [cityPick[0], cityPick[1]], + 'form.preferredRegions': regionToCityText(cityPick) + }) + } else { + this.setData({ + intendedRegionLine: arr[0] ? String(arr[0]).trim() : '', + 'form.preferredRegion': arr + }) + } + }, + + onIntendedRegionProv(e) { + const v = regionValueFromEvent(e) + if (!v || !v.length) return + const p = (v[0] || '').trim() + if (!p) return + this.setData({ + intendedProvPicker: [p], + intendedRegionLine: p, + 'form.preferredRegion': [p], + 'form.preferredRegions': p + }) + }, + + onIntendedRegionCity(e) { + const v = regionValueFromEvent(e) + if (!v || !v.length) return + const p = (v[0] || '').trim() + if (!p) return + const c2 = v[1] != null && v[1] !== '' ? String(v[1]).trim() : '' + const pair = [p, c2 || p] + this.setData({ + intendedCityPicker: pair, + intendedRegionLine: formatRegionLine(pair), + 'form.preferredRegion': pair, + 'form.preferredRegions': regionToCityText(pair) + }) + }, + + onPreferredFieldChange(e) { + const idx = parseInt(e.detail.value, 10) || 0 + const opts = this.data.majorOptions + const raw = opts[idx] || '' + const val = raw && raw !== MAJOR_PLACEHOLDER ? raw : '' + this.setData({ + preferredFieldsIndex: idx, + 'form.preferredFields': val + }) + }, + + onSave() { + const f = this.data.form + if (!f.name || !f.province || !f.streamOrSubjects) { + wx.showToast({ title: '请选择姓名、所在地区与科类/选科', icon: 'none' }) + return + } + this.setData({ saving: true }) + gaokaoApi.saveForm({ + ...f, + estimatedScore: f.estimatedScore ? Number(f.estimatedScore) : null + }).then(() => { + wx.showToast({ title: '保存成功', icon: 'success' }) + setTimeout(() => wx.navigateBack(), 400) + }).catch((e) => { + wx.showToast({ title: e.message || '保存失败', icon: 'none' }) + }).finally(() => this.setData({ saving: false })) + } +}) + diff --git a/miniprogram/pages/gaokao/form.json b/miniprogram/pages/gaokao/form.json new file mode 100644 index 0000000..629fc43 --- /dev/null +++ b/miniprogram/pages/gaokao/form.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "高考信息表单" +} + diff --git a/miniprogram/pages/gaokao/form.wxml b/miniprogram/pages/gaokao/form.wxml new file mode 100644 index 0000000..ab11a68 --- /dev/null +++ b/miniprogram/pages/gaokao/form.wxml @@ -0,0 +1,72 @@ + + + + 姓名 + + + + 所在地区 + + + {{regionLine ? regionLine : '点选高考生源省、市'}} + + + + + 科类/选科 + + + {{form.streamOrSubjects}} + 请点选科类或选科组合 + + + + + 估分 + + + + 成绩说明 + + + + 志愿草表 + + + + 意向地区 + + + 粒度:{{intendedModeLine}} + + + + + + + {{intendedRegionLine ? intendedRegionLine : '点选省份(可只到省)'}} + + + + + + + {{intendedRegionLine ? intendedRegionLine : '点选省、市(含直辖市)'}} + + + + + + + 意向专业 + + + {{form.preferredFields}} + 可选,点选专业方向 + + + + + + + diff --git a/miniprogram/pages/gaokao/form.wxss b/miniprogram/pages/gaokao/form.wxss new file mode 100644 index 0000000..b3078ab --- /dev/null +++ b/miniprogram/pages/gaokao/form.wxss @@ -0,0 +1,86 @@ +.container { + min-height: 100vh; + background: #f7f8fc; + padding: 24rpx; + box-sizing: border-box; +} + +.card { + background: #fff; + border-radius: 16rpx; + padding: 20rpx; +} + +.item { + margin-bottom: 18rpx; +} + +.label { + font-size: 26rpx; + color: #333; + margin-bottom: 8rpx; +} + +.input { + height: 74rpx; + border: 1rpx solid #e6e8ee; + border-radius: 12rpx; + padding: 0 20rpx; + font-size: 26rpx; +} + +.input-picker { + line-height: 72rpx; + box-sizing: border-box; + color: #1f2937; +} + +/* 避免内联 picker 与下一行表单项左右串版(如市名跑到「科类」旁) */ +.item > picker, +.item picker, +.intended-region-pickers picker { + display: block; + width: 100%; +} + +.input-picker-empty text { + color: #9ca3af; +} + +.input-picker .ph { + color: #9ca3af; +} + +.textarea { + min-height: 140rpx; + border: 1rpx solid #e6e8ee; + border-radius: 12rpx; + padding: 16rpx 20rpx; + font-size: 26rpx; + width: 100%; + box-sizing: border-box; +} + +/* 意向地区:先选粒度,再选省或省+市;第二行与第一行间留白 */ +.intended-mode-picker { + display: block; + width: 100%; +} + +.item-intended .intended-region-pickers { + margin-top: 12rpx; +} + +.input-sub text { + font-size: 24rpx; + color: #6b7280; +} + +.save-btn { + margin-top: 24rpx; + background: #7c3aed; + color: #fff; + border-radius: 999rpx; + font-size: 30rpx; +} + diff --git a/miniprogram/pages/gaokao/index.js b/miniprogram/pages/gaokao/index.js new file mode 100644 index 0000000..c619680 --- /dev/null +++ b/miniprogram/pages/gaokao/index.js @@ -0,0 +1,107 @@ +const gaokaoApi = require('../../utils/gaokao') + +Page({ + data: { + loading: false, + tasks: { + mbti: { code: 'mbti', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'mbti', typeName: 'MBTI性格', emoji: '🧠', testTime: '' }, + pdp: { code: 'pdp', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'pdp', typeName: 'PDP行为', emoji: '🦁', testTime: '' }, + disc: { code: 'disc', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'disc', typeName: 'DISC测评', emoji: '📊', testTime: '' }, + face: { code: 'face', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'ai', typeName: '拍照面相', emoji: '📷', testTime: '', recordTestType: 'ai' }, + form: { code: 'form', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'form', typeName: '高考信息表单', emoji: '📝', testTime: '' } + }, + canAnalyze: false, + missingItems: [], + analyzing: false + }, + + onShow() { + this.refreshStatus() + }, + + refreshStatus() { + this.setData({ loading: true }) + gaokaoApi + .getTaskStatus({ scene: 'gaokao_hub' }) + .then((data) => { + this.setData({ + tasks: data.tasks || this.data.tasks, + canAnalyze: !!data.canAnalyze, + missingItems: data.missingItems || [] + }) + }) + .catch((e) => { + wx.showToast({ title: e.message || '加载失败', icon: 'none' }) + }) + .finally(() => this.setData({ loading: false })) + }, + + goTask(e) { + const code = e.currentTarget.dataset.code + const resultId = Number(e.currentTarget.dataset.resultId || 0) + const done = (e.currentTarget.dataset.status || '') === 'done' + if (code === 'mbti') { + if (done && resultId > 0) { + wx.navigateTo({ url: `/pages/result/mbti?id=${resultId}&type=mbti` }) + } else { + wx.navigateTo({ url: '/pages/test/mbti' }) + } + return + } + if (code === 'pdp') { + if (done && resultId > 0) { + wx.navigateTo({ url: `/pages/result/pdp?id=${resultId}&type=pdp` }) + } else { + wx.navigateTo({ url: '/pages/test/pdp' }) + } + return + } + if (code === 'disc') { + if (done && resultId > 0) { + wx.navigateTo({ url: `/pages/result/disc?id=${resultId}&type=disc` }) + } else { + wx.navigateTo({ url: '/pages/test/disc' }) + } + return + } + if (code === 'face') { + const recType = String(e.currentTarget.dataset.recordType || 'ai').toLowerCase() + const typeParam = recType === 'face' ? 'face' : 'ai' + if (done && resultId > 0) { + wx.navigateTo({ url: `/pages/index/result?id=${resultId}&type=${typeParam}` }) + } else { + wx.switchTab({ url: '/pages/index/camera' }) + } + return + } + if (code === 'form') { + wx.navigateTo({ url: '/pages/gaokao/form' }) + } + }, + + onAnalyzeTap() { + if (!this.data.canAnalyze) { + const nameMap = { + mbti: 'MBTI测试', + pdp: 'PDP测试', + disc: 'DISC测试', + face: '拍照面相', + form: '高考信息表单' + } + const msg = (this.data.missingItems || []).map((k) => nameMap[k] || k).join('、') + wx.showToast({ + title: msg ? `请先完成:${msg}` : '请先完成全部任务', + icon: 'none', + duration: 2500 + }) + return + } + this.setData({ analyzing: true }) + wx.navigateTo({ + url: '/pages/gaokao/report?pendingAnalyze=1', + complete: () => { + this.setData({ analyzing: false }) + } + }) + } +}) diff --git a/miniprogram/pages/gaokao/index.json b/miniprogram/pages/gaokao/index.json new file mode 100644 index 0000000..74d5cc7 --- /dev/null +++ b/miniprogram/pages/gaokao/index.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "高考志愿" +} + diff --git a/miniprogram/pages/gaokao/index.wxml b/miniprogram/pages/gaokao/index.wxml new file mode 100644 index 0000000..3bcd09c --- /dev/null +++ b/miniprogram/pages/gaokao/index.wxml @@ -0,0 +1,90 @@ + + + + 高考志愿任务中心 + 完成 MBTI、PDP、DISC、拍照面相与高考信息表单后,即可生成综合分析;若定价非 0,请在报告页解锁全文(与问卷类测评一致)。 + + + + + + + {{tasks.mbti.emoji}} + {{tasks.mbti.typeName}} + + {{tasks.mbti.resultText || '已完成'}} + 未完成,请去测试 + {{tasks.mbti.testTime}} + + {{tasks.mbti.status==='done'?'查看结果':'去完成'}} + + + + + + {{tasks.pdp.emoji}} + {{tasks.pdp.typeName}} + + {{tasks.pdp.resultText || '已完成'}} + 未完成,请去测试 + {{tasks.pdp.testTime}} + + {{tasks.pdp.status==='done'?'查看结果':'去完成'}} + + + + + + {{tasks.disc.emoji}} + {{tasks.disc.typeName}} + + {{tasks.disc.resultText || '已完成'}} + 未完成,请去测试 + {{tasks.disc.testTime}} + + {{tasks.disc.status==='done'?'查看结果':'去完成'}} + + + + + + {{tasks.face.emoji}} + {{tasks.face.typeName}} + + {{tasks.face.resultText || '已完成'}} + 未完成,请去拍摄 + {{tasks.face.testTime}} + + {{tasks.face.status==='done'?'查看结果':'去完成'}} + + + + + + {{tasks.form.emoji}} + {{tasks.form.typeName}} + + + 已填写:{{tasks.form.resultText}} + 已完成 + + 未完成,请先填写 + {{tasks.form.testTime}} + + {{tasks.form.status==='done'?'查看结果':'去完成'}} + + + + + + + + + diff --git a/miniprogram/pages/gaokao/index.wxss b/miniprogram/pages/gaokao/index.wxss new file mode 100644 index 0000000..eea1f27 --- /dev/null +++ b/miniprogram/pages/gaokao/index.wxss @@ -0,0 +1,142 @@ +.page { + min-height: 100vh; + background: #f7f8fc; + box-sizing: border-box; +} + +.container { + min-height: 100%; + background: #f7f8fc; + padding: 32rpx 24rpx; + /* 底部固定操作条占位(单主按钮 + 安全区) */ + padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.header { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 20rpx; +} + +.title { + font-size: 36rpx; + font-weight: 700; + color: #222; +} + +.desc { + font-size: 24rpx; + color: #666; + margin-top: 8rpx; +} + +.task-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.task-card { + background: #fff; + border-radius: 14rpx; + padding: 22rpx 20rpx; + display: flex; + align-items: center; + justify-content: space-between; +} + +.task-title-row { + display: flex; + align-items: center; + gap: 12rpx; +} + +.task-emoji { + font-size: 36rpx; + line-height: 1; +} + +.name { + font-size: 30rpx; + color: #222; + font-weight: 600; +} + +.task-time { + margin-top: 6rpx; + font-size: 22rpx; + color: #9ca3af; +} + +.result { + margin-top: 8rpx; + font-size: 24rpx; + color: #666; +} + +.result--value { + font-size: 30rpx; + font-weight: 600; + color: #4f46e5; +} + +.result.todo { + color: #d04848; +} + +.status { + font-size: 24rpx; + color: #7c3aed; +} + +.status.done { + color: #1f9d55; +} + +/* 底部悬浮:全宽主操作 */ +.bottom-actions { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + width: 100%; + box-sizing: border-box; + padding: 16rpx 24rpx; + padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 -8rpx 32rpx rgba(15, 23, 42, 0.08); + border-top: 1rpx solid #eef0f4; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.action-btn { + width: 100% !important; + margin: 0; + padding: 24rpx 32rpx; + line-height: 1.35; + border-radius: 16rpx; + font-size: 30rpx; + font-weight: 700; + border: none; + box-sizing: border-box; +} + +.action-btn::after { + border: none; +} + +.action-btn--primary { + background: #7c3aed; + color: #fff; +} + +.action-btn--disabled { + background: #e5e7eb; + color: #9ca3af; +} + diff --git a/miniprogram/pages/gaokao/report.js b/miniprogram/pages/gaokao/report.js new file mode 100644 index 0000000..80d07b3 --- /dev/null +++ b/miniprogram/pages/gaokao/report.js @@ -0,0 +1,690 @@ +const app = getApp() +const gaokaoApi = require('../../utils/gaokao') +const { requestPromise } = require('../../utils/request') +const payment = require('../../utils/payment') +const { + hasPhone, + bindPhoneByCode, + needsResultProfileGate, + navigateToCompleteProfileAfterPhoneIfNeeded +} = require('../../utils/phoneAuth.js') +const unlockGate = require('../../utils/unlockGate.js') +const inviteCodeGate = require('../../utils/inviteCodeGate.js') +const { openTimelineShareHint } = require('../../utils/resultProfileGate.js') +const { computeJourney, markShared } = require('../../utils/gaokaoJourneyState.js') +const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js') + +/** GET /api/test/detail | share-detail 返回体 -> mergeApiReport 入参 */ +function mapTestDetailToReportPayload(detail) { + if (!detail || typeof detail !== 'object') { + return null + } + if (String(detail.testType || '').toLowerCase() !== 'gaokao') { + return null + } + const d = detail.data + if (!d || typeof d !== 'object') { + return null + } + const locked = !!d.locked + let report = d.report + if (typeof report === 'string') { + try { + report = JSON.parse(report) + } catch (e) { + report = null + } + } + const inputSnap = + d.inputSnapshot && typeof d.inputSnapshot === 'object' && !Array.isArray(d.inputSnapshot) + ? d.inputSnapshot + : {} + + if (locked) { + const ov = + typeof d.overview === 'string' && d.overview !== '' + ? d.overview + : String(d.overview || '') + report = { + overview: ov, + personalityReason: '', + disclaimers: '', + majorRecommend: [], + schoolRecommend: {}, + inputEcho: { + name: String(inputSnap.name || ''), + province: String(inputSnap.province || ''), + streamSubjects: String(inputSnap.streamSubjects || ''), + estimatedScore: inputSnap.estimatedScore != null ? Number(inputSnap.estimatedScore) : 0, + mbti: String(inputSnap.mbti || ''), + pdp: String(inputSnap.pdp || ''), + disc: String(inputSnap.disc || '') + }, + locked: true + } + } else if (!report || typeof report !== 'object' || Array.isArray(report)) { + return null + } + + const topOv = typeof d.overview === 'string' ? d.overview : '' + return { + id: detail.id, + createdAt: detail.createdAt, + overview: topOv, + report + } +} + +/** + * 接口返回: { id, createdAt, overview, report: reportJson } + * 展示用合并为一层,便于 wxml 绑定 + */ +function mergeApiReport(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + let br = payload.report + if (typeof br === 'string') { + try { + br = JSON.parse(br) + } catch (e) { + br = null + } + } + const block = br && typeof br === 'object' && !Array.isArray(br) ? br : {} + const hasBlock = Object.keys(block).length > 0 + const hasOverview = typeof payload.overview === 'string' && payload.overview !== '' + if (!hasBlock && !hasOverview && !(payload.id > 0)) { + return null + } + const overview = hasOverview ? payload.overview : block.overview || '' + return Object.assign({}, block, { overview }) +} + +function normalizeSchoolRow(x, band) { + if (!x || typeof x !== 'object') { + return null + } + return { + band: band || '', + schoolName: String(x.schoolName || x.name || '').trim() || '未命名院校', + city: String(x.city || '').trim(), + level: String(x.level || '').trim(), + reason: String(x.reason || x.desc || '').trim() + } +} + +function buildSchoolListFlat(rawSr) { + if (Array.isArray(rawSr)) { + return rawSr.map((x) => normalizeSchoolRow(x, '')).filter(Boolean) + } + if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) { + const chong = Array.isArray(rawSr.chong) ? rawSr.chong : [] + const wen = Array.isArray(rawSr.wen) ? rawSr.wen : [] + const bao = Array.isArray(rawSr.bao) ? rawSr.bao : [] + return [ + ...chong.map((x) => normalizeSchoolRow(x, '冲')), + ...wen.map((x) => normalizeSchoolRow(x, '稳')), + ...bao.map((x) => normalizeSchoolRow(x, '保')) + ].filter(Boolean) + } + return [] +} + +function buildViewModel(payload) { + const report = mergeApiReport(payload) + if (!report) { + return { + report: null, + inputEcho: {}, + majorList: [], + schoolListFlat: [], + schoolChongCount: 0, + schoolWenCount: 0, + schoolBaoCount: 0, + hasSchoolFlat: false, + hasNoMajors: true + } + } + const rawSr = report.schoolRecommend + const schoolListFlat = buildSchoolListFlat(rawSr) + + let schoolChongCount = 0 + let schoolWenCount = 0 + let schoolBaoCount = 0 + if (rawSr && typeof rawSr === 'object' && !Array.isArray(rawSr)) { + schoolChongCount = Array.isArray(rawSr.chong) ? rawSr.chong.length : 0 + schoolWenCount = Array.isArray(rawSr.wen) ? rawSr.wen.length : 0 + schoolBaoCount = Array.isArray(rawSr.bao) ? rawSr.bao.length : 0 + } + + const majors = Array.isArray(report.majorRecommend) ? report.majorRecommend : [] + const inputEcho = report.inputEcho || {} + + return { + report, + inputEcho, + majorList: majors.map((m) => { + const rawName = + m && + (m.majorName || + m.name || + m.title || + m.major || + m.major_name || + m.majorChinese || + m['专业'] || + m['专业名称']) + const name = rawName != null && rawName !== '' ? String(rawName).trim() : '' + const displayName = name || '未命名专业' + const score = m && (m.fitScore != null ? m.fitScore : m.matchScore) + const fitLabel = score != null && score !== '' ? '(' + String(score) + ')' : '' + return { name: displayName, fitLabel } + }), + schoolListFlat, + schoolChongCount, + schoolWenCount, + schoolBaoCount, + hasSchoolFlat: schoolListFlat.length > 0, + hasNoMajors: majors.length === 0 + } +} + +function payInfoFromDetail(detail) { + const isPaid = !!(detail && (detail.isPaid === 1 || detail.isPaid === true)) + const paidAmount = detail && detail.paidAmount != null ? Number(detail.paidAmount) : 0 + const amountYuan = + detail && detail.amountYuan != null + ? Number(detail.amountYuan) + : paidAmount > 0 + ? paidAmount / 100 + : 0 + const needPaymentToUnlock = + detail && + (detail.needPaymentToUnlock === true || + (!!detail.requiresPayment && !isPaid && paidAmount > 0)) + return { + requiresPayment: needPaymentToUnlock, + isPaid, + amountYuan: needPaymentToUnlock ? amountYuan : 0 + } +} + +Page({ + data: { + report: null, + inputEcho: {}, + majorList: [], + schoolListFlat: [], + schoolChongCount: 0, + schoolWenCount: 0, + schoolBaoCount: 0, + hasSchoolFlat: false, + hasNoMajors: true, + journey: { step1Unlocked: false, step2Unlocked: false, activeStep: 1 }, + payInfo: { + requiresPayment: false, + isPaid: false, + amountYuan: 0 + }, + testResultId: '', + shareToken: '', + hasReloadedAfterPay: false, + hasPhone: false, + fromShare: false, + profileGate: false, + showInviteCodeDialog: false, + isPendingAnalyze: false, + analyzingTitle: '正在生成高考志愿分析报告', + analyzingTip: '', + analyzeProgress: 0 + }, + + onLoad(options) { + try { + wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) + } catch (e) {} + + const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share') + const sid = options && options.id != null && options.id !== '' ? String(options.id) : '' + const st = options && options.st ? String(options.st).trim() : '' + + if (sid && st) { + this.setData({ fromShare: true }) + this.loadShareDetail(sid, st) + return + } + + const ec = + typeof this.getOpenerEventChannel === 'function' ? this.getOpenerEventChannel() : null + if (ec && typeof ec.once === 'function') { + ec.once('gaokaoAnalyzeReport', (payload) => { + if (payload && payload.report) { + this.applyPayloadOnly({ + id: payload.id, + createdAt: payload.createdAt || 0, + overview: payload.overview || '', + report: payload.report + }) + } + }) + } + + const pendingAnalyze = options && String(options.pendingAnalyze) === '1' + if (pendingAnalyze) { + this._pendingAnalyze = true + return + } + + const rid = options && options.id != null ? parseInt(String(options.id), 10) : 0 + this._detailReportId = rid > 0 && !Number.isNaN(rid) ? rid : 0 + if (fromShareFs) { + this.setData({ fromShare: true }) + } + + const delay = options && options.fromAnalyze === '1' ? 400 : 0 + setTimeout(() => this.load(), delay) + }, + + onReady() { + if (this._pendingAnalyze) { + this._pendingAnalyze = false + this.beginAnalyzeFlow() + } + }, + + onUnload() { + this._clearAnalyzeTimers() + }, + + _clearAnalyzeTimers() { + if (this._analyzeProgressTimer) { + clearInterval(this._analyzeProgressTimer) + this._analyzeProgressTimer = null + } + }, + + beginAnalyzeFlow() { + this._clearAnalyzeTimers() + const tips = [ + '正在读取您的 MBTI 与测评数据…', + '正在匹配专业维度与性格倾向…', + '正在根据分数与省份生成志愿建议…', + '正在润色报告与安全合规校验…', + '生成综合报告…' + ] + let progress = 0 + let tipIndex = 0 + this.setData({ + isPendingAnalyze: true, + analyzingTitle: '正在生成高考志愿分析报告', + analyzingTip: tips[0], + analyzeProgress: 0 + }) + this._analyzeProgressTimer = setInterval(() => { + progress += 3 + if (progress > 95) progress = 95 + if (progress > (tipIndex + 1) * 18 && tipIndex < tips.length - 1) tipIndex++ + this.setData({ + analyzeProgress: Math.floor(progress), + analyzingTip: tips[tipIndex] + }) + }, 200) + + gaokaoApi + .analyze() + .then((res) => { + this._clearAnalyzeTimers() + const rawId = res && (res.reportId != null ? res.reportId : res.id) + const numId = parseInt(String(rawId), 10) + if (!rawId || Number.isNaN(numId) || numId <= 0) { + throw new Error('未返回报告') + } + this._detailReportId = numId + this.setData({ + analyzeProgress: 100, + analyzingTip: '分析完成!' + }) + return new Promise((r) => setTimeout(r, 400)).then(() => + this.loadDetail(numId, { silent: true }).catch(() => { + const rep = res && res.report + if (rep && typeof rep === 'object') { + this.applyPayloadOnly({ + id: numId, + createdAt: res.createdAt || 0, + overview: + (typeof res.overview === 'string' && res.overview) || + (rep.overview && String(rep.overview)) || + '', + report: rep + }) + return + } + return Promise.reject(new Error('报告已生成,但加载详情失败')) + }) + ) + }) + .then(() => { + this.setData({ isPendingAnalyze: false }) + }) + .catch((e) => { + this._clearAnalyzeTimers() + this.setData({ isPendingAnalyze: false, analyzeProgress: 0 }) + wx.showToast({ title: (e && e.message) || '分析失败', icon: 'none' }) + setTimeout(() => { + wx.navigateBack({ delta: 1 }) + }, 1600) + }) + }, + + onShow() { + this.setData({ hasPhone: hasPhone() }) + if (this.data.report && !this.data.fromShare) { + const profileGate = needsResultProfileGate(!!this.data.fromShare) + this.setData({ profileGate }) + this._syncJourney() + } + // 切换个人/企业 Tab 后回到报告页:静默重拉详情以同步 paidAmount(与当前 Tab 定价一致) + if (!this.data.fromShare && !this.data.isPendingAnalyze) { + const rid = parseInt(String(this.data.testResultId || ''), 10) + if (rid > 0) { + this.loadDetail(rid, { silent: true }).catch(() => {}) + } + } + }, + + onShareAppMessage() { + const id = this.data.testResultId + const st = this.data.shareToken + if (!id || !st) { + return { title: '高考志愿分析报告', path: '/pages/gaokao/index' } + } + return { + title: '高考志愿分析报告', + path: `/pages/gaokao/report?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}&fs=1` + } + }, + + _syncJourney() { + const j = computeJourney( + { + profileGate: !!this.data.profileGate, + payRequired: !!(this.data.payInfo && this.data.payInfo.requiresPayment), + isPaid: !!(this.data.payInfo && this.data.payInfo.isPaid) + }, + this.data.testResultId || '0' + ) + this.setData({ journey: j }) + }, + + _reportPaywallOnce(payInfo) { + if (!payInfo || !payInfo.requiresPayment || payInfo.isPaid) return + if (this._paywallReported) return + this._paywallReported = true + try { + require('../../utils/analytics').track('paywall_view', { + type: 'gaokao', + amountYuan: payInfo.amountYuan + }) + } catch (e) {} + }, + + applyPayloadOnly(payload) { + const vm = buildViewModel(payload) + const profileGate = needsResultProfileGate(!!this.data.fromShare) + const patch = Object.assign(vm, { profileGate }) + if (payload && payload.id != null) { + patch.testResultId = String(payload.id) + } + this.setData(patch) + this._syncJourney() + }, + + applyDetailPayload(detail) { + const mapped = mapTestDetailToReportPayload(detail) + if (!mapped) { + wx.showToast({ title: '报告数据无效', icon: 'none' }) + this.setData(buildViewModel(null)) + return + } + const payInfo = payInfoFromDetail(detail) + const profileGate = needsResultProfileGate(!!this.data.fromShare) + const vm = buildViewModel(mapped) + const patch = Object.assign(vm, { + payInfo, + profileGate, + shareToken: (detail && detail.shareToken) || '', + testResultId: detail.id != null ? String(detail.id) : '' + }) + this.setData(patch) + this._reportPaywallOnce(payInfo) + this._syncJourney() + }, + + loadShareDetail(id, st) { + wx.showLoading({ title: '加载中...' }) + requestPromise({ + url: `/api/test/share-detail?id=${encodeURIComponent(id)}&st=${encodeURIComponent(st)}`, + method: 'GET' + }) + .then((res) => { + const body = res.data || {} + if (body.code !== 200) { + throw new Error(body.message || '加载失败') + } + this.applyDetailPayload(body.data || {}) + }) + .catch((e) => { + wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' }) + this.setData(buildViewModel(null)) + }) + .finally(() => wx.hideLoading()) + }, + + loadDetail(id, opts) { + const silent = !!(opts && opts.silent) + const numId = parseInt(String(id), 10) + if (!numId || Number.isNaN(numId)) { + return Promise.resolve() + } + if (!silent) wx.showLoading({ title: '加载中...' }) + const gd = app.globalData || {} + const pricingScope = gd.appScope === 'enterprise' ? 'enterprise' : 'personal' + let detailUrl = `/api/test/detail?id=${encodeURIComponent(numId)}&pricingScope=${encodeURIComponent(pricingScope)}` + try { + const eid = getEnterpriseIdForApiPayload() + if (eid != null && Number(eid) > 0) { + detailUrl += `&enterpriseId=${encodeURIComponent(String(eid))}` + } + } catch (e) {} + return requestPromise({ + url: detailUrl, + method: 'GET' + }) + .then((res) => { + const body = res.data || {} + if (body.code !== 200) { + throw new Error(body.message || '加载失败') + } + this.applyDetailPayload(body.data || {}) + }) + .catch((e) => { + if (!silent) { + wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' }) + this.setData(buildViewModel(null)) + } + return Promise.reject(e) + }) + .finally(() => { + if (!silent) wx.hideLoading() + }) + }, + + load() { + const rid = this._detailReportId || 0 + if (rid > 0) { + this.loadDetail(rid) + return + } + gaokaoApi + .latestReport() + .then((payload) => { + const id = payload && payload.id + if (!id) { + throw new Error('暂无报告') + } + return this.loadDetail(id) + }) + .catch((e) => { + wx.showToast({ title: e.message || '暂无报告', icon: 'none' }) + this.setData(buildViewModel(null)) + }) + }, + + goCompleteProfile() { + try { + require('../../utils/analytics').track('tap_complete_profile', { from: 'gaokao_report' }) + } catch (e) {} + wx.navigateTo({ url: '/pages/user-profile/index' }) + }, + + goWantTest() { + wx.switchTab({ url: '/pages/index/index' }) + }, + + goReadFullFromShare() { + wx.switchTab({ url: '/pages/profile/index' }) + }, + + onTapReadFull() { + try { + require('../../utils/analytics').track('tap_read_full', { type: 'gaokao' }) + } catch (e) {} + if (this.data.profileGate) { + unlockGate.scrollToUnlockAnchor(this) + wx.showToast({ + title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权', + icon: 'none' + }) + return + } + if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) { + this.unlockFullReport() + return + } + wx.showToast({ title: '当前已是完整报告', icon: 'none' }) + }, + + onTapShareMoment() { + try { + require('../../utils/analytics').track('tap_share_moment', { type: 'gaokao' }) + } catch (e) {} + if (!this.data.journey.step1Unlocked) { + wx.showToast({ title: '请先解锁全文', icon: 'none' }) + this.onTapReadFull() + return + } + markShared(this.data.testResultId || '0') + this._syncJourney() + openTimelineShareHint() + }, + + unlockFullReport() { + const { payInfo, testResultId, hasReloadedAfterPay } = this.data + if (!payInfo.requiresPayment || payInfo.isPaid) return + try { + require('../../utils/analytics').track('tap_unlock_full', { + type: 'gaokao', + amountYuan: payInfo.amountYuan + }) + } catch (e) {} + const run = + typeof app.ensureLogin === 'function' + ? app.ensureLogin() + : Promise.resolve(!!(app.globalData && app.globalData.token) || !!wx.getStorageSync('token')) + run.then((logged) => { + if (!logged) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + unlockGate.ensureUnlockPrerequisitesBeforePay(this).then((ok) => { + if (!ok) return + inviteCodeGate.ensureInviteCodeGate(this).then((go) => { + if (!go) return + payment.purchaseGaokaoReport({ + testResultId: testResultId ? parseInt(String(testResultId), 10) || undefined : undefined, + success: () => { + wx.showToast({ title: '已解锁完整报告', icon: 'success' }) + this.setData({ 'payInfo.isPaid': true }) + this._syncJourney() + if (testResultId && !hasReloadedAfterPay) { + this.setData({ hasReloadedAfterPay: true }) + setTimeout(() => this.loadDetail(testResultId), 500) + } + }, + fail: () => {} + }) + }) + }) + }) + }, + + onGetPhoneNumberForGaokaoPay(e) { + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + if (!hasPhone()) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + this.unlockFullReport() + return + } + if (!code) { + if (hasPhone()) { + this.unlockFullReport() + } else { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + } + return + } + bindPhoneByCode(code) + .then(() => { + this.setData({ hasPhone: hasPhone() }) + const profileGate = needsResultProfileGate(!!this.data.fromShare) + this.setData({ profileGate }) + navigateToCompleteProfileAfterPhoneIfNeeded() + this._syncJourney() + this.unlockFullReport() + }) + .catch(() => {}) + }, + + onPhoneLoginForResultGate(e) { + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + wx.showToast({ title: '需要授权手机号才能查看完整报告', icon: 'none' }) + return + } + if (!code) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + return + } + bindPhoneByCode(code) + .then(() => { + this.setData({ hasPhone: hasPhone() }) + const profileGate = needsResultProfileGate(!!this.data.fromShare) + this.setData({ profileGate }) + navigateToCompleteProfileAfterPhoneIfNeeded() + this._syncJourney() + }) + .catch(() => {}) + }, + + onInviteCodeSkip() { + inviteCodeGate.finishInviteCodeGate(this, true) + }, + + onInviteCodeSuccess() { + inviteCodeGate.finishInviteCodeGate(this, true) + } +}) diff --git a/miniprogram/pages/gaokao/report.json b/miniprogram/pages/gaokao/report.json new file mode 100644 index 0000000..ff079de --- /dev/null +++ b/miniprogram/pages/gaokao/report.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "高考分析报告", + "usingComponents": { + "invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog" + } +} diff --git a/miniprogram/pages/gaokao/report.wxml b/miniprogram/pages/gaokao/report.wxml new file mode 100644 index 0000000..43809e8 --- /dev/null +++ b/miniprogram/pages/gaokao/report.wxml @@ -0,0 +1,262 @@ + + + + + + + + {{analyzingTitle}} + {{analyzingTip}} + + + + AI 生成可能需要 30 秒~1 分钟,请稍候… + + + + + + + + + + {{inputEcho.estimatedScore || 0}} + + + + + + + + MBTI + {{inputEcho.mbti}} + + + PDP + {{inputEcho.pdp}} + + + DISC + {{inputEcho.disc}} + + + + + + + + + 完整高考志愿分析报告 + 解锁后可查看冲稳保院校、专业推荐与性格解读全文。 + + 综合总评(预览) + + {{report.overview}} + + + + + • 冲稳保院校与推荐理由 + • 专业推荐与匹配说明 + • 性格与志愿方向解读 + + + + + + + + + + + + + 完整报告暂不可查看 + 请稍后在「我的」重试或联系客服;若刚授权手机,可下拉刷新本页。 + + + + + + + 综合总评(预览) + {{report.overview}} + 授权手机号并设置头像、昵称后即可查看全文。 + + + + + + + + + + 📊 + 综合总评 + + {{report.overview}} + + + + + 🎯 + 冲稳保建议 + + + + 冲 {{schoolChongCount}}所 + 稳 {{schoolWenCount}}所 + 保 {{schoolBaoCount}}所 + + + 院校参考(共 {{schoolListFlat.length}} 所) + + + + + + + {{sch.band}} + {{sch.schoolName}} + + + 📍{{sch.city}} + 🎓{{sch.level}} + + + + {{sch.reason}} + + + + + + + 冲 {{schoolChongCount}}所 + 稳 {{schoolWenCount}}所 + 保 {{schoolBaoCount}}所 + + + + + + 💼 + 专业建议 + + + + + {{idx + 1}} + {{row.name}} + {{row.fitLabel}} + + + + 暂无专业推荐 + + + + + 🧠 + 性格匹配说明 + + {{report.personalityReason}} + + + + + ⚠️ + 免责声明 + + {{report.disclaimers}} + + + + + 📄 + 暂无报告,请先完成综合分析 + + + + + + 1 + 看全文 + + + + 2 + 分享朋友圈 + + + + + {{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}} + {{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料后解锁' : '登录并完善资料') : '点击解锁')}} + + + 🔒朋友圈 + {{journey.step1Unlocked ? (journey.step2Unlocked ? '已分享' : '点击分享') : '先解锁全文'}} + + + + + + + + + + + + + + diff --git a/miniprogram/pages/gaokao/report.wxss b/miniprogram/pages/gaokao/report.wxss new file mode 100644 index 0000000..2655c42 --- /dev/null +++ b/miniprogram/pages/gaokao/report.wxss @@ -0,0 +1,698 @@ +@import '../../styles/result-page-dashboard.wxss'; + +.gaokao-report-page { + min-height: 100vh; + background: #f4f6f9; +} + +.container { + min-height: 100vh; + background: #f4f6f9; + padding: 24rpx; + padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); + box-sizing: border-box; +} + +.container.container--with-bottom-tools { + padding-bottom: calc(120rpx + 400rpx + env(safe-area-inset-bottom)); +} + +.container.container--with-share-footer { + padding-bottom: calc(220rpx + env(safe-area-inset-bottom)); +} + +.journey-stepper--two { + max-width: 520rpx; + margin-left: auto; + margin-right: auto; +} + +.result-bottom-tools__row--two .result-tool-btn:nth-child(1)::before { + content: '01'; +} +.result-bottom-tools__row--two .result-tool-btn:nth-child(2)::before { + content: '02'; +} + +/* 付费墙(与 MBTI 结果页同款布局,配色贴近高考页主色) */ +.paywall-card { + margin-bottom: 24rpx; + border-radius: 24rpx; + overflow: hidden; +} +.paywall-content { + position: relative; + min-height: 360rpx; +} +/* 有总评预览时拉高容器,便于展示约 40vh 正文 */ +.paywall-content--preview40 { + min-height: 48vh; +} +.paywall-blur { + padding: 32rpx 24rpx 200rpx; + border-radius: 24rpx; + background: rgba(255, 255, 255, 0.97); + backdrop-filter: blur(6rpx); +} +.paywall-mask { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 24rpx; + z-index: 1; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.28) 38%, + rgba(255, 255, 255, 0.58) 100% + ); + pointer-events: none; +} +/* 顶部约 40% 区域不压暗,以下渐强引导至解锁按钮 */ +.paywall-mask--from40 { + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 40%, + rgba(255, 255, 255, 0.35) 58%, + rgba(255, 255, 255, 0.72) 78%, + rgba(255, 255, 255, 0.94) 100% + ); +} +.paywall-fake-title { + display: block; + font-size: 30rpx; + font-weight: 600; + color: #444; + margin-bottom: 16rpx; +} +.paywall-fake-line { + display: block; + font-size: 26rpx; + color: #888; + line-height: 1.8; +} +.paywall-fake-line--compact { + margin-bottom: 20rpx; + font-size: 24rpx; + color: #64748b; +} +.paywall-preview-wrap { + margin-top: 8rpx; +} +.paywall-preview-label { + display: block; + font-size: 24rpx; + font-weight: 600; + color: #6366f1; + margin-bottom: 12rpx; +} +.paywall-preview-inner { + position: relative; + max-height: 40vh; + overflow: hidden; + border-radius: 16rpx; + background: #f8fafc; + padding: 20rpx 20rpx 48rpx; + box-sizing: border-box; +} +.paywall-preview-text { + display: block; + font-size: 28rpx; + color: #334155; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} +.paywall-preview-fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 100rpx; + background: linear-gradient(to bottom, rgba(248, 250, 252, 0), rgba(248, 250, 252, 1)); + pointer-events: none; +} +.paywall-btn { + position: absolute; + left: 5%; + right: 5%; + width: 90%; + bottom: 132rpx; + z-index: 3; + padding: 20rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35); + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 12rpx; +} +.paywall-btn-main { + font-size: 30rpx; + color: #fff; + font-weight: 600; +} +.paywall-btn-price { + font-size: 24rpx; + color: #e0e7ff; +} +.paywall-btn--inline-profile { + position: relative !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + width: 100% !important; + margin-top: 28rpx; + z-index: 1; +} + +.result-share-footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + box-sizing: border-box; + width: 100%; + padding: 20rpx 32rpx; + padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); + background: #f5f5f5; + box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + gap: 16rpx; +} +.result-share-footer-btn { + width: 100% !important; + box-sizing: border-box; + display: block; + margin: 0; + padding: 0; + min-height: 96rpx; + line-height: 96rpx; + border-radius: 999rpx; + font-size: 30rpx; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + border: none; +} +.result-share-footer-btn::after { + border: none; +} +.result-share-footer-row2 .result-share-footer-btn { + flex: 1; + min-height: 88rpx; + line-height: 88rpx; +} + +.sub { + font-size: 26rpx; + color: #94a3b8; +} + +/* Header Card:渐变区随内容增高,避免省/科类标签落到白底上被「裁切」看不见 */ +.header-card { + position: relative; + background: #fff; + border-radius: 24rpx; + margin-bottom: 24rpx; + overflow: hidden; + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04); +} + +.header-hero { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + padding: 32rpx 32rpx 36rpx; +} + +.header-body { + padding: 24rpx 32rpx 32rpx; + background: #fff; +} + +.header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20rpx; +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: 36rpx; + font-weight: bold; + color: #fff; + margin-bottom: 16rpx; + line-height: 1.35; + text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +.user-tags { + display: flex; + flex-wrap: wrap; + gap: 12rpx; +} + +.tag { + font-size: 22rpx; + padding: 8rpx 18rpx; + line-height: 1.3; + border-radius: 100rpx; + font-weight: 500; +} + +.tag-province { + background: rgba(255, 255, 255, 0.2); + color: #fff; + border: 1rpx solid rgba(255, 255, 255, 0.4); +} + +.tag-stream { + background: rgba(255, 255, 255, 0.2); + color: #fff; + border: 1rpx solid rgba(255, 255, 255, 0.4); +} + +.score-box { + background: #fff; + padding: 16rpx 24rpx; + border-radius: 20rpx; + box-shadow: 0 8rpx 16rpx rgba(99, 102, 241, 0.15); + display: flex; + align-items: baseline; + flex-shrink: 0; +} + +.score-num { + font-size: 48rpx; + font-weight: 800; + color: #6366f1; + line-height: 1; +} + +.score-unit { + font-size: 24rpx; + color: #666; + margin-left: 4rpx; + font-weight: 500; +} + +.personality-tags { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + padding-top: 4rpx; + border-top: 1rpx dashed #e2e8f0; +} + +.p-tag { + display: flex; + flex-wrap: wrap; + align-items: stretch; + background: #f8fafc; + border-radius: 12rpx; + overflow: visible; + border: 1rpx solid #e2e8f0; + max-width: 100%; +} + +.p-label { + background: #e2e8f0; + color: #475569; + font-size: 20rpx; + font-weight: bold; + padding: 6rpx 12rpx; +} + +.p-value { + color: #334155; + font-size: 22rpx; + font-weight: 600; + padding: 6rpx 16rpx; + line-height: 1.45; + white-space: normal; + word-break: break-word; + max-width: 460rpx; + box-sizing: border-box; +} + +/* Common Card */ +.card { + background: #fff; + border-radius: 24rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03); +} + +.title-wrap { + display: flex; + align-items: center; + margin-bottom: 24rpx; +} + +.title-icon { + font-size: 32rpx; + margin-right: 12rpx; +} + +.title { + font-size: 32rpx; + font-weight: bold; + color: #1e293b; +} + +.content { + font-size: 28rpx; + color: #475569; + line-height: 1.7; + text-align: justify; +} + +/* Sub Counts (冲稳保) */ +.sub-counts { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-bottom: 24rpx; +} + +.count-tag { + font-size: 24rpx; + font-weight: 600; + padding: 8rpx 20rpx; + border-radius: 12rpx; +} + +.count-chong { + background: #fff1f2; + color: #e11d48; +} + +.count-wen { + background: #eff6ff; + color: #2563eb; +} + +.count-bao { + background: #f0fdf4; + color: #16a34a; +} + +.count-total { + background: #f1f5f9; + color: #475569; +} + +/* School List */ +.school-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.school-item { + background: #f8fafc; + border-radius: 16rpx; + padding: 24rpx; + border: 1rpx solid #f1f5f9; +} + +.school-header { + display: flex; + align-items: center; + margin-bottom: 12rpx; +} + +.school-band-tag { + font-size: 22rpx; + font-weight: bold; + padding: 4rpx 12rpx; + border-radius: 8rpx; + margin-right: 16rpx; + flex-shrink: 0; +} + +.band-chong { + background: #ffe4e6; + color: #e11d48; +} + +.band-wen { + background: #dbeafe; + color: #2563eb; +} + +.band-bao { + background: #dcfce7; + color: #16a34a; +} + +.band-none { + background: #e2e8f0; + color: #475569; +} + +.school-name { + font-size: 30rpx; + font-weight: bold; + color: #1e293b; + flex: 1; +} + +.school-meta-row { + display: flex; + flex-wrap: wrap; + gap: 24rpx; + margin-bottom: 16rpx; +} + +.meta-item { + font-size: 24rpx; + color: #64748b; + display: flex; + align-items: center; +} + +.meta-icon { + margin-right: 6rpx; + font-size: 24rpx; +} + +.school-reason { + font-size: 26rpx; + color: #475569; + line-height: 1.6; + background: #fff; + padding: 16rpx 20rpx; + border-radius: 12rpx; + position: relative; +} + +.quote-mark { + color: #cbd5e1; + font-size: 40rpx; + font-family: serif; + position: absolute; + top: 8rpx; + left: 12rpx; + line-height: 1; +} + +.school-reason { + padding-left: 40rpx; +} + +/* Major List */ +.major-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.major-item { + display: flex; + align-items: center; + background: #f8fafc; + padding: 20rpx 24rpx; + border-radius: 16rpx; +} + +.major-rank { + width: 44rpx; + height: 44rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + font-weight: bold; + margin-right: 20rpx; + flex-shrink: 0; +} + +.rank-1 { + background: #fef08a; + color: #b45309; +} + +.rank-2 { + background: #e2e8f0; + color: #475569; +} + +.rank-3 { + background: #ffedd5; + color: #9a3412; +} + +.rank-other { + background: #f1f5f9; + color: #64748b; +} + +.major-name { + font-size: 28rpx; + font-weight: 600; + color: #334155; + flex: 1; +} + +.major-score { + font-size: 24rpx; + color: #64748b; + background: #fff; + padding: 4rpx 12rpx; + border-radius: 100rpx; +} + +/* Disclaimer */ +.disclaimer-card { + background: #fef2f2; +} + +.disclaimer-card .title { + color: #9f1239; +} + +.disclaimer-text { + color: #be123c; + font-size: 24rpx; +} + +/* Empty State */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.empty-icon { + font-size: 100rpx; + margin-bottom: 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 30rpx; + color: #64748b; +} + +/* 综合分析加载(与人脸结果页同款结构,配色贴近高考页) */ +.analyzing-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(15, 23, 42, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.analyzing-content { + background-color: #fff; + border-radius: 24rpx; + padding: 80rpx 56rpx; + width: 560rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 16rpx 48rpx rgba(99, 102, 241, 0.2); +} + +.analyzing-icon { + width: 120rpx; + height: 120rpx; + margin-bottom: 40rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.analyzing-spinner { + width: 120rpx; + height: 120rpx; + border: 8rpx solid #e2e8f0; + border-top-color: #6366f1; + border-radius: 50%; + animation: gaokao-analyzing-spin 0.9s linear infinite; +} + +@keyframes gaokao-analyzing-spin { + to { + transform: rotate(360deg); + } +} + +.analyzing-title { + font-size: 36rpx; + font-weight: 600; + color: #1e293b; + margin-bottom: 16rpx; +} + +.analyzing-desc { + font-size: 28rpx; + color: #6366f1; + margin-bottom: 24rpx; + text-align: center; + font-weight: 500; +} + +.analyzing-bar { + width: 400rpx; + height: 12rpx; + background: #e2e8f0; + border-radius: 6rpx; + overflow: hidden; + margin-bottom: 24rpx; +} + +.analyzing-bar-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1, #a855f7); + border-radius: 6rpx; + transition: width 0.3s ease; +} + +.analyzing-hint { + font-size: 24rpx; + color: #94a3b8; + text-align: center; + line-height: 1.6; +} diff --git a/miniprogram/pages/history/index.js b/miniprogram/pages/history/index.js index dca58e6..492276a 100644 --- a/miniprogram/pages/history/index.js +++ b/miniprogram/pages/history/index.js @@ -141,15 +141,40 @@ Page({ }, formatList(rawList) { - const typeNames = { mbti: 'MBTI性格测试', sbti: 'SBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' } - const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' } + const typeNames = { + mbti: 'MBTI性格测试', + sbti: 'SBTI性格测试', + disc: 'DISC性格测试', + pdp: 'PDP行为偏好测试', + ai: '面相分析', + resume: '简历综合分析', + gaokao: '高考志愿' + } + const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋', gaokao: '🎓' } return rawList.map((item, idx) => { + const testType = String(item.testType || item.type || 'mbti').toLowerCase() + if (testType === 'gaokao') { + const rawBadge = String(item.resultText || '').trim() + const shortBadge = + rawBadge === '已生成' || rawBadge === '志愿报告' || rawBadge === '查看报告' + ? rawBadge + : '已生成' + return { + ...item, + type: 'gaokao', + testType: 'gaokao', + key: item.key || 'gaokao_' + (item.id || idx), + emoji: item.emoji || '🎓', + typeName: '高考志愿', + resultText: shortBadge, + enterpriseName: item.enterpriseName || '' + } + } if (item.typeName) { const t = String(item.testType || item.type || 'mbti').toLowerCase() return { ...item, type: t, enterpriseName: item.enterpriseName || '' } } - const testType = (item.testType || item.type || 'mbti').toLowerCase() const ts = item.createdAt || item.testTime || item.timestamp return { ...item, @@ -219,11 +244,12 @@ Page({ disc: '/pages/result/disc', pdp: '/pages/result/pdp', ai: '/pages/index/result', - resume: '/pages/result/resume' + resume: '/pages/result/resume', + gaokao: '/pages/gaokao/report' } const base = routes[type] if (!base) return - if ((type === 'ai' || type === 'resume') && !id) return + if ((type === 'ai' || type === 'resume' || type === 'gaokao') && !id) return const query = id ? `?id=${id}&type=${type}` : '' wx.navigateTo({ url: query ? base + query : base }) }, diff --git a/miniprogram/pages/history/index.wxss b/miniprogram/pages/history/index.wxss index a55b977..300e53e 100644 --- a/miniprogram/pages/history/index.wxss +++ b/miniprogram/pages/history/index.wxss @@ -116,6 +116,8 @@ .icon-pdp { background-color: #dbeafe; } .icon-disc { background-color: #dcfce7; } .icon-ai { background-color: #fce7f3; } +.icon-resume { background-color: #f1f5f9; } +.icon-gaokao { background: linear-gradient(135deg, #eef2ff, #e0e7ff); } .icon-emoji { font-size: 32rpx; @@ -148,6 +150,9 @@ padding: 4rpx 16rpx; border-radius: 20rpx; flex-shrink: 0; + max-width: 46%; + min-width: 0; + box-sizing: border-box; } .badge-mbti { background-color: #f3e8ff; } @@ -165,10 +170,17 @@ .badge-ai { background-color: #fce7f3; } .badge-ai .badge-text { color: #db2777; } +.badge-gaokao { background-color: #eef2ff; } +.badge-gaokao .badge-text { color: #4f46e5; } + .badge-text { font-size: 22rpx; font-weight: 500; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 100%; } .test-time-row { diff --git a/miniprogram/pages/test-select/index.js b/miniprogram/pages/test-select/index.js index fd99409..8635ea5 100644 --- a/miniprogram/pages/test-select/index.js +++ b/miniprogram/pages/test-select/index.js @@ -9,6 +9,8 @@ Page({ permSbti: true, permPdp: true, permDisc: true, + /** 高考志愿任务中心;企业关闭 gaokao 时隐藏 */ + permGaokao: true, /** AI 对话(神仙 AI);企业关闭 aiHub 时隐藏 */ permAiHub: true, /** 四类问卷入口均被企业权限关闭时提示 */ @@ -43,6 +45,7 @@ Page({ const permSbti = !p || p.sbti !== false const permPdp = !p || p.pdp !== false const permDisc = !p || p.disc !== false + const permGaokao = !p || p.gaokao !== false const permAiHub = (!p || p.aiHub !== false) && !hideAi this.setData({ permFace, @@ -50,8 +53,9 @@ Page({ permSbti, permPdp, permDisc, + permGaokao, permAiHub, - allTestsDisabled: p && !permMbti && !permSbti && !permPdp && !permDisc + allTestsDisabled: p && !permMbti && !permSbti && !permPdp && !permDisc && !permGaokao }) }, @@ -79,6 +83,17 @@ Page({ wx.navigateTo({ url: '/pages/test/disc' }) }, + goGaokaoHub() { + if (!this.data.permGaokao) { + wx.showToast({ title: '当前企业未开放高考志愿功能', icon: 'none' }) + return + } + try { + require('../../utils/analytics').track('tap_test_select_gaokao', {}) + } catch (e) {} + wx.navigateTo({ url: '/pages/gaokao/index' }) + }, + goAIChatInterpretation() { if (isAuditHideAiMode(getApp().globalData)) { wx.showToast({ title: '功能升级中', icon: 'none' }) diff --git a/miniprogram/pages/test-select/index.wxml b/miniprogram/pages/test-select/index.wxml index b35f92f..faf4a2c 100644 --- a/miniprogram/pages/test-select/index.wxml +++ b/miniprogram/pages/test-select/index.wxml @@ -1,8 +1,8 @@ - + 选择一项详细性格测试 - 问卷测评与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告 + 问卷测评、高考志愿规划与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告 @@ -49,6 +49,17 @@ + + + 🎓 + + + 高考志愿任务中心 + 完成 MBTI / PDP / DISC / 拍照面相与信息表单 · 购买报告后生成志愿分析 + + + + 💬 diff --git a/miniprogram/pages/test-select/index.wxss b/miniprogram/pages/test-select/index.wxss index 3c5869f..b5c75e3 100644 --- a/miniprogram/pages/test-select/index.wxss +++ b/miniprogram/pages/test-select/index.wxss @@ -90,6 +90,11 @@ background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(96, 165, 250, 0.18) 100%); } +.entry-icon-wrap.gaokao { + background: linear-gradient(135deg, rgba(79, 70, 229, 0.18) 0%, rgba(129, 140, 248, 0.22) 100%); + border: 1rpx solid rgba(99, 102, 241, 0.25); +} + .entry-icon-wrap.ai-chat { background: linear-gradient(135deg, rgba(124, 58, 237, 0.16) 0%, rgba(99, 102, 241, 0.2) 100%); } diff --git a/miniprogram/utils/gaokao.js b/miniprogram/utils/gaokao.js new file mode 100644 index 0000000..d4dce3d --- /dev/null +++ b/miniprogram/utils/gaokao.js @@ -0,0 +1,87 @@ +const { requestPromise } = require('./request') + +function gaokaoPricingQueryParts(extraParams) { + const app = getApp() + const gd = (app && app.globalData) || {} + const scope = + (extraParams && extraParams.pricingScope) || + (gd.appScope === 'enterprise' ? 'enterprise' : 'personal') + const parts = [`pricingScope=${encodeURIComponent(scope)}`] + try { + const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js') + const eid = getEnterpriseIdForApiPayload() + if (eid != null && Number(eid) > 0) { + parts.push(`enterpriseId=${encodeURIComponent(String(eid))}`) + } + } catch (e) {} + return parts +} + +function getTaskStatus(params = {}) { + const query = [] + if (params.referrerId) query.push(`referrerId=${encodeURIComponent(params.referrerId)}`) + if (params.channelCode) query.push(`channelCode=${encodeURIComponent(params.channelCode)}`) + if (params.scene) query.push(`scene=${encodeURIComponent(params.scene)}`) + gaokaoPricingQueryParts(params).forEach((p) => query.push(p)) + const qs = query.length ? `?${query.join('&')}` : '' + return requestPromise({ + url: `/api/gaokao/task-status${qs}`, + method: 'GET' + }).then((res) => (res.data && res.data.data) || {}) +} + +function getForm() { + return requestPromise({ + url: '/api/gaokao/form', + method: 'GET' + }).then((res) => (res.data && res.data.data) || {}) +} + +function saveForm(data) { + return requestPromise({ + url: '/api/gaokao/form', + method: 'POST', + data + }).then((res) => (res.data && res.data.data) || {}) +} + +function analyze(extra = {}) { + const app = getApp() + const gd = (app && app.globalData) || {} + const pricingScope = + (extra && extra.pricingScope) || (gd.appScope === 'enterprise' ? 'enterprise' : 'personal') + let enterpriseId = 0 + try { + const { getEnterpriseIdForApiPayload } = require('./enterpriseContext.js') + const eid = getEnterpriseIdForApiPayload() + if (eid != null && Number(eid) > 0) enterpriseId = Number(eid) + } catch (e) {} + + return requestPromise({ + url: '/api/gaokao/analyze', + method: 'POST', + data: Object.assign({}, extra, { pricingScope, enterpriseId }), + timeout: 120000 + }).then((res) => { + const body = res.data || {} + if (body.code !== 200) { + throw new Error(body.message || '分析失败') + } + return body.data || {} + }) +} + +function latestReport() { + return requestPromise({ + url: '/api/gaokao/report/my-latest', + method: 'GET' + }).then((res) => (res.data && res.data.data) || {}) +} + +module.exports = { + getTaskStatus, + getForm, + saveForm, + analyze, + latestReport +} diff --git a/miniprogram/utils/gaokaoJourneyState.js b/miniprogram/utils/gaokaoJourneyState.js new file mode 100644 index 0000000..0230ac9 --- /dev/null +++ b/miniprogram/utils/gaokaoJourneyState.js @@ -0,0 +1,63 @@ +/** + * 高考报告页「两步解锁」:看全文 → 分享朋友圈(不与 MBTI 的 mbti_journey_unlocks 混用) + * storage: gaokao_journey_ = { sharedMoment: ts } + */ + +function storageKey(testResultId) { + const id = + testResultId != null && String(testResultId) !== '' && String(testResultId) !== '0' + ? String(testResultId) + : '0' + return 'gaokao_journey_' + id +} + +function read(testResultId) { + try { + const v = wx.getStorageSync(storageKey(testResultId)) + if (v && typeof v === 'object') return v + } catch (e) {} + return {} +} + +function write(testResultId, obj) { + try { + wx.setStorageSync(storageKey(testResultId), obj || {}) + } catch (e) {} +} + +function isStep1Unlocked({ profileGate, payRequired, isPaid }) { + if (profileGate) return false + if (payRequired && !isPaid) return false + return true +} + +function markShared(testResultId) { + const v = read(testResultId) + v.sharedMoment = Date.now() + write(testResultId, v) +} + +/** + * @param {{ profileGate: boolean, payRequired: boolean, isPaid: boolean }} ctx + * @param {string|number} testResultId + * @returns {{ step1Unlocked: boolean, step2Unlocked: boolean, activeStep: number }} + */ +function computeJourney(ctx, testResultId) { + const s1 = isStep1Unlocked(ctx) + const s2 = s1 && !!read(testResultId).sharedMoment + let activeStep = 1 + if (!s1) activeStep = 1 + else if (!s2) activeStep = 2 + else activeStep = 0 + return { + step1Unlocked: s1, + step2Unlocked: s2, + activeStep + } +} + +module.exports = { + computeJourney, + markShared, + isStep1Unlocked +} diff --git a/miniprogram/utils/payment.js b/miniprogram/utils/payment.js index c845128..a5690db 100644 --- a/miniprogram/utils/payment.js +++ b/miniprogram/utils/payment.js @@ -36,7 +36,8 @@ function generateOrderId(productType) { single_test: 'TSGL', recharge: 'RCG', deep_personal: 'DPER', - deep_team: 'DTEAM' + deep_team: 'DTEAM', + gaokao: 'GAOKAO' } const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6) @@ -69,7 +70,18 @@ function generateOrderId(productType) { * @param {Function} options.fail - 失败回调 */ function wxPay(options) { - const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options + const { + orderId, + amount = 0, + description, + productType, + testResultId, + deepProductId, + enterpriseId, + pricingScope, + success, + fail + } = options if (app.globalData && app.globalData.miniprogramAuditMode) { const msg = '版本审核期间不可发起支付' @@ -116,14 +128,34 @@ function wxPay(options) { // 创建订单时将本次测试记录ID传给后端,避免每次都只更新“最新一条” testResultId: testResultId || 0, // 深度服务使用的具体套餐ID/产品Key(用于从 categories 中选择价格) - deepProductId: deepProductId || '' + deepProductId: deepProductId || '', + // 高考:与 Tab appScope 一致,后端按个人/企业档刷新 paidAmount + pricingScope: + productType === 'gaokao' + ? pricingScope || + ((app.globalData && app.globalData.appScope) || 'personal') + : pricingScope || '' }, success: (res) => { wx.hideLoading() if (res.statusCode === 200 && res.data.code === 200) { const paymentData = res.data.data - + + // 后端 0 元单(如高考志愿定价为 0):不调 wx.requestPayment,直接视为成功 + if (paymentData && paymentData.skipWxPay) { + wx.hideLoading() + try { + triggerOrderPaid(orderId) + } catch (e) {} + try { + require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount: 0, note: 'zero_skip_wx' }) + } catch (e) {} + wx.showToast({ title: '已解锁', icon: 'success', duration: 2000 }) + success && success({ skipWxPay: true, order: paymentData }) + return + } + // 2. 调起微信支付 wx.requestPayment({ timeStamp: paymentData.timeStamp, @@ -421,7 +453,7 @@ function purchaseByPricing(productType, description, extra, maybeFail) { opts = extra || {} } - const { testResultId, success, fail } = opts + const { testResultId, success, fail, pricingScope } = opts const orderId = generateOrderId(productType) wxPay({ @@ -431,6 +463,7 @@ function purchaseByPricing(productType, description, extra, maybeFail) { productType, testResultId, enterpriseId: enterpriseIdForOrder(), + pricingScope, success, fail }) @@ -499,6 +532,26 @@ function purchaseTeamAnalysis(success, fail) { purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail) } +/** 高考志愿分析报告(统一定价键 gaokao,走 orders + test_results) */ +function purchaseGaokaoReport(extra, maybeFail) { + let opts = {} + if (typeof extra === 'function' || extra === undefined) { + opts.success = extra + opts.fail = maybeFail + } else { + opts = extra || {} + } + const { success, fail, testResultId } = opts + const pricingScope = + (app.globalData && app.globalData.appScope) === 'enterprise' ? 'enterprise' : 'personal' + purchaseByPricing('gaokao', '高考志愿分析报告', { + success, + fail, + testResultId, + pricingScope + }) +} + /** * 企业充值 * 支持两种调用: @@ -692,6 +745,7 @@ module.exports = { purchaseResumeAnalysis, purchaseFullReport, purchaseTeamAnalysis, + purchaseGaokaoReport, recharge, purchasePersonalDeepService, purchaseTeamDeepService, diff --git a/开发文档/MBTI定价支付与分销逻辑.md b/开发文档/MBTI定价支付与分销逻辑.md new file mode 100644 index 0000000..2bcf0e5 --- /dev/null +++ b/开发文档/MBTI定价支付与分销逻辑.md @@ -0,0 +1,228 @@ +# 平台定价、用户付款与分销逻辑说明 + +> **说明**:文件名保留 `MBTI…` 仅为历史习惯;本文描述的是 **全平台同一套规则**,适用于 **MBTI、人脸(face)、PDP、DISC、SBTI、简历(resume)、高考(gaokao)** 等在 `Test::saveResult`、`Payment`、`TestProductPricing`、`Distribution`、`EnterpriseBillingService` 中有分支的测评/产品。差异主要在于:`pricing_config` / 管理后台里的 **JSON 键名**(如 `mbti`、`face`、`disc`…)、`test_results.testType`、以及个别白名单(例如企业余额入账 `creditEnterpriseBalanceForOrder` 不含 `gaokao`,见 §5)。 +> +> 下文在举例、防混淆时仍常以 **MBTI** 写具体字段名,换其他测评时把 **`mbti` 键 / `mbti` 产品类型** 换成对应键即可。 + +--- + +## 1. 项目相关位置(速查) + +| 层级 | 路径 / 说明 | +|------|----------------| +| 定价表 | `pricing_config`(模型 `app\model\PricingConfig`),`type` + `enterpriseId` 唯一 | +| 超管定价 API | `api/app/controller/superadmin/Pricing.php` | +| 企业管理定价 API | `api/app/controller/admin/Pricing.php` | +| 超管前端 | `admin/src/views/superadmin/Pricing.vue` | +| 企业管理员前端 | `admin/src/views/admin/Pricing.vue` | +| 提交测评 / 写 `test_results` | `api/app/controller/api/Test.php`(`saveResult` 等) | +| 统一下单与回调 | `api/app/controller/api/Payment.php` | +| 订单金额计算 | `Payment::calculateAmount` → `app\common\service\TestProductPricing::amountFenForTestProduct` | +| 分销 | `api/app/controller/api/Distribution.php`(`settleCommission`、`settleTestCommission`) | +| 企业平台费(与用户付款分列) | `api/app/common/service/EnterpriseBillingService.php` | + +--- + +## 2. `pricing_config` 中的几类「价格」 + +配置存在 JSON 字段 `config` 里,单项测评单价单位为 **元**(代码里会 `×100` 转为 **分** 落库/支付)。常见键名与 `testType` / 支付 `productType` 对齐,例如:**`mbti`、`face`、`disc`、`pdp`、`sbti`**;企业版里还可能有 **`report`、`teamAnalysis`**(团队分析)等扩展字段,以管理端表单与 `Test.php` 解析为准。 + +### 2.1 超管后台(全局默认) + +| `type` | `enterpriseId` | 含义 | +|--------|------------------|------| +| `personal` | `NULL` | **全局个人版**默认价:无企业管理端覆盖、且用户走个人链路时使用 | +| `enterprise` | `NULL` | **全局企业版**兜底价(用户走「企业档」计价、且该企业未配 `admin_enterprise` 时用);**同一行 JSON 里各测评键(`mbti`、`face`、`disc`…)也会被 `EnterpriseBillingService` 当作对应类型的「企业平台费」单价**(测评提交成功且 `test_results` 带企业归属时从企业余额扣,与用户实付分列) | +| `enterprise` | 某企业 ID | (可选)超管可为单个企业单独配一条企业版价(仍走 `getByTypeAndEnterprise('enterprise', eid)`,**平台费仍读全局 enterprise 行**,见 §7) | + +接口:`GET/PUT /superadmin/pricing`(见 `superadmin/Pricing.php`)。 + +### 2.2 企业管理后台(企业专属) + +仅 **`enterprise_admin`** 会解析出 `enterpriseId`;角色为普通 `admin` 且无企业绑定时,`enterpriseId` 为 `null`,此时个人版配置落在 **`admin_personal` + `enterpriseId = NULL`** 行(与「平台运营」共用一套逻辑,见 `admin/Pricing::resolveEnterpriseId`)。 + +| `type` | `enterpriseId` | 含义 | +|--------|------------------|------| +| `admin_personal` | 企业 ID 或 NULL | **企业侧「个人版」价**:用户绑定该企业、且走 **个人档** 定价时优先读取(见 `PricingConfig::getByTypeAndEnterprise('personal', eid)`) | +| `admin_enterprise` | 企业 ID | **企业侧「企业版」价**:订单/测评上下文带 **企业测试** 时用(见下文章节 4、5) | + +接口:`GET/PUT /api/v1/admin/pricing`(见 `admin/Pricing.php`)。 + +### 2.3 读取优先级(与模型注释一致) + +- **个人档 `personal`**:`admin_personal`(有 eid 则带 eid)→ 否则 `personal` 全局。实现见 `PricingConfig::getByTypeAndEnterprise`。 +- **企业档 `enterprise`**:`admin_enterprise` + eid → 否则 `enterprise` 全局。 + +--- + +## 3. 测评提交时:是否需要付费、应付多少分 + +在 **`Test::saveResult`**(提交问卷 / 人脸等结果)中,对允许的 **`testType`**(如 `mbti`、`face`、`disc`、`pdp`;`ai` 入口通常按人脸处理等,以接口校验为准): + +1. 根据请求是否带 **`enterpriseId`**、以及用户 **`wechat_users.enterpriseId`**,解析出 `pricingEnterpriseId` / `writeEnterpriseId`(与 `Payment::calculateAmount` 注释一致:企业分享带 eid、个人测可仍有绑定企业用于定价)。 +2. **`getRequiresPaymentByTestType($testType, …)`**:若解析到的配置里 **该类型对应键**(如 `mbti`、`face`…)单价 **> 0**,则 `requiresPayment = 1`。 +3. **`getStandardAmountFenByTestType`**:把配置里的 **元** 转为 **分**,写入 `test_results.paidAmount`(表示「应付标准价」)。 + +定价配置来源 **`Test::resolvePricingConfig`**(与 `PricingConfig` 一致): + +- 若本次是 **企业测试**(`enterpriseId` 非空)→ 用 **`enterprise`** 档 → `admin_enterprise` / 全局 `enterprise`。 +- 否则若用户 **绑定企业** → 用 **`personal`** 档 + 该企业 → `admin_personal` / 全局 `personal`。 +- 否则 → 全局 **`personal`**。 + +代码:`api/app/controller/api/Test.php` 中 `getRequiresPaymentByTestType`、`getStandardAmountFenByTestType`、`resolvePricingConfig` 及 `saveResult` 内 `insertGetId`。 + +--- + +## 4. 用户发起微信支付:`Payment::create` + +### 4.1 金额从哪里来 + +1. **优先固定价**:若传入 **`testResultId`**(或未传但自动绑定到最近一条同 `productType` 的 `test_results`),且该记录 **`paidAmount > 0`**,则订单金额 **`fixedAmountFen = paidAmount`**,不再重新算价(与提交测评时写入的标准价一致)。 +2. **否则**:调用 **`calculateAmount`**,对测试类产品走 **`TestProductPricing::amountFenForTestProduct`**。 + +### 4.2 `calculateAmount` 与「个人 / 企业」两档 + +在 `Payment::calculateAmount` 中: + +- 若推断出的 **`$enterpriseId` 非空**(来自 `test_results.enterpriseId` 或请求参数 `enterpriseId`)→ **`pricingType = 'enterprise'`** → 读 **企业版** 配置。 +- 否则 → **`pricingType = 'personal'`**;若用户绑定了企业,会把 **`pricingEnterpriseId`** 设为该用户 `wechat_users.enterpriseId`,用于读 **`admin_personal`**。 + +第五参数传入 `TestProductPricing::amountFenForTestProduct(..., $pricingType)`,避免「只因为有企业 ID 就误用企业版价」的问题(高考 `gaokao` 等场景曾修复过同类逻辑;各 `productType` 与此共用 `TestProductPricing`)。 + +代码:`api/app/controller/api/Payment.php` 的 `create`、`calculateAmount`;`api/app/common/service/TestProductPricing.php`。 + +### 4.3 订单与测评绑定 + +创建 **`orders`** 后,若存在 `test_results`,会把 **`test_results.orderId`** 更新为当前订单 id,便于支付回调按订单反查测评类型、做分销。 + +--- + +## 5. 支付成功之后 + +在 **`Payment::notify`** / **`Payment::query`** 确认支付成功后(非 `recharge`): + +1. **`test_results`**:`isPaid = 1`,写入 `paidAmount`、`paidAt`(与微信 `total_fee` 或本地订单金额一致)。 +2. **`creditEnterpriseBalanceForOrder`**:若订单带 **`enterpriseId`** 且 `productType` 属于 `face|mbti|sbti|disc|pdp|resume|recharge`,把 **用户实付金额** 记一条 **`finance_records`(type=`recharge` 命名历史原因)**,**增加** 该企业 `enterprises.balance`(注释:企业测试收入)。 +3. **`Distribution::settleCommission($orderId)`**:按分销规则从 **企业余额**(有企业订单时)或平台侧给推荐人结算佣金(见下一节)。 + +注意:**`gaokao`** 不在 `creditEnterpriseBalanceForOrder` 的白名单内;高考另有业务处理。 + +--- + +## 6. 分销(与各测评订单 / 测完事件相关) + +### 6.1 订单支付佣金:`settleCommission` + +- **触发**:测试类订单(`productType` 与 `testType` 映射一致,如 **`mbti`、`face`、`disc`、`pdp`、`sbti`、`resume`、`gaokao`** 等)支付成功,`commission_records` 按 **`orderId` 防重**。 +- **绑定查找**:根据订单的 `enterpriseId` 决定 `scope`(`enterprise` / `personal`),在 **`distribution_bindings`** 中匹配邀请关系;企业单未命中时可 **回退** 到 personal 绑定(代码注释:扣款企业仍以订单为准)。 +- **佣金数值**:从 **`getTestCommissionConfig($testType, $scope, $configEnterpriseId)`** 读取: + - 优先 **`distribution` 配置里的 `testSettings[$testType]`**(如 `testSettings.mbti`、`testSettings.face`…里的 `enabled`、比例或固定分); + - 否则回退全局 **`commissionRate` / `commissionAmountFen`**。 +- **企业订单**:优先从 **`billingEnterpriseId = 订单 enterpriseId`** 的余额扣佣金;不足则记入推荐人 **`walletPending`(冻结)**;无企业则平台直接给推荐人钱包入账。 + +代码:`Distribution::settleCommission` 及 `getTestCommissionConfig` / `resolveTestSetting`。 + +### 6.2 测评完成佣金(免单 / 未付款场景):`settleTestCommission` + +- **触发**:`Test::saveResult` 在写入 `test_results` 成功后调用(与各问卷 / 人脸提交同源)。 +- **适用类型**:代码里 **`$allowedTypes = ['face','mbti','sbti','disc','pdp']`**(`ai` 会归一成 `face`),需 **`testSettings` 里开启 `noPayment`** 等条件;金额来自分销配置。 +- **与订单佣金区别**:无 `orderId`,防重维度为 **`testResultId` + `commissionSource = test_completion`**;仍可能从企业余额扣款给推荐人。 + +代码:`Distribution::settleTestCommission`。 + +--- + +## 7. 企业平台费(与用户付款、分销并列的另一条线) + +**`EnterpriseBillingService::chargePlatformFeeForTestResult`** + +- **时机**:`Test::saveResult` 成功插入 `test_results` 后(各 `testType`),与是否已微信支付 **无关**。 +- **金额**:读 **超管全局** `PricingConfig` 中 **`type = enterprise` 且 `enterpriseId = NULL`** 的 JSON 里 **各测评键** 的 **平台单价(元)→ 分**;描述里带 `testResultId` **幂等**。 +- **作用**:从 **`enterprises.balance` 扣减**(`finance_records` type=`consume`),表示 **平台向企业收的单次测评费**;与用户微信实付、分销佣金是 **不同科目**。 + +代码:`api/app/common/service/EnterpriseBillingService.php`,调用处 `api/app/controller/api/Test.php`(`saveResult` 内)。 + +--- + +## 8. 心智模型小结(避免混淆) + +| 概念 | 谁配置 | 影响什么 | +|------|--------|----------| +| 个人版价 / 企业版价(用户侧售价) | 超管 `personal`/`enterprise` + 企业 `admin_personal`/`admin_enterprise` | `requiresPayment`、`paidAmount`、微信支付金额 | +| 分销佣金比例或固定分 | 分销配置 **`testSettings` 下按类型分块**(如 `mbti`、`face`、`disc`…) | 支付成功后 `settleCommission`;免单完成时 `settleTestCommission` | +| 企业平台费单价 | 超管 **全局 enterprise**(`enterpriseId = NULL`)配置里 **与各测评键同名** 的单价 | 测评提交成功即从 **企业余额** 扣平台费(`test_results` 写入企业归属时);**与上表「企业版用户价」共用同一配置源,账目语义不同** | +| 企业余额「加一笔」 | 用户支付成功且订单有 `enterpriseId` | `creditEnterpriseBalanceForOrder`:用户实付进企业余额(测试类产品白名单) | + +--- + +## 9. 业务场景示例(与你描述的逻辑对齐) + +以下以 **MBTI**(配置键 **`mbti`**)举例;**人脸 / PDP / DISC / SBTI** 等仅把键名与 `testType` 替换即可,**规则相同**。数值均为说明用。约定: + +- **超管**:`personal` 全局 `mbti = 1` 元;`enterprise` 全局 `mbti = 2` 元(该 2 元在代码里同时作为 **企业版用户价兜底** 与 **MBTI 平台费单价** 的数据来源,见 §2.1、§7)。 +- **A 企业管理员**:`admin_personal` 里 `mbti = 0.5` 元;`admin_enterprise` 里 `mbti = 0` 元。 + +### 9.1 小王(已绑定 A 企业)— 做「个人版」链路测评 + +- **用户侧应付**:走 **`personal` 档** → 优先 A 的 **`admin_personal`** → **0.5 元**(写入 `test_results.paidAmount` / 微信支付按此,除非另有逻辑)。 +- **企业被平台扣费(平台费)**:测评提交写库成功后,`EnterpriseBillingService` 按超管全局 **`enterprise.mbti = 2` 元** 从 **A 企业余额** 扣 **2 元**(`finance_records` consume,幂等按 `testResultId`)。 +- **企业「测试收入」**:用户付完款后,`creditEnterpriseBalanceForOrder` 把 **用户实付 0.5 元** 记入 A 企业余额(与平台费不同科目)。你描述的 **「企业收入 0.5」** 指这笔 **用户实付入账**;**「企业需要扣费 2 元」** 指 **平台费** 另扣。 + +### 9.2 小张(已绑定 A 企业)— 做「企业版」链路测评 + +- **用户侧应付**:走 **`enterprise` 档** → 优先 A 的 **`admin_enterprise`** → **0 元**(可能 `requiresPayment = 0`,无需微信付)。 +- **企业平台费**:同上,提交测评成功后仍按全局 **`enterprise.mbti = 2` 元** 从 A 余额扣 **2 元**(只要写入了企业归属的 `test_results` 且平台费单价大于 0)。 +- **企业测试收入**:用户实付 **0** → **`creditEnterpriseBalanceForOrder` 入账 0**。你描述的 **「企业收入 0」** 即无用户实付进账。 + +### 9.3 小李(未绑定任何企业)— 仅个人版 + +- **用户侧应付**:无企业 → **`personal` 全局** → **1 元**(超管个人版默认)。 +- **企业平台费 / 企业测试收入**:无 `test_results.enterpriseId`(或企业 ID 为 0)时,**不按企业扣平台费**(`EnterpriseBillingService` 直接 return);也无企业余额入账。 + +--- + +## 10. 前端管理端对应关系 + +| 后台 | 页面 | 保存到后端的含义(简写) | +|------|------|---------------------------| +| 超级管理 | `admin/src/views/superadmin/Pricing.vue` | 全局 `personal` / `enterprise`(及 deep 等) | +| 企业管理 | `admin/src/views/admin/Pricing.vue` | `admin_personal`、(企业管理员)`admin_enterprise` | + +--- + +## 11. 分销两种结佣(对话整理) + +管理后台里 **各测评类型**(MBTI、人脸、PDP、DISC、SBTI…)在分销配置中均可单独一块 **`testSettings[testType]`**:可配 **佣金类型(比例 / 固定金额)**、**固定金额(元)**,以及 **「无需付款触发」**(用户完成测试即发放佣金,无需付款)。对应代码里两条独立链路如下(与具体是哪一种测评无关,仅 `testType` 不同)。 + +### 11.1 两种情况对照 + +| 情况 | 名称(口语) | 什么时候结佣 | 主要代码入口 | +|------|----------------|-------------|----------------| +| **①** | **跟单结佣**(有微信支付) | 用户 **订单已支付**(微信回调 / 查询确认) | `Distribution::settleCommission(orderId)`,由 `Payment` 支付成功后调用 | +| **②** | **测完结佣**(可不依赖付款) | 用户 **测评结果已提交落库**(`Test::saveResult` 成功),且 `testSettings` 中 **`enabled` + `noPayment`** 等条件满足 | `Distribution::settleTestCommission(testResultId, …)`,由 `Test::saveResult` 内调用 | + +**「无需付款触发」** 打开时,对应 **②**:不要求用户先微信付款,只要 **测完** 且邀请绑定、佣金规则满足,即可尝试结佣。 + +### 11.2 对话摘要 + +**运营**:两种结佣怎么区分? + +**产品**:**①** 永远跟 **微信已付订单** 走;**②** 跟 **测评提交成功** 走,和是否付款脱钩(故叫「无需付款触发」)。 + +**运营**:小王付 0.5 元、小张 0 元,和 ①② 怎么叠? + +**产品**(与 §9 场景对齐,实际仍受绑定、`enabled`、防重等约束): + +| 用户 | 用户侧 | ① 跟单结佣 | ② 测完结佣 | +|------|--------|------------|------------| +| 小王 | 个人链路付 0.5 元 | 付完款可触发:订单金额参与 **比例**;或按配置 **固定分** | 若打开「无需付款触发」:**测完** 也可能触发 **②**(防重用 `testResultId`,与订单防重不同) | +| 小张 | 企业链路 0 元 | 订单金额为 0:**比例佣金为 0**;若 ① 为 **固定金额且大于 0** 仍可能有一笔(看配置) | **测完** 若满足 `noPayment`:**② 仍可发**(适合「用户没付钱也要给邀请人记一笔」) | +| 小李 | 未绑企业、个人 1 元 | 通常以 **①** 为主(有付款才有订单) | **②** 若绑定/企业上下文不满足则可能不触发 | + +**运营**:两种会重复拿两次吗? + +**产品**:**不是同一笔账**:① 按 **`orderId`** 防重;② 按 **`testResultId` + `commissionSource = test_completion`** 防重。若业务上 **同时** 依赖跟单与测完,可能出现 **两笔不同记录**,是否都要由运营在后台把开关与金额想清楚。 + +--- + +*文档根据当前仓库代码整理;若路由前缀以实际 `api/route` 部署为准。* diff --git a/高考志愿MBTI开发文档.md b/高考志愿MBTI开发文档.md new file mode 100644 index 0000000..df22b4c --- /dev/null +++ b/高考志愿MBTI开发文档.md @@ -0,0 +1,577 @@ +# 高考志愿 MBTI 功能开发文档(V1) + +## 1. 文档目标 + +基于 `测高考.txt` 的会议纪要,整理一版可直接执行的开发文档,覆盖: + +- 业务目标与范围 +- 前后端功能拆解 +- AI 分析输入输出规范 +- 提示词与规则(AI 可直接理解) +- 里程碑与验收标准 + +--- + +## 2. 背景与业务目标 + +高考季上线「高考志愿分析」专栏,结合考生的性格测评与分数信息,输出可读、可执行、可追溯的志愿建议,支持后续与教育机构合作场景(客资沉淀、加微信、入群转化)。 + +核心目标: + +1. 给考生与家长一个“先有方向”的初版建议(不是最终填报结论)。 +2. 让机构老师可基于报告继续人工精细化服务。 +3. 保持与现有团队版产品架构一致,支持按公司配置切换首页主入口。 + +--- + +## 3. 范围定义 + +## 3.1 本期(V1)要做 + +- 新增**独立高考志愿入口**(不是复用原团队入口),首页可配置为高考版优先。 +- 点击该入口后进入「高考任务中心页」,集中展示: + - MBTI 测试入口 + - PDP 测试入口 + - DISC 测试入口 + - 高考信息表单入口 + - 综合分析按钮 +- 收集基础信息:姓名、地区、科类/选科、估分/模拟分(可选)、志愿草表(可选文本或图片 OCR)。 +- 复用现有性格测评体系,取结果做综合分析: + - MBTI(必用) + - PDP(用) + - DISC(用) + - 面相分析(如已有能力可融合) +- 生成综合报告:学校梯度(冲/稳/保)+ 专业匹配 + 风险提示 + 下一步动作。 +- 支持机构合作链路:报告后提示加微信/进群(已有能力对接)。 + +## 3.2 本期不做 + +- 直接替代人工老师完成最终志愿填报。 +- 复杂到省级全量规则引擎(如超细颗粒组合政策全自动推演)。 +- 多轮深度问答式咨询(先提供单次报告)。 + +--- + +## 4. 用户流程(V1) + +1. 用户点击首页「高考志愿」新入口,进入任务中心页。 +2. 任务中心展示 4 个任务入口(MBTI、PDP、DISC、高考信息表单)+ 1 个综合分析按钮。 +3. 用户按任意顺序完成测试与表单。 +4. 任务中心实时显示每个任务状态: + - 未完成:显示“未完成/去测试”提示; + - 已完成:显示“已完成/查看结果”并可点击查看已有结果。 +5. 仅当 MBTI、PDP、DISC 和表单全部完成时,才可点击「AI 综合分析」。 +6. 未完成前,综合分析按钮保持灰色禁用状态,并提示缺失项(如“请先完成 DISC 与高考信息表单”)。 +7. 全部完成后按钮点亮,点击后进入 AI 分析流程。 +8. AI 先联网检索当年权威数据,再生成 JSON 报告并渲染结果页。 +9. 报告末尾引导加微信/进群(机构合作链路)。 + +--- + +## 5. 产品与交互要求 + +## 5.1 首页与入口 + +- 支持“固定首页”能力:按租户/公司配置,首页主入口可切为高考版或团队版。 +- 支持可见性配置:个人版入口可显示或隐藏。 +- 高考志愿必须为单独入口,命名建议:`高考志愿分析`(可配置文案)。 + +## 5.2 表单字段(建议) + +必填: + +- 姓名 +- 省份 +- 科类(新高考省份可改为选科组合) +- 至少一个有效测评结果(MBTI 必须有) + +选填(V1 建议都可选,避免流失): + +- 估分/模拟成绩 +- 语数英及综合分项 +- 志愿草表文本 +- 志愿草表图片 OCR +- 意向地区 +- 意向专业大类 + +## 5.3 任务中心状态与按钮规则(新增) + +任务中心卡片状态定义: + +- `todo`(未完成):文案“去完成”,卡片右侧显示提示点。 +- `done`(已完成):文案“查看结果”,卡片右侧显示完成标识。 + +综合分析按钮状态: + +- 默认灰色禁用:存在任一 `todo` 时保持禁用; +- 激活可点击:4 个任务全部 `done` 后点亮; +- 点击禁用按钮时需给出明确提示:列出未完成项名称,不可只提示“请先完成”。 + +任务入口点击行为: + +- 未完成任务:进入对应测试/表单页面; +- 已完成任务:进入对应结果页或回填编辑页(表单可编辑再保存); +- 测试结果以“最近一次有效提交”为准。 + +## 5.4 分析结果页 + +必须包含: + +- 总评(120~200 字) +- 性格画像摘要 +- 分数定位(区间表达) +- 学校推荐:冲/稳/保(每档建议 3 所) +- 专业推荐(建议 5 个) +- 志愿草表点评(若用户提供) +- 风险与免责声明 +- 后续行动建议(学生 3 条 + 家长 3 条) + +## 5.5 全局定价(新增) + +高考志愿功能需纳入全局定价体系,支持按租户统一配置,不在页面写死价格。 + +- 定价对象: + - `gaokao_single_report`(单次综合分析) + - `gaokao_package_basic`(基础包,可含测评 + 1 次综合分析) + - `gaokao_package_pro`(进阶包,可含报告解读服务) +- 定价维度: + - 原价、活动价、渠道价(可选) + - 生效时间、失效时间 + - 适用租户(平台默认/企业覆盖) +- 价格展示规则: + - 任务中心和支付确认页展示同一价格源; + - 若有活动价,显示“划线原价 + 活动价”; + - 若未配置价格,不允许下单并给出配置缺失提示。 + +## 5.6 分销能力(新增) + +高考志愿功能接入分销体系,支持渠道推广和佣金结算。 + +- 分销基础: + - 支持分享入口(海报/链接)携带 `referrerId`; + - 用户首次进入高考入口时绑定分销关系(按现有平台规则)。 +- 佣金规则: + - 支持按商品维度配置佣金比例或固定金额; + - 支持一级分销(V1 必做),多级分销后续迭代; + - 退款后佣金自动冲正。 +- 订单归因: + - 高考相关订单需记录来源渠道、分销员、归因时间; + - 用户管理后台可按分销员查看转化人数、支付金额、佣金金额。 + +## 5.7 用户管理展示(新增) + +用户管理后台需新增“高考志愿”视图与字段,便于机构跟进。 + +- 列表新增字段: + - 高考入口状态(未进入/进行中/已完成) + - 测试完成状态(MBTI、PDP、DISC) + - 表单完成状态 + - 综合分析状态(未生成/已生成/生成失败) + - 最近分析时间 + - 报告摘要标签(如“工科倾向”“省内优先”) +- 详情页新增模块: + - 最近一次综合分析结果(可查看摘要和完整 JSON) + - 推荐学校/专业快照 + - 风险提示与免责声明 + - 分销归因与订单记录(来源、成交、佣金) +- 筛选与导出: + - 支持按“是否完成综合分析”“是否付费”“分销员”筛选; + - 支持导出高考用户跟进清单(CSV/Excel)。 + +--- + +## 6. AI 生成规则(必须执行) + +本节是给模型与后端共同遵守的“硬规则”。 + +1. 命中率只能给区间(如 `10-25%`),不能给点估。 +2. 严禁“保录”“稳上”“一定能进”。 +3. 所有学校/专业推荐都要有理由,至少包含:分数梯度、性格匹配、地域或就业其一。 +4. MBTI 解释至少覆盖四维中的 3 维:I/E、N/S、T/F、J/P。 +5. 若有志愿草表,点评需引用用户原文不少于 3 条。 +6. 输出必须是严格 JSON(不能返回 Markdown 包裹)。 +7. 缺失信息必须留空(空字符串或空数组),不能编造。 +8. 先检索再结论:每次调用至少执行 5 组当年检索。 +9. 每个关键结论都要附 `sources`(标题、URL、时间、摘要)。 +10. 检索失败时必须显式降级,降低置信区间并写明“仅供方向参考”。 + +--- + +## 7. AI 可直接使用的提示词模板 + +以下模板可直接放入后端 `system` / `user` 消息中,已做工程化约束。 + +## 7.1 System Prompt(精简可执行版) + +```text +你是高考志愿AI分析师。你的任务是结合考生测评结果、分数信息和志愿草表,输出一份严格JSON格式的志愿建议。 + +硬性规则: +1) 任何录取概率都使用区间,如10-25%,禁止点估; +2) 禁止“保录取/一定录取/稳上”等承诺语; +3) 学校与专业推荐必须说明理由(分数梯度+性格匹配+就业/地域); +4) MBTI解释至少覆盖I/E、N/S、T/F、J/P中的3个维度; +5) 若用户提供志愿草表,wishReview.evidence至少3条用户原文; +6) 输出必须是严格JSON,不要Markdown、不要注释; +7) 缺失数据留空,禁止编造; +8) 生成结论前必须联网检索当年高考/招生/专业/就业数据,至少5组query; +9) 关键结论必须带sources(title,url,publishedAt,snippet); +10) 检索失败时,必须降级:schoolRecommend置空或降档,并在sources中写note说明。 + +语气要求:理性、克制、有温度;结论是“建议”而非“承诺”。 +``` + +## 7.2 User Prompt(模板) + +```text +请基于以下考生数据生成高考志愿分析: + +【性格测评】 +MBTI: {{mbti}} +PDP: {{pdp}} +DISC: {{disc}} +面相分析: {{faceText}} + +【基础信息】 +姓名: {{name}} +省份: {{province}} +科类/选科: {{streamOrSubjects}} +目标层次: {{targetTier}} +意向地区: {{preferredRegions}} +意向专业: {{preferredFields}} + +【成绩信息】 +估分: {{estimatedScore}} +分数文本: {{scoreText}} +分数OCR: {{scoreImagesOcr}} + +【志愿草表】 +文本: {{wishListText}} +OCR: {{wishListOcr}} + +请严格输出JSON,结构必须完整,缺失字段用空值,不要编造数据。 +``` + +--- + +## 8. 输出 JSON 结构(建议标准) + +```json +{ + "overview": "", + "personalityProfile": { + "mbti": "", + "pdp": "", + "disc": "", + "face": "" + }, + "scoreProfile": { + "estimated": null, + "tierFit": "", + "percentileGuess": "", + "sources": [] + }, + "schoolRecommend": { + "chong": [], + "wen": [], + "bao": [] + }, + "majorRecommend": [], + "wishReview": { + "strengths": [], + "risks": [], + "rebalance": [], + "evidence": [] + }, + "personalityReason": "", + "nextSteps": [], + "disclaimers": "", + "searchMeta": { + "queryCount": 0, + "queries": [], + "fetchedAt": "", + "coverage": "full" + } +} +``` + +--- + +## 9. 检索策略(后端/Agent) + +每次分析必须新检索,不复用历史。 + +最低 5 组 query(示例): + +1. `{{year}} {{province}} 高考 一分一段 投档线` +2. `{{schoolName}} {{province}} 近三年 投档线 位次` +3. `{{majorName}} 就业率 学科评估 {{year}}` +4. `{{subjects}} 选科要求 专业目录 {{year}}` +5. `{{targetTier}} {{preferredRegions}} 大学名单 {{year}}` + +信源优先级: + +- 高:`gov.cn`、`edu.cn`、阳光高考、学校招生网 +- 中:主流媒体教育频道、官方报告 +- 低:聚合站/转载站(仅兜底,不作为主依据) + +--- + +## 10. 接口与工程建议 + +## 10.1 后端接口(建议) + +- `POST /api/gaokao/analyze` + - 入参:表单 + 测评结果 + 可选 OCR 文本 + - 出参:标准 JSON 报告 +- `GET /api/gaokao/pricing` + - 入参:租户、渠道、用户身份 + - 出参:高考功能可售商品与当前生效价格 +- `POST /api/gaokao/order/create` + - 入参:商品、支付方式、分销归因参数 + - 出参:订单信息与支付参数 +- `GET /api/admin/gaokao/users` + - 入参:状态筛选(分析状态/付费状态/分销员) + - 出参:高考用户列表及任务完成状态 +- `GET /api/admin/gaokao/users/{id}` + - 出参:用户高考档案、报告摘要、分销与订单信息 + +## 10.2 关键工程约束 + +- 开启模型工具调用(`web_search` 或自建搜索代理)。 +- 强制 `response_format = json_object`(按服务商能力适配)。 +- 返回前执行结构校验与规则校验,不通过则重试或降级。 +- 定价与分销统一走平台全局配置中心,禁止各端写死。 +- 价格读取、下单、支付回调、佣金结算使用同一商品编码,避免账务不一致。 + +--- + +## 11. 验收标准(UAT) + +功能验收: + +- 能完整跑通“新入口 -> 任务中心 -> 测试/表单 -> 分析 -> 报告 -> 加微信引导”链路。 +- 在信息不全场景下仍能返回可读报告,不报错。 +- 任务中心状态正确:未完成任务有提示,已完成任务可查看结果。 +- 综合分析按钮规则正确:未完成时灰色禁用,全部完成后可点击。 +- 全局定价生效正确:不同租户/渠道命中对应价格,前后端展示一致。 +- 分销归因正确:分享进入、下单、退款、佣金冲正链路可核对。 +- 用户管理可见高考结果:列表与详情可查看任务状态和分析结果摘要。 + +质量验收: + +- JSON 100% 可解析。 +- 检索覆盖:`searchMeta.queryCount >= 5`(或 `coverage=none` 且走降级)。 +- 概率全部为区间,且无承诺词。 +- 学校与专业条目均可追溯到来源。 + +业务验收: + +- 报告可供机构老师二次解读,不与人工流程冲突。 +- 能体现“初版可用,后续可迭代”。 + +--- + +## 12. 版本与迭代建议 + +- V1:先上线可用版(本文件范围)。 +- V1.1:补省份规则细化(3+1+2 / 3+3 全量映射)。 +- V1.2:加入“按机构策略模板”输出(不同机构不同话术与重点)。 + +--- + +## 13. 数据库表结构草案(可直接给研发) + +以下为 V1 建议的最小可用表结构,命名可按现有项目规范调整。 + +## 13.1 高考商品定价表 `gaokao_pricing` + +用途:高考相关商品的全局定价配置(支持租户覆盖、时间生效)。 + +核心字段: + +- `id` bigint PK +- `tenant_id` bigint,租户 ID(`0` 表示平台默认) +- `product_code` varchar(64),如 `gaokao_single_report` +- `product_name` varchar(100) +- `price_original` decimal(10,2) +- `price_sale` decimal(10,2) +- `price_channel` decimal(10,2) NULL +- `currency` varchar(16) DEFAULT `CNY` +- `status` tinyint(0=停用,1=启用) +- `effective_at` datetime +- `expired_at` datetime NULL +- `extra_json` json NULL(活动标签、展示文案等) +- `created_at` datetime +- `updated_at` datetime + +索引建议: + +- `idx_tenant_product_status` (`tenant_id`, `product_code`, `status`) +- `idx_effective_time` (`effective_at`, `expired_at`) +- 唯一约束(可选):同租户同商品同时间段不允许重叠生效 + +## 13.2 高考用户档案表 `gaokao_user_profile` + +用途:存储用户在高考功能内的进度状态、表单信息和最新报告指针。 + +核心字段: + +- `id` bigint PK +- `user_id` bigint UNIQUE +- `tenant_id` bigint +- `entry_status` tinyint(0=未进入,1=进行中,2=已完成) +- `mbti_status` tinyint(0=未测,1=已测) +- `pdp_status` tinyint(0=未测,1=已测) +- `disc_status` tinyint(0=未测,1=已测) +- `form_status` tinyint(0=未填,1=已填) +- `analyze_status` tinyint(0=未生成,1=已生成,2=失败) +- `last_analyze_at` datetime NULL +- `latest_report_id` bigint NULL(关联报告表) +- `name` varchar(64) +- `province` varchar(32) +- `stream_or_subjects` varchar(128) +- `estimated_score` int NULL +- `form_json` json NULL(完整表单回填) +- `tags_json` json NULL(如“工科倾向”“省内优先”) +- `created_at` datetime +- `updated_at` datetime + +索引建议: + +- `uk_user_id` (`user_id`) +- `idx_tenant_status` (`tenant_id`, `entry_status`, `analyze_status`) +- `idx_last_analyze_at` (`last_analyze_at`) + +## 13.3 高考分析报告表 `gaokao_report` + +用途:保存每次 AI 分析结果与来源信息,支持后台查看和审计追溯。 + +核心字段: + +- `id` bigint PK +- `user_id` bigint +- `tenant_id` bigint +- `version` varchar(20)(prompt/version) +- `input_snapshot_json` json(入参快照) +- `report_json` json(完整输出) +- `overview` text +- `search_meta_json` json +- `status` tinyint(0=失败,1=成功) +- `error_msg` varchar(500) NULL +- `created_at` datetime + +索引建议: + +- `idx_user_created` (`user_id`, `created_at`) +- `idx_tenant_created` (`tenant_id`, `created_at`) + +## 13.4 高考订单表 `gaokao_order` + +用途:记录高考功能付费订单,与平台支付及分销结算对齐。 + +核心字段: + +- `id` bigint PK +- `order_no` varchar(64) UNIQUE +- `user_id` bigint +- `tenant_id` bigint +- `product_code` varchar(64) +- `pricing_id` bigint +- `amount_original` decimal(10,2) +- `amount_payable` decimal(10,2) +- `amount_paid` decimal(10,2) NULL +- `currency` varchar(16) +- `pay_status` tinyint(0=待支付,1=已支付,2=已退款,3=关闭) +- `pay_channel` varchar(32) +- `paid_at` datetime NULL +- `refund_at` datetime NULL +- `ext_json` json NULL +- `created_at` datetime +- `updated_at` datetime + +索引建议: + +- `uk_order_no` (`order_no`) +- `idx_user_pay_status` (`user_id`, `pay_status`) +- `idx_tenant_created` (`tenant_id`, `created_at`) + +## 13.5 分销归因表 `gaokao_distribution_attribution` + +用途:记录用户来源、分销员关系、归因窗口,服务佣金计算。 + +核心字段: + +- `id` bigint PK +- `user_id` bigint +- `tenant_id` bigint +- `referrer_user_id` bigint(分销员) +- `channel_code` varchar(64)(海报/链接/机构码) +- `scene` varchar(64)(share_link/poster/qr) +- `attributed_at` datetime +- `expire_at` datetime NULL(归因窗口) +- `is_locked` tinyint(0=可变更,1=锁定) +- `created_at` datetime +- `updated_at` datetime + +索引建议: + +- `idx_user_tenant` (`user_id`, `tenant_id`) +- `idx_referrer` (`referrer_user_id`, `created_at`) +- 唯一约束(建议):同 `user_id + tenant_id` 仅保留 1 条生效归因 + +## 13.6 分销佣金流水表 `gaokao_distribution_commission` + +用途:订单成交后记录应结/已结/冲正的佣金流水。 + +核心字段: + +- `id` bigint PK +- `tenant_id` bigint +- `order_id` bigint +- `order_no` varchar(64) +- `user_id` bigint(购买用户) +- `referrer_user_id` bigint(分销员) +- `commission_rule_type` varchar(20)(ratio/fixed) +- `commission_rule_value` decimal(10,4) +- `commission_amount` decimal(10,2) +- `status` tinyint(0=待结算,1=已结算,2=已冲正) +- `settled_at` datetime NULL +- `reversed_at` datetime NULL +- `remark` varchar(255) NULL +- `created_at` datetime +- `updated_at` datetime + +索引建议: + +- `idx_referrer_status` (`referrer_user_id`, `status`) +- `idx_order_id` (`order_id`) +- `idx_tenant_created` (`tenant_id`, `created_at`) + +## 13.7 用户管理聚合查询建议(非新表) + +后台“高考用户管理”建议通过以下聚合视图/查询实现: + +- 主表:`gaokao_user_profile` +- 左连接:`gaokao_report`(最新一条) +- 左连接:`gaokao_order`(最近支付状态) +- 左连接:`gaokao_distribution_attribution` + `gaokao_distribution_commission` + +建议输出字段: + +- 用户基础信息 + 任务完成状态 + 最近分析时间 +- 报告摘要(overview)与标签(tags_json) +- 订单金额/支付状态 +- 分销员/渠道/累计佣金 + +--- + +## 14. 与现有提示词文件关系 + +你提供的 `高考志愿MBTI推荐_prompt.md` 已非常完整,建议作为“详细版 Prompt 规范”;本文件作为“开发执行版 PRD + Prompt 摘要规范”。 + +推荐落地方式: + +1. 本文件给产品/前后端/测试对齐需求。 +2. `高考志愿MBTI推荐_prompt.md` 作为 AI 服务最终系统提示词来源。 +3. 后端将两者版本号写入 runtime 配置,便于追踪效果。