feat: 高考志愿与定价分销链路更新(管理端/API/小程序)

- 新增高考志愿核心服务、模型、控制器与迁移脚本

- 同步管理端定价/分销/设置与小程序入口、历史、测试选择和支付逻辑

- 补充相关开发文档

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-25 16:19:08 +08:00
parent 513093a5e0
commit c4ac0f8a08
56 changed files with 6100 additions and 68 deletions

View File

@@ -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<Record<string, TestSetting>>({
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; }
}
</style>

View File

@@ -46,6 +46,10 @@
<label>SBTI测试价格 (/)</label>
<el-input-number v-model="personal.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number v-model="personal.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
@@ -83,6 +87,10 @@
<label>SBTI测试价格 (/)</label>
<el-input-number v-model="enterprise.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number v-model="enterprise.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
@@ -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) {

View File

@@ -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<string, boolean>
({ face: true, mbti: true, sbti: true, pdp: true, disc: true, gaokao: true, distribution: true }) as Record<string, boolean>
const permLoading = ref(false)
const permSaving = ref(false)

View File

@@ -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<Record<string, TestSetting>>({
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 {

View File

@@ -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<string, any>, key: string) => {

View File

@@ -85,6 +85,16 @@
class="w-full"
/>
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number
v-model="personal.gaokao"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
</div>
<div class="save-actions">
<el-button type="primary" class="save-btn" @click="savePersonal">
@@ -149,6 +159,16 @@
/>
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number
v-model="enterprise.gaokao"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item form-item--span-full">
<label>最低充值金额 ()</label>
<el-input-number
v-model="enterprise.minRecharge"
@@ -468,7 +488,8 @@ const personal = reactive({
mbti: 9.9,
disc: 9.9,
pdp: 9.9,
sbti: 9.9
sbti: 9.9,
gaokao: 0
})
const enterprise = reactive({
@@ -477,6 +498,7 @@ const enterprise = reactive({
pdp: 8.0,
disc: 8.0,
sbti: 8.0,
gaokao: 0,
minRecharge: 1000.0
})
@@ -569,12 +591,12 @@ const loadPricing = async () => {
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 {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
<?php
namespace app\common\service;
use app\model\PricingConfig as PricingConfigModel;
use think\facade\Db;
/**
* 与 Payment::calculateAmount 中「测试类产品」分支一致,供非 Controller 复用(如高考定价)
*/
class TestProductPricing
{
/** @var string[] */
public const TEST_PRODUCT_TYPES = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis', 'gaokao'];
/**
* @param int|null $enterpriseId 用于查价的企业 IDpersonal 时走 admin_personal+eidenterprise 时走 admin_enterprise+eid
* @param string $pricingTier personal=用户侧个人版定价含企业后台「个人版」专属价enterprise=企业版定价
* @return array{0:int,1:string} [amountFen, pricingType personal|enterprise]
*/
public static function amountFenForTestProduct(
string $productType,
int $wechatUserId,
?int $enterpriseId = null,
int $quantity = 1,
string $pricingTier = 'personal'
): array {
$pricingType = $pricingTier === 'enterprise' ? 'enterprise' : 'personal';
$quantity = $quantity > 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];
}
}

View File

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

View File

@@ -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 ?: '其他');

View File

@@ -0,0 +1,96 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 企业后台:高考用户管理
*/
class GaokaoUser extends BaseController
{
private function currentEnterpriseId(): int
{
$user = $this->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,
]);
}
}

View File

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

View File

@@ -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
* 返回当前用户名下可用邀请码;若无记录则自动生成一条(便于前端直接展示)

View File

@@ -0,0 +1,120 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\GaokaoService;
use think\facade\Request;
/**
* 高考志愿功能 API
*/
class Gaokao extends BaseController
{
private function wechatUserId(): int
{
$user = $this->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
));
}
}

View File

@@ -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<string,mixed> $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深度服务定价配置为元转为分

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 超管:高考用户管理(全平台)
*/
class GaokaoUser extends BaseController
{
private function authOk(): bool
{
$user = $this->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,
]);
}
}

View File

@@ -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<string,mixed> $cfg
* @return array<string,mixed>
*/
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<string,mixed> $cfg
* @return array<string,mixed>
*/
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);
}
}

View File

@@ -61,6 +61,8 @@ class Enterprise extends Model
'sbti' => true,
'pdp' => true,
'disc' => true,
/** 高考志愿任务中心 / 分析报告 */
'gaokao' => true,
/** 详细测试列表中的「AI 性格测试」聚合入口 */
'aiHub' => true,
'distribution' => true,

View File

@@ -0,0 +1,31 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考分销归因
*/
class GaokaoDistributionAttribution extends Model
{
protected $name = 'gaokao_distribution_attribution';
protected $schema = [
'id' => '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';
}

View File

@@ -0,0 +1,35 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考分销佣金流水
*/
class GaokaoDistributionCommission extends Model
{
protected $name = 'gaokao_distribution_commission';
protected $schema = [
'id' => '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';
}

View File

@@ -0,0 +1,38 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考业务订单表
*/
class GaokaoOrder extends Model
{
protected $name = 'gaokao_order';
protected $schema = [
'id' => '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'];
}

View File

@@ -0,0 +1,60 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考定价配置
*/
class GaokaoPricing extends Model
{
protected $name = 'gaokao_pricing';
protected $schema = [
'id' => '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();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考分析报告快照
*/
class GaokaoReport extends Model
{
protected $name = 'gaokao_report';
protected $schema = [
'id' => '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'];
}

View File

@@ -0,0 +1,40 @@
<?php
namespace app\model;
use think\Model;
/**
* 高考用户档案
*/
class GaokaoUserProfile extends Model
{
protected $name = 'gaokao_user_profile';
protected $schema = [
'id' => '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'];
}

View File

@@ -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.idtestType=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 说明

View File

@@ -0,0 +1,3 @@
-- 可选:将旧版 mbti_gaokao_report 迁入 mbti_test_resultstestType=gaokao
-- 执行前请备份数据库;按实际 MySQL 版本与 JSON 函数编写 INSERT…SELECT。
-- 迁移完成后:用新 test_results.id 回写 mbti_gaokao_user_profile.latestReportId再择机 DROP 旧高考业务表。

View File

@@ -0,0 +1,4 @@
-- 回滚:仅删除高考用户档案表(新版迁移仅创建此表)
-- 若库中仍有旧版 mbti_gaokao_report / mbti_gaokao_order 等表,请按需手动 DROP
DROP TABLE IF EXISTS `mbti_gaokao_user_profile`;

View File

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

View File

@@ -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,
// 测试次数

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "高考信息表单"
}

View File

@@ -0,0 +1,72 @@
<view class="container">
<view class="card">
<view class="item">
<view class="label">姓名</view>
<input class="input" data-key="name" value="{{form.name}}" bindinput="onInput" placeholder="请输入姓名" />
</view>
<view class="item">
<view class="label">所在地区</view>
<picker mode="region" level="city" value="{{regionPickerValue}}" bindchange="onRegionChange">
<view class="input input-picker {{!regionLine ? 'input-picker-empty' : ''}}">
<text>{{regionLine ? regionLine : '点选高考生源省、市'}}</text>
</view>
</picker>
</view>
<view class="item">
<view class="label">科类/选科</view>
<picker mode="selector" range="{{subjectOptions}}" value="{{streamOrSubjectsIndex}}" bindchange="onSubjectChange">
<view class="input input-picker">
<text wx:if="{{form.streamOrSubjects}}">{{form.streamOrSubjects}}</text>
<text wx:else class="ph">请点选科类或选科组合</text>
</view>
</picker>
</view>
<view class="item">
<view class="label">估分</view>
<input class="input" type="number" data-key="estimatedScore" value="{{form.estimatedScore}}" bindinput="onInput" placeholder="选填" />
</view>
<view class="item">
<view class="label">成绩说明</view>
<textarea class="textarea" data-key="scoreText" value="{{form.scoreText}}" bindinput="onInput" placeholder="可选,填写模考成绩说明"></textarea>
</view>
<view class="item">
<view class="label">志愿草表</view>
<textarea class="textarea" data-key="wishListText" value="{{form.wishListText}}" bindinput="onInput" placeholder="可选,填写当前志愿方案"></textarea>
</view>
<view class="item item-intended">
<view class="label">意向地区</view>
<picker class="intended-mode-picker" mode="selector" range="{{intendedModeOptions}}" value="{{intendedModeIndex}}" bindchange="onIntendedModeChange">
<view class="input input-picker input-sub">
<text>粒度:{{intendedModeLine}}</text>
</view>
</picker>
<view class="intended-region-pickers">
<block wx:if="{{intendedModeIndex == 0}}">
<picker mode="region" level="province" value="{{intendedProvPicker}}" bindchange="onIntendedRegionProv">
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
<text>{{intendedRegionLine ? intendedRegionLine : '点选省份(可只到省)'}}</text>
</view>
</picker>
</block>
<block wx:else>
<picker mode="region" level="city" value="{{intendedCityPicker}}" bindchange="onIntendedRegionCity">
<view class="input input-picker {{!intendedRegionLine ? 'input-picker-empty' : ''}}">
<text>{{intendedRegionLine ? intendedRegionLine : '点选省、市(含直辖市)'}}</text>
</view>
</picker>
</block>
</view>
</view>
<view class="item">
<view class="label">意向专业</view>
<picker mode="selector" range="{{majorOptions}}" value="{{preferredFieldsIndex}}" bindchange="onPreferredFieldChange">
<view class="input input-picker">
<text wx:if="{{form.preferredFields}}">{{form.preferredFields}}</text>
<text wx:else class="ph">可选,点选专业方向</text>
</view>
</picker>
</view>
</view>
<button class="save-btn" loading="{{saving}}" bindtap="onSave">保存表单</button>
</view>

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "高考志愿"
}

View File

@@ -0,0 +1,90 @@
<view class="page">
<view class="container">
<view class="header">
<view class="title">高考志愿任务中心</view>
<view class="desc">完成 MBTI、PDP、DISC、拍照面相与高考信息表单后即可生成综合分析若定价非 0请在报告页解锁全文与问卷类测评一致。</view>
</view>
<view class="task-list">
<view class="task-card" data-code="mbti" data-status="{{tasks.mbti.status}}" data-result-id="{{tasks.mbti.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.mbti.emoji}}</text>
<text class="name">{{tasks.mbti.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.mbti.status==='done'}}">{{tasks.mbti.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.mbti.status==='done' && tasks.mbti.testTime}}">{{tasks.mbti.testTime}}</view>
</view>
<view class="status {{tasks.mbti.status==='done'?'done':''}}">{{tasks.mbti.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="pdp" data-status="{{tasks.pdp.status}}" data-result-id="{{tasks.pdp.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.pdp.emoji}}</text>
<text class="name">{{tasks.pdp.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.pdp.status==='done'}}">{{tasks.pdp.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.pdp.status==='done' && tasks.pdp.testTime}}">{{tasks.pdp.testTime}}</view>
</view>
<view class="status {{tasks.pdp.status==='done'?'done':''}}">{{tasks.pdp.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="disc" data-status="{{tasks.disc.status}}" data-result-id="{{tasks.disc.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.disc.emoji}}</text>
<text class="name">{{tasks.disc.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.disc.status==='done'}}">{{tasks.disc.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.disc.status==='done' && tasks.disc.testTime}}">{{tasks.disc.testTime}}</view>
</view>
<view class="status {{tasks.disc.status==='done'?'done':''}}">{{tasks.disc.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="face" data-status="{{tasks.face.status}}" data-result-id="{{tasks.face.testResultId}}" data-record-type="{{tasks.face.recordTestType}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.face.emoji}}</text>
<text class="name">{{tasks.face.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.face.status==='done'}}">{{tasks.face.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去拍摄</view>
<view class="task-time" wx:if="{{tasks.face.status==='done' && tasks.face.testTime}}">{{tasks.face.testTime}}</view>
</view>
<view class="status {{tasks.face.status==='done'?'done':''}}">{{tasks.face.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="form" data-status="{{tasks.form.status}}" data-result-id="{{tasks.form.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.form.emoji}}</text>
<text class="name">{{tasks.form.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.form.status==='done'}}">
<block wx:if="{{tasks.form.resultText}}">已填写:{{tasks.form.resultText}}</block>
<block wx:else>已完成</block>
</view>
<view class="result todo" wx:else>未完成,请先填写</view>
<view class="task-time" wx:if="{{tasks.form.status==='done' && tasks.form.testTime}}">{{tasks.form.testTime}}</view>
</view>
<view class="status {{tasks.form.status==='done'?'done':''}}">{{tasks.form.status==='done'?'查看结果':'去完成'}}</view>
</view>
</view>
</view>
<view class="bottom-actions">
<button
class="action-btn {{canAnalyze ? 'action-btn--primary' : 'action-btn--disabled'}}"
loading="{{analyzing}}"
bindtap="onAnalyzeTap"
disabled="{{analyzing}}"
>
{{canAnalyze ? '开始综合分析' : '综合分析(未完成)'}}
</button>
</view>
</view>

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "高考分析报告",
"usingComponents": {
"invite-code-dialog": "/components/invite-code-dialog/invite-code-dialog"
}
}

View File

@@ -0,0 +1,262 @@
<view class="gaokao-report-page">
<!-- 综合分析:与人脸结果页类似的加载态 -->
<view class="analyzing-modal" wx:if="{{isPendingAnalyze}}">
<view class="analyzing-content">
<view class="analyzing-icon">
<view class="analyzing-spinner"></view>
</view>
<text class="analyzing-title">{{analyzingTitle}}</text>
<text class="analyzing-desc">{{analyzingTip}}</text>
<view class="analyzing-bar">
<view class="analyzing-bar-fill" style="width: {{analyzeProgress}}%"></view>
</view>
<text class="analyzing-hint">AI 生成可能需要 30 秒1 分钟,请稍候…</text>
</view>
</view>
<view
class="container {{report && !fromShare ? 'container--with-bottom-tools' : ''}} {{report && fromShare ? 'container--with-share-footer' : ''}}"
wx:if="{{report}}"
>
<view class="header-card">
<view class="header-hero">
<view class="header-top">
<view class="user-info">
<view class="user-name">{{inputEcho.name || '同学'}}</view>
<view class="user-tags">
<text class="tag tag-province" wx:if="{{inputEcho.province}}">{{inputEcho.province}}</text>
<text class="tag tag-stream" wx:if="{{inputEcho.streamSubjects}}">{{inputEcho.streamSubjects}}</text>
</view>
</view>
<view class="score-box">
<text class="score-num">{{inputEcho.estimatedScore || 0}}</text>
<text class="score-unit">分</text>
</view>
</view>
</view>
<view class="header-body" wx:if="{{inputEcho.mbti || inputEcho.pdp || inputEcho.disc}}">
<view class="personality-tags">
<view class="p-tag" wx:if="{{inputEcho.mbti}}">
<text class="p-label">MBTI</text>
<text class="p-value">{{inputEcho.mbti}}</text>
</view>
<view class="p-tag" wx:if="{{inputEcho.pdp}}">
<text class="p-label">PDP</text>
<text class="p-value">{{inputEcho.pdp}}</text>
</view>
<view class="p-tag" wx:if="{{inputEcho.disc}}">
<text class="p-label">DISC</text>
<text class="p-value">{{inputEcho.disc}}</text>
</view>
</view>
</view>
</view>
<view class="card paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid && !fromShare}}">
<view class="paywall-content paywall-content--preview40">
<view class="paywall-blur">
<text class="paywall-fake-title">完整高考志愿分析报告</text>
<text class="paywall-fake-line paywall-fake-line--compact">解锁后可查看冲稳保院校、专业推荐与性格解读全文。</text>
<view class="paywall-preview-wrap" wx:if="{{report.overview}}">
<text class="paywall-preview-label">综合总评(预览)</text>
<view class="paywall-preview-inner">
<text class="paywall-preview-text">{{report.overview}}</text>
<view class="paywall-preview-fade"></view>
</view>
</view>
<block wx:else>
<text class="paywall-fake-line">• 冲稳保院校与推荐理由</text>
<text class="paywall-fake-line">• 专业推荐与匹配说明</text>
<text class="paywall-fake-line">• 性格与志愿方向解读</text>
</block>
</view>
<view class="paywall-mask paywall-mask--from40"></view>
<view id="unlock-gate-anchor" class="unlock-gate-anchor-btn-wrap">
<button
class="paywall-btn"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForGaokaoPay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
<button class="paywall-btn" wx:elif="{{hasPhone}}" bindtap="unlockFullReport">
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
</view>
</view>
</view>
<view class="card paywall-card" wx:elif="{{report.locked && !fromShare && !profileGate}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试或联系客服;若刚授权手机,可下拉刷新本页。</text>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去个人资料</text>
</button>
</view>
</view>
<view
wx:if="{{profileGate && report && (!payInfo.requiresPayment || payInfo.isPaid) && !fromShare}}"
id="unlock-gate-anchor"
class="unlock-gate-profile-block"
>
<view class="preview-teaser-card" wx:if="{{report.overview}}">
<text class="preview-teaser-title">综合总评(预览)</text>
<text class="preview-teaser-desc">{{report.overview}}</text>
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文。</text>
</view>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button wx:else class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
<view id="unlock-scroll-tail"></view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">📊</view>
<view class="title">综合总评</view>
</view>
<view class="content">{{report.overview}}</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">🎯</view>
<view class="title">冲稳保建议</view>
</view>
<block wx:if="{{hasSchoolFlat}}">
<view class="sub-counts" wx:if="{{schoolChongCount || schoolWenCount || schoolBaoCount}}">
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
</view>
<view class="sub-counts" wx:else>
<view class="count-tag count-total">院校参考(共 {{schoolListFlat.length}} 所)</view>
</view>
<view class="school-list">
<block wx:for="{{schoolListFlat}}" wx:for-item="sch" wx:for-index="sidx" wx:key="sidx">
<view class="school-item">
<view class="school-header">
<view
class="school-band-tag band-{{sch.band === '冲' ? 'chong' : (sch.band === '稳' ? 'wen' : (sch.band === '保' ? 'bao' : 'none'))}}"
wx:if="{{sch.band}}"
>{{sch.band}}</view>
<view class="school-name">{{sch.schoolName}}</view>
</view>
<view class="school-meta-row" wx:if="{{sch.city || sch.level}}">
<text class="meta-item" wx:if="{{sch.city}}"><text class="meta-icon">📍</text>{{sch.city}}</text>
<text class="meta-item" wx:if="{{sch.level}}"><text class="meta-icon">🎓</text>{{sch.level}}</text>
</view>
<view class="school-reason" wx:if="{{sch.reason}}">
<text class="quote-mark">“</text>
{{sch.reason}}
</view>
</view>
</block>
</view>
</block>
<view wx:else class="sub-counts">
<view class="count-tag count-chong">冲 {{schoolChongCount}}所</view>
<view class="count-tag count-wen">稳 {{schoolWenCount}}所</view>
<view class="count-tag count-bao">保 {{schoolBaoCount}}所</view>
</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">💼</view>
<view class="title">专业建议</view>
</view>
<view class="major-list" wx:if="{{!hasNoMajors}}">
<block wx:for="{{majorList}}" wx:for-item="row" wx:for-index="idx" wx:key="idx">
<view class="major-item">
<view class="major-rank rank-{{idx < 3 ? idx + 1 : 'other'}}">{{idx + 1}}</view>
<view class="major-name">{{row.name}}</view>
<view class="major-score" wx:if="{{row.fitLabel}}">{{row.fitLabel}}</view>
</view>
</block>
</view>
<view wx:if="{{hasNoMajors}}" class="sub">暂无专业推荐</view>
</view>
<view class="card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">🧠</view>
<view class="title">性格匹配说明</view>
</view>
<view class="content">{{report.personalityReason}}</view>
</view>
<view class="card disclaimer-card" wx:if="{{!report.locked}}">
<view class="title-wrap">
<view class="title-icon">⚠️</view>
<view class="title">免责声明</view>
</view>
<view class="content disclaimer-text">{{report.disclaimers}}</view>
</view>
</view>
<view class="empty" wx:elif="{{!isPendingAnalyze}}">
<view class="empty-icon">📄</view>
<view class="empty-text">暂无报告,请先完成综合分析</view>
</view>
<view class="result-bottom-tools" wx:if="{{report && !fromShare}}">
<view class="journey-stepper journey-stepper--two">
<view class="journey-stepper__item">
<view
class="journey-stepper__dot {{journey.step1Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===1 ? 'journey-stepper__dot--active' : '')}}"
>1</view>
<text class="journey-stepper__label {{journey.activeStep===1 ? 'journey-stepper__label--active' : ''}}">看全文</text>
</view>
<view class="journey-stepper__bar {{journey.step1Unlocked ? 'journey-stepper__bar--done' : ''}}"></view>
<view class="journey-stepper__item">
<view
class="journey-stepper__dot {{journey.step2Unlocked ? 'journey-stepper__dot--done' : (journey.activeStep===2 ? 'journey-stepper__dot--active' : '')}}"
>2</view>
<text class="journey-stepper__label {{journey.activeStep===2 ? 'journey-stepper__label--active' : ''}}">分享朋友圈</text>
</view>
</view>
<view class="result-bottom-tools__row result-bottom-tools__row--two">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料后解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view
class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}"
bindtap="onTapShareMoment"
>
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? (journey.step2Unlocked ? '已分享' : '点击分享') : '先解锁全文'}}</text>
</view>
</view>
</view>
<view class="result-share-footer result-share-footer--stack" wx:if="{{report && fromShare}}">
<button class="result-share-footer-btn" bindtap="goWantTest">我也要测试</button>
<view class="result-share-footer-row2">
<button class="result-share-footer-btn result-share-footer-btn--ghost" bindtap="goReadFullFromShare">看全文</button>
<button class="result-share-footer-btn result-share-footer-btn--share" open-type="share">分享给好友</button>
</view>
</view>
<invite-code-dialog
visible="{{showInviteCodeDialog}}"
bind:skip="onInviteCodeSkip"
bind:success="onInviteCodeSuccess"
/>
</view>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<!--pages/test-select/index.wxml - 详细性格测试入口MBTI / SBTI / PDP / DISC / AI-->
<!--pages/test-select/index.wxml - 详细性格测试 + 高考志愿等入口-->
<view class="container">
<view class="card intro-card">
<text class="intro-title">选择一项详细性格测试</text>
<text class="intro-desc">问卷测评与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告</text>
<text class="intro-desc">问卷测评、高考志愿规划与 AI 对话 / 拍照面相分项进入,完成后可获得对应报告</text>
</view>
<view class="card entry-card" bindtap="goMBTI" wx:if="{{permMbti}}">
@@ -49,6 +49,17 @@
<text class="entry-arrow">→</text>
</view>
<view class="card entry-card" bindtap="goGaokaoHub" wx:if="{{permGaokao}}">
<view class="entry-icon-wrap gaokao">
<text class="entry-emoji">🎓</text>
</view>
<view class="entry-content">
<text class="entry-name">高考志愿任务中心</text>
<text class="entry-brief">完成 MBTI / PDP / DISC / 拍照面相与信息表单 · 购买报告后生成志愿分析</text>
</view>
<text class="entry-arrow">→</text>
</view>
<view class="card entry-card" bindtap="goAIChatInterpretation" wx:if="{{permAiHub}}">
<view class="entry-icon-wrap ai-chat">
<text class="entry-emoji">💬</text>

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
/**
* 高考报告页「两步解锁」:看全文 → 分享朋友圈(不与 MBTI 的 mbti_journey_unlocks 混用)
* storage: gaokao_journey_<testResultId> = { 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
}

View File

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

View File

@@ -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` 部署为准。*

View File

@@ -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) 关键结论必须带sourcestitle,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` tinyint0=停用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` tinyint0=未进入1=进行中2=已完成)
- `mbti_status` tinyint0=未测1=已测)
- `pdp_status` tinyint0=未测1=已测)
- `disc_status` tinyint0=未测1=已测)
- `form_status` tinyint0=未填1=已填)
- `analyze_status` tinyint0=未生成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` tinyint0=失败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` tinyint0=待支付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` tinyint0=可变更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` tinyint0=待结算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 配置,便于追踪效果。