-
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ card.total }}
+ 人
+
+
+ {{ card.active }}活跃
+ {{ card.tested }}已测试
+
+
+
+
+
+
+
+
+
池内用户总数
+
{{ summary.totalUsers }}
+
+
+
活跃用户
+
+ {{ summary.activeUsers }} ({{ summary.activePercent }}%)
+
+
+
+
已完成测试
+
+ {{ summary.testedUsers }} ({{ summary.testedPercent }}%)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
+
![]()
+
+ {{ avatarLetter(row) }}
+
+
+
{{ row.username || '未设置昵称' }}
+
{{ row.openid || '—' }}
+
{{ row.phone}}
+
+
+
+
+
+
+
+
+
+
+ {{ row.faceMbtiType }}
+
+
+ {{ row.faceDiscType }}
+
+
+ {{ row.facePdpType }}
+
+
+ —
+
+
+
+
+
+
+
+
+
+ {{ row.mbtiType }}
+
+
+ —
+
+
+
+
+
+
+
+
+
+ {{ row.pdpType }}
+
+
+ —
+
+
+
+
+
+
+
+
+
+ {{ row.discType }}
+
+
+ —
+
+
+
+
+
+
+ {{ row.testCount ?? 0 }}
+
+
+
+
+
+
+
+ {{ row.enterprise || '个人用户(无企业)' }}
+
+
+
+
+
+
+ 正常
+
+
+
+
+
+ {{ formatDate(row.createdAt) }}
+
+
+
+
+
+
+
+ 查看
+
+
+
+
+
+
+ 暂无用户数据
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDate(row.createdAt) }}
+
+
+ {{ formatTestType(row.testType) }}
+
+
+
+
+
+ {{ row.requiresPayment ? '是' : '否' }}
+
+
+
+
+
+
+ {{ row.isPaid ? '是' : '否' }}
+
+
+
+
+
+
+ {{ row.isPaid ? formatAmount(row.paidAmount) : '-' }}
+
+
+
+
+
+
+ {{ row.isPaid ? formatDate(row.paidAt) : '-' }}
+
+
+
+
+
+ 查看
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ mbtiDetail.type }}
+
{{ mbtiDetail.name }}
+
{{ mbtiDetail.description }}
+
+
+
+
+
+
+
+
+
+
{{ discDetail.type }}
+
{{ discDetail.title }}
+
{{ discDetail.description }}
+
+
+
四维占比
+
+
+
{{ key }}
+
+
{{ discDetail.percentages[key] || 0 }}%
+
+
+
+
+
+
+
+
+
+
+
+
{{ pdpDetail.emoji }}
+
+ {{ pdpDetail.type }}
+ · {{ pdpDetail.title }}
+
+
{{ pdpDetail.description }}
+
+
+
+
+
团队角色定位
+
{{ pdpDetail.teamRole }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AI 识别 MBTI
+
+ {{ faceDetail.mbti.type }}
+ · {{ faceDetail.mbti.title }}
+
+
+
+
AI 识别 DISC
+
+ 主类型:{{ faceDetail.disc.primary }}
+ ,次类型:{{ faceDetail.disc.secondary }}
+
+
+
+
AI 识别 PDP
+
+ 主类型:{{ faceDetail.pdp.primary }}
+ ,次类型:{{ faceDetail.pdp.secondary }}
+
+
+
+
整体气质概览
+
{{ faceDetail.overview }}
+
+
+
骨相特征
+
{{ faceDetail.boneAnalysis }}
+
+
+
性格总结
+
{{ faceDetail.personalitySummary }}
+
+
+
+
+
+
+
+
+
+
{{ resumeDetail.bossView.headline }}
+
+
+
{{ m.value }}
+
{{ m.label }}
+
+
+
+ {{ resumeDetail.bossView.costInsight }}
+
+
+
+
+
+
+
+
+
工作风格
+
{{ resumeDetail.portrait.workStyle }}
+
+
+
+
+
+
+
+
员工全生命周期预测
+
+
+ 入职
+ {{ resumeDetail.hrView.lifecycle.onboarding }}
+
+
+ 试用期
+ {{ resumeDetail.hrView.lifecycle.probation }}
+
+
+ 成长期
+ {{ resumeDetail.hrView.lifecycle.growth }}
+
+
+ 留存
+ {{ resumeDetail.hrView.lifecycle.retention }}
+
+
+
+
+
+
绩效潜力
+
{{ resumeDetail.hrView.performance.potential }}
+
+
+
+
+
团队匹配 & 管理建议
+
+ 最佳团队:{{ resumeDetail.hrView.teamFit.bestTeam }}
+
+
+ 管理建议:{{ resumeDetail.hrView.teamFit.manageAdvice }}
+
+
+
+
合规风险
+
风险等级:{{ resumeDetail.hrView.complianceRisk.level }}
+
{{ resumeDetail.hrView.complianceRisk.notes }}
+
+
+
+
+
+
简历要点
+
{{ resumeDetail.resumeHighlights }}
+
+
+
+
+
+
分析报告
+
{{ currentTestDescription }}
+
+
+
+
+
补充说明
+
{{ currentTestDescription }}
+
+
+
+
+
+
+
+
+
diff --git a/admin/vite.config.ts b/admin/vite.config.ts
index f1790b4..d7f3c78 100644
--- a/admin/vite.config.ts
+++ b/admin/vite.config.ts
@@ -1,52 +1,49 @@
-import { fileURLToPath, URL } from 'node:url'
-import { defineConfig } from 'vite'
-import vue from '@vitejs/plugin-vue'
-import AutoImport from 'unplugin-auto-import/vite'
-import Components from 'unplugin-vue-components/vite'
-import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
-
-export default defineConfig({
- css: {
- preprocessorOptions: {
- scss: { silenceDeprecations: ['legacy-js-api'] },
- sass: { silenceDeprecations: ['legacy-js-api'] }
- }
- },
- build: {
- chunkSizeWarningLimit: 1200
- },
- plugins: [
- vue(),
- AutoImport({
- imports: ['vue', 'vue-router', 'pinia'],
- resolvers: [ElementPlusResolver()],
- dts: 'src/auto-imports.d.ts',
- }),
- Components({
- resolvers: [ElementPlusResolver()],
- dts: 'src/components.d.ts',
- }),
- ],
- resolve: {
- alias: {
- '@': fileURLToPath(new URL('./src', import.meta.url))
- }
- },
- server: {
- host: '0.0.0.0', // 允许局域网访问
- port: 5173, // 首选端口
- strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
- proxy: {
- '/api': {
- // 如果 VITE_API_BASE_URL 是完整URL,则不使用代理
- // 否则代理到测试服务器
- // 注意:在vite.config.ts中无法直接访问import.meta.env
- // 这里使用默认代理配置,实际请求会根据VITE_API_BASE_URL决定
- target: 'http://test.mbti.com', // 本地开发代理到测试环境
- changeOrigin: true,
- rewrite: (path) => path, // 保持路径不变
- }
- }
- }
-})
-
+import { fileURLToPath, URL } from 'node:url'
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import AutoImport from 'unplugin-auto-import/vite'
+import Components from 'unplugin-vue-components/vite'
+import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+
+export default defineConfig({
+ css: {
+ preprocessorOptions: {
+ scss: { silenceDeprecations: ['legacy-js-api'] },
+ sass: { silenceDeprecations: ['legacy-js-api'] }
+ }
+ },
+ build: {
+ chunkSizeWarningLimit: 1200
+ },
+ plugins: [
+ vue(),
+ AutoImport({
+ imports: ['vue', 'vue-router', 'pinia'],
+ resolvers: [ElementPlusResolver()],
+ dts: 'src/auto-imports.d.ts',
+ }),
+ Components({
+ resolvers: [ElementPlusResolver()],
+ dts: 'src/components.d.ts',
+ }),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ }
+ },
+ server: {
+ host: '0.0.0.0', // 允许局域网访问
+ port: 5173, // 首选端口
+ strictPort: false, // 如果端口被占用,自动尝试下一个可用端口
+ proxy: {
+ '/api': {
+ // 与 .env.development 配合:VITE_API_BASE_URL 留空时,浏览器请求 /api/* 由这里转发到本机 ThinkPHP
+ target: process.env.VITE_DEV_API_PROXY ?? 'http://127.0.0.1:8787',
+ changeOrigin: true,
+ rewrite: (path) => path,
+ },
+ },
+ }
+})
+
diff --git a/api/app/common/service/FeishuLeadWebhookService.php b/api/app/common/service/FeishuLeadWebhookService.php
new file mode 100644
index 0000000..973abe0
--- /dev/null
+++ b/api/app/common/service/FeishuLeadWebhookService.php
@@ -0,0 +1,325 @@
+ false,
+ 'webhookUrl' => '',
+ 'contactPerson' => '运营',
+ ];
+ $row = Db::name('system_config')
+ ->where('key', self::CONFIG_KEY)
+ ->where('enterprise_id', 0)
+ ->find();
+ if (!$row || empty($row['value'])) {
+ return $def;
+ }
+ $v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
+ if (!is_array($v)) {
+ return $def;
+ }
+ return array_merge($def, $v);
+ }
+
+ /**
+ * 支付成功(含测试付费、充值等):每订单仅推一次
+ */
+ public static function onOrderPaid(int $orderDbId, int $userId): void
+ {
+ if ($orderDbId <= 0 || $userId <= 0) {
+ return;
+ }
+ $dedupKey = 'order_paid:' . $orderDbId;
+ $order = Db::name('orders')->where('id', $orderDbId)->find();
+ if (!$order) {
+ return;
+ }
+ $productType = (string) ($order['productType'] ?? '');
+ $amountFen = (int) ($order['amount'] ?? 0);
+ $title = (string) ($order['productTitle'] ?? '');
+ $source = self::sourceLabelForOrder($productType, $title, $amountFen);
+ self::pushLead([
+ 'dedupKey' => $dedupKey,
+ 'userId' => $userId,
+ 'source' => $source,
+ 'extraLine' => '订单号: ' . ($order['orderNo'] ?? '') . ' · 金额: ¥' . number_format($amountFen / 100, 2),
+ ]);
+ }
+
+ /**
+ * 首次绑定手机号(测试完成留资)
+ */
+ public static function onPhoneBound(int $userId, string $phone): void
+ {
+ if ($userId <= 0 || trim($phone) === '') {
+ return;
+ }
+ self::pushLead([
+ 'dedupKey' => 'phone_bind:' . $userId,
+ 'userId' => $userId,
+ 'source' => '测试完成·授权手机号',
+ 'phone' => $phone,
+ ]);
+ }
+
+ /**
+ * @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
+ */
+ public static function pushLead(array $p): void
+ {
+ $cfg = self::getConfig();
+ if (empty($cfg['enabled'])) {
+ return;
+ }
+ $url = trim((string) ($cfg['webhookUrl'] ?? ''));
+ if ($url === '' || stripos($url, 'http') !== 0) {
+ return;
+ }
+
+ $dedupKey = $p['dedupKey'] ?? '';
+ if ($dedupKey === '') {
+ return;
+ }
+
+ if (!self::beginDedup($dedupKey)) {
+ return;
+ }
+
+ $userId = (int) ($p['userId'] ?? 0);
+ $wu = $userId > 0
+ ? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone')->find()
+ : null;
+ $nickname = trim((string) ($wu['nickname'] ?? ''));
+ if ($nickname === '') {
+ $nickname = '微信用户';
+ }
+ $phone = trim((string) ($p['phone'] ?? ($wu['phone'] ?? '')));
+
+ $contact = trim((string) ($cfg['contactPerson'] ?? '运营'));
+ if ($contact === '') {
+ $contact = '运营';
+ }
+
+ $source = (string) ($p['source'] ?? '小程序');
+ $now = date('Y-m-d H:i');
+
+ $text = "📋 新获客\n来源: {$source}\n对接人: {$contact}\n━━━━━━━━━━";
+ $text .= "\n姓名: {$nickname}";
+ if ($phone !== '') {
+ $text .= "\n手机: {$phone}";
+ }
+ $text .= "\n时间: {$now}";
+ if (!empty($p['extraLine'])) {
+ $text .= "\n" . $p['extraLine'];
+ }
+
+ $lines = self::recentBehaviorLines($userId, 8);
+ if (count($lines) > 0) {
+ $text .= "\n━━━━━━━━━━\n最近行为:";
+ $i = 1;
+ foreach ($lines as $line) {
+ $text .= "\n {$i}. {$line}";
+ $i++;
+ }
+ }
+
+ $ok = self::postWebhook($url, $text);
+ if (!$ok) {
+ self::rollbackDedup($dedupKey);
+ }
+ }
+
+ private static function sourceLabelForOrder(string $productType, string $title, int $amountFen): string
+ {
+ if ($productType === 'recharge') {
+ return '企业余额·充值支付成功';
+ }
+ $map = [
+ 'face' => '面相测试',
+ 'mbti' => 'MBTI测试',
+ 'disc' => 'DISC测试',
+ 'pdp' => 'PDP测试',
+ 'resume' => '简历分析',
+ 'report' => '完整报告',
+ 'team_analysis' => '团队分析',
+ 'deep_personal' => '个人深度服务',
+ 'deep_team' => '团队深度服务',
+ 'vip' => 'VIP',
+ ];
+ $label = $map[$productType] ?? strtoupper($productType);
+ $suffix = '·支付成功';
+ if ($title !== '') {
+ return $label . '·「' . self::oneLine($title, 40) . '」' . $suffix;
+ }
+ if ($amountFen === 100) {
+ return $label . '·1元支付' . $suffix;
+ }
+ return $label . $suffix;
+ }
+
+ private static function oneLine(string $s, int $max): string
+ {
+ $s = preg_replace('/\s+/u', ' ', trim($s));
+ if (mb_strlen($s) > $max) {
+ return mb_substr($s, 0, $max) . '…';
+ }
+ return $s;
+ }
+
+ private static function recentBehaviorLines(int $userId, int $limit): array
+ {
+ if ($userId <= 0) {
+ return [];
+ }
+ try {
+ $rows = Db::name('analytics_events')
+ ->where('userId', $userId)
+ ->order('id', 'desc')
+ ->limit($limit)
+ ->select()
+ ->toArray();
+ } catch (\Throwable $e) {
+ return [];
+ }
+ $out = [];
+ foreach ($rows as $r) {
+ $out[] = self::formatAnalyticsLine($r);
+ }
+ return $out;
+ }
+
+ private static function formatAnalyticsLine(array $r): string
+ {
+ $name = (string) ($r['eventName'] ?? '');
+ $path = trim((string) ($r['pagePath'] ?? ''));
+ $props = [];
+ if (!empty($r['propsJson'])) {
+ $decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
+ $props = is_array($decoded) ? $decoded : [];
+ }
+ $labelMap = [
+ 'page_view' => '浏览页面',
+ 'button_click' => '按钮点击',
+ 'click_pay' => '发起支付',
+ 'click_recharge'=> '点击充值',
+ ];
+ $label = $labelMap[$name] ?? $name;
+ $detail = '';
+ if ($name === 'page_view' && $path !== '') {
+ $detail = $path;
+ }
+ if (isset($props['action']) && (string) $props['action'] !== '') {
+ $detail = (string) $props['action'];
+ if (!empty($props['productType'])) {
+ $detail .= ' · ' . (string) $props['productType'];
+ }
+ } elseif (isset($props['label']) && (string) $props['label'] !== '') {
+ $detail = (string) $props['label'];
+ } elseif ($path !== '' && $detail === '') {
+ $detail = $path;
+ }
+ $line = $detail !== '' ? "{$label}: {$detail}" : $label;
+ $ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
+ if (!$ts && !empty($r['createdAt'])) {
+ $ts = strtotime((string) $r['createdAt']) * 1000;
+ }
+ if ($ts) {
+ $line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
+ }
+ return $line;
+ }
+
+ private static function humanTimeAgoCn(int $clientTsMs): string
+ {
+ $now = (int) (microtime(true) * 1000);
+ $sec = max(0, (int) (($now - $clientTsMs) / 1000));
+ if ($sec < 60) {
+ return '刚刚';
+ }
+ if ($sec < 3600) {
+ return (int) floor($sec / 60) . '分钟前';
+ }
+ if ($sec < 86400) {
+ return (int) floor($sec / 3600) . '小时前';
+ }
+ return (int) floor($sec / 86400) . '天前';
+ }
+
+ private static function beginDedup(string $dedupKey): bool
+ {
+ try {
+ Db::name('feishu_lead_dedup')->insert([
+ 'dedupKey' => $dedupKey,
+ 'createdAt' => date('Y-m-d H:i:s'),
+ ]);
+ return true;
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ private static function rollbackDedup(string $dedupKey): void
+ {
+ try {
+ Db::name('feishu_lead_dedup')->where('dedupKey', $dedupKey)->delete();
+ } catch (\Throwable $e) {
+ }
+ }
+
+ private static function postWebhook(string $url, string $text): bool
+ {
+ $payload = [];
+ if (stripos($url, 'qyapi.weixin.qq.com') !== false) {
+ $payload = [
+ 'msgtype' => 'text',
+ 'text' => ['content' => $text],
+ ];
+ } else {
+ $payload = [
+ 'msg_type' => 'text',
+ 'content' => ['text' => $text],
+ ];
+ }
+ $json = json_encode($payload, JSON_UNESCAPED_UNICODE);
+ $ch = curl_init($url);
+ if ($ch === false) {
+ return false;
+ }
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 8);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ $body = curl_exec($ch);
+ $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($code >= 400) {
+ return false;
+ }
+ if ($body !== false && $body !== '') {
+ $resp = json_decode($body, true);
+ if (is_array($resp)) {
+ if (isset($resp['code']) && (int) $resp['code'] !== 0) {
+ return false;
+ }
+ if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+}
diff --git a/api/app/controller/admin/AppUser.php b/api/app/controller/admin/AppUser.php
index 6a36cc2..560fd46 100644
--- a/api/app/controller/admin/AppUser.php
+++ b/api/app/controller/admin/AppUser.php
@@ -1,358 +1,261 @@
-request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- $page = (int) Request::param('page', 1);
- $pageSize = (int) Request::param('pageSize', 20);
- $pageSize = min(max($pageSize, 1), 100);
- $keyword = trim(Request::param('keyword', ''));
-
- $where = [];
- if ($keyword !== '') {
- $where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
- }
-
- // admin / enterprise_admin 均只能看本企业数据
- $enterpriseId = $user['enterpriseId'] ?? null;
- if (!$enterpriseId) {
- // JWT 未含 enterpriseId 时回退查库(兼容旧 token)
- $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
- $enterpriseId = $adminRow['enterpriseId'] ?? null;
- }
-
- // 若有企业ID:先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
- $profileUserIds = [];
- if ($enterpriseId) {
- $profileUserIds = Db::name('user_profile')
- ->where('enterpriseId', $enterpriseId)
- ->column('userId');
- $profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
- if (empty($profileUserIds)) {
- return paginate_response([], 0, $page, $pageSize);
- }
- }
-
- // 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
- try {
- $dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
- $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
- } catch (\Throwable $e) {
- $dedupIds = Db::name('wechat_users')->column('id');
- $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
- }
- if (empty($dedupIds)) {
- return paginate_response([], 0, $page, $pageSize);
- }
-
- $baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
- // 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
- if (!empty($profileUserIds)) {
- $baseQuery->whereIn('id', $profileUserIds);
- }
- if ($where) {
- $baseQuery->where($where);
- }
-
- $total = (int) $baseQuery->count();
- $list = (clone $baseQuery)
- ->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
- ->order('createdAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- // 为每条用户附加测试统计(test_results.userId 对应 wechat_users.id)
- $ids = array_column($list, 'id');
- $testCounts = [];
- $lastTestAt = [];
- $testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
- $payStats = [];
- $enterpriseName = null;
- if ($enterpriseId) {
- $ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
- $enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
- }
- if (!empty($ids)) {
- // 测试统计严格按 test_results.enterpriseId 归属企业过滤
- $trBase = Db::name('test_results')->where('userId', 'in', $ids);
- if ($enterpriseId) {
- $trBase->where('enterpriseId', $enterpriseId);
- }
- $counts = (clone $trBase)
- ->group('userId')
- ->column('COUNT(*) as cnt', 'userId');
- $testCounts = $counts ?: [];
-
- $lastRows = (clone $trBase)
- ->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
- ->order('createdAt', 'desc')
- ->select();
- foreach ($lastRows as $row) {
- $uid = $row['userId'];
- if (!isset($lastTestAt[$uid])) {
- $lastTestAt[$uid] = $row['createdAt'];
- }
- if (!isset($testTypes[$uid])) {
- $testTypes[$uid] = [];
- }
- $testTypes[$uid][] = [
- 'testType' => $row['testType'],
- 'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
- 'createdAt' => $row['createdAt'],
- 'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
- ];
- }
- // 付款统计:user_profile(按当前企业过滤)
- try {
- $profilesQuery = Db::name('user_profile')
- ->where('userId', 'in', $ids);
- if ($enterpriseId) {
- $profilesQuery->where('enterpriseId', $enterpriseId);
- }
- $profiles = $profilesQuery
- ->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
- ->group('userId')
- ->select()
- ->toArray();
- foreach ($profiles as $p) {
- $uid = (int) ($p['userId'] ?? 0);
- if ($uid > 0) {
- $payStats[$uid] = [
- 'paidOrders' => (int) ($p['paidOrders'] ?? 0),
- 'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
- ];
- }
- }
- } catch (\Throwable $e) {
- $payStats = [];
- }
- }
-
- foreach ($list as &$row) {
- $id = $row['id'];
- $testsForUser = $testTypes[$id] ?? [];
- $row['username'] = $row['nickname'] ?? ('用户' . $id);
- $row['testCount'] = (int) ($testCounts[$id] ?? 0);
- $row['lastTestAt'] = $lastTestAt[$id] ?? null;
- $row['tests'] = $testsForUser;
- $row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
- $row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
- $row['discType'] = $this->extractResultType($testsForUser, 'disc');
- $row['faceType'] = $this->extractResultType($testsForUser, 'face');
- $row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
- $row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
- $row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
- $row['enterprise'] = $enterpriseName !== null ? $enterpriseName : '全部';
- $pay = $payStats[$id] ?? null;
- $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
- $row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
- }
-
- return paginate_response($list, $total, $page, $pageSize);
- }
-
- /**
- * 测试用户详情:基本信息 + 测试记录列表
- * GET /api/v1/admin/app-users/:id
- */
- public function detail($id)
- {
- $user = $this->request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- // admin / enterprise_admin 均只能查看本企业的用户
- $enterpriseId = $user['enterpriseId'] ?? null;
- if (!$enterpriseId) {
- $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
- $enterpriseId = $adminRow['enterpriseId'] ?? null;
- }
- if ($enterpriseId) {
- // 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
- $has = Db::name('user_profile')
- ->where('userId', $id)
- ->where('enterpriseId', $enterpriseId)
- ->find();
- if (!$has) {
- return error('无权限查看该用户', 403);
- }
- }
-
- $row = Db::name('wechat_users')->where('id', $id)->find();
- if (!$row) {
- return error('用户不存在', 404);
- }
-
- $data = [
- 'id' => (int) $row['id'],
- 'username' => $row['nickname'] ?? ('用户' . $row['id']),
- 'nickname' => $row['nickname'] ?? '',
- 'avatar' => $row['avatar'] ?? '',
- 'phone' => $row['phone'] ?? '',
- 'email' => '',
- 'gender' => (int) ($row['gender'] ?? 0),
- 'country' => $row['country'] ?? '',
- 'province' => $row['province'] ?? '',
- 'city' => $row['city'] ?? '',
- 'status' => (int) ($row['status'] ?? 1),
- 'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
- 'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
- 'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
- ];
-
- // 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
- $testQuery = Db::name('test_results')->where('userId', $id);
- if ($enterpriseId) {
- $testQuery->where('enterpriseId', $enterpriseId);
- }
- $tests = $testQuery
- ->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
- ->order('createdAt', 'desc')
- ->select()
- ->toArray();
- foreach ($tests as &$t) {
- $raw = $t['resultData'] ?? '';
- $t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
- $t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
- unset($t['testEnterpriseId']);
- }
-
- $data['testCount'] = count($tests);
- $data['testList'] = $tests;
- $data['mbtiType'] = $this->extractResultType($tests, 'mbti');
- $data['pdpType'] = $this->extractResultType($tests, 'pdp');
- $data['discType'] = $this->extractResultType($tests, 'disc');
- $data['faceType'] = $this->extractResultType($tests, 'face');
- $data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
- $data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
- $data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
-
- return success($data);
- }
-
- /**
- * 从测试记录中取出某类型的最近结果(result 可能是 JSON 字符串,取 type 或 result 字段)
- */
- private function extractResultType(array $tests, string $type): string
- {
- $targetType = strtolower($type);
- foreach ($tests as $t) {
- if (strtolower($t['testType'] ?? '') !== $targetType) {
- continue;
- }
- $result = $t['result'] ?? '';
- if (!is_string($result)) {
- continue;
- }
- $dec = json_decode($result, true);
- if (!is_array($dec)) {
- return $targetType === 'face' ? '人脸分析' : trim($result);
- }
-
- if ($targetType === 'face') {
- return '人脸分析';
- }
-
- if ($targetType === 'mbti') {
- return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
- }
-
- if ($targetType === 'disc') {
- $desc = $dec['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($dec['dominantType'])) {
- return (string) $dec['dominantType'];
- }
- return (string) ($dec['disc'] ?? '');
- }
-
- if ($targetType === 'pdp') {
- $desc = $dec['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($dec['dominantType'])) {
- return (string) $dec['dominantType'];
- }
- return (string) ($dec['pdp'] ?? '');
- }
-
- return (string) ($dec['type'] ?? $dec['result'] ?? '');
- }
- return '';
- }
-
- /**
- * 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
- */
- private function extractFaceSubType(array $tests, string $subType): string
- {
- $target = strtolower($subType);
- foreach ($tests as $t) {
- if (strtolower($t['testType'] ?? '') !== 'face') {
- continue;
- }
- $result = $t['result'] ?? '';
- if (!is_string($result)) {
- continue;
- }
- $dec = json_decode($result, true);
- if (!is_array($dec)) {
- continue;
- }
-
- if ($target === 'mbti') {
- if (!empty($dec['mbti']['type'])) {
- return (string) $dec['mbti']['type'];
- }
- if (!empty($dec['mbtiType'])) {
- return (string) $dec['mbtiType'];
- }
- } elseif ($target === 'disc') {
- if (!empty($dec['disc']['primary'])) {
- return (string) $dec['disc']['primary'];
- }
- if (!empty($dec['disc'])) {
- return (string) $dec['disc'];
- }
- } elseif ($target === 'pdp') {
- if (!empty($dec['pdp']['primary'])) {
- return (string) $dec['pdp']['primary'];
- }
- if (!empty($dec['pdp'])) {
- return (string) $dec['pdp'];
- }
- }
- }
- return '';
- }
-}
+request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ $page = (int) Request::param('page', 1);
+ $pageSize = (int) Request::param('pageSize', 20);
+ $pageSize = min(max($pageSize, 1), 100);
+ $keyword = trim(Request::param('keyword', ''));
+
+ $where = [];
+ if ($keyword !== '') {
+ $where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
+ }
+
+ // admin / enterprise_admin 均只能看本企业数据
+ $enterpriseId = $user['enterpriseId'] ?? null;
+ if (!$enterpriseId) {
+ // JWT 未含 enterpriseId 时回退查库(兼容旧 token)
+ $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
+ $enterpriseId = $adminRow['enterpriseId'] ?? null;
+ }
+
+ // 若有企业ID:先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
+ $profileUserIds = [];
+ if ($enterpriseId) {
+ $profileUserIds = Db::name('user_profile')
+ ->where('enterpriseId', $enterpriseId)
+ ->column('userId');
+ $profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
+ if (empty($profileUserIds)) {
+ return paginate_response([], 0, $page, $pageSize);
+ }
+ }
+
+ // 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
+ try {
+ $dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
+ $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
+ } catch (\Throwable $e) {
+ $dedupIds = Db::name('wechat_users')->column('id');
+ $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
+ }
+ if (empty($dedupIds)) {
+ return paginate_response([], 0, $page, $pageSize);
+ }
+
+ $baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
+ // 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
+ if (!empty($profileUserIds)) {
+ $baseQuery->whereIn('id', $profileUserIds);
+ }
+ if ($where) {
+ $baseQuery->where($where);
+ }
+
+ $total = (int) $baseQuery->count();
+ $list = (clone $baseQuery)
+ ->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
+ ->order('createdAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ // 为每条用户附加测试统计(test_results.userId 对应 wechat_users.id)
+ $ids = array_column($list, 'id');
+ $testCounts = [];
+ $lastTestAt = [];
+ $testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
+ $payStats = [];
+ $enterpriseName = null;
+ if ($enterpriseId) {
+ $ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
+ $enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
+ }
+ if (!empty($ids)) {
+ // 测试统计严格按 test_results.enterpriseId 归属企业过滤
+ $trBase = Db::name('test_results')->where('userId', 'in', $ids);
+ if ($enterpriseId) {
+ $trBase->where('enterpriseId', $enterpriseId);
+ }
+ $counts = (clone $trBase)
+ ->group('userId')
+ ->column('COUNT(*) as cnt', 'userId');
+ $testCounts = $counts ?: [];
+
+ $lastRows = (clone $trBase)
+ ->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
+ ->order('createdAt', 'desc')
+ ->select();
+ foreach ($lastRows as $row) {
+ $uid = $row['userId'];
+ if (!isset($lastTestAt[$uid])) {
+ $lastTestAt[$uid] = $row['createdAt'];
+ }
+ if (!isset($testTypes[$uid])) {
+ $testTypes[$uid] = [];
+ }
+ $testTypes[$uid][] = [
+ 'testType' => $row['testType'],
+ 'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
+ 'createdAt' => $row['createdAt'],
+ 'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
+ ];
+ }
+ // 付款统计:user_profile(按当前企业过滤)
+ try {
+ $profilesQuery = Db::name('user_profile')
+ ->where('userId', 'in', $ids);
+ if ($enterpriseId) {
+ $profilesQuery->where('enterpriseId', $enterpriseId);
+ }
+ $profiles = $profilesQuery
+ ->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
+ ->group('userId')
+ ->select()
+ ->toArray();
+ foreach ($profiles as $p) {
+ $uid = (int) ($p['userId'] ?? 0);
+ if ($uid > 0) {
+ $payStats[$uid] = [
+ 'paidOrders' => (int) ($p['paidOrders'] ?? 0),
+ 'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
+ ];
+ }
+ }
+ } catch (\Throwable $e) {
+ $payStats = [];
+ }
+ }
+
+ foreach ($list as &$row) {
+ $id = $row['id'];
+ $testsForUser = $testTypes[$id] ?? [];
+ $row['username'] = $row['nickname'] ?? ('用户' . $id);
+ $row['testCount'] = (int) ($testCounts[$id] ?? 0);
+ $row['lastTestAt'] = $lastTestAt[$id] ?? null;
+ $row['tests'] = $testsForUser;
+ $row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
+ $row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
+ $row['discType'] = $this->extractResultType($testsForUser, 'disc');
+ $row['faceType'] = $this->extractResultType($testsForUser, 'face');
+ $row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
+ $row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
+ $row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
+ $row['enterprise'] = $enterpriseName !== null ? $enterpriseName : '全部';
+ $pay = $payStats[$id] ?? null;
+ $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
+ $row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
+ }
+
+ return paginate_response($list, $total, $page, $pageSize);
+ }
+
+ /**
+ * 测试用户详情:基本信息 + 测试记录列表
+ * GET /api/v1/admin/app-users/:id
+ */
+ public function detail($id)
+ {
+ $user = $this->request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ // admin / enterprise_admin 均只能查看本企业的用户
+ $enterpriseId = $user['enterpriseId'] ?? null;
+ if (!$enterpriseId) {
+ $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
+ $enterpriseId = $adminRow['enterpriseId'] ?? null;
+ }
+ if ($enterpriseId) {
+ // 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
+ $has = Db::name('user_profile')
+ ->where('userId', $id)
+ ->where('enterpriseId', $enterpriseId)
+ ->find();
+ if (!$has) {
+ return error('无权限查看该用户', 403);
+ }
+ }
+
+ $row = Db::name('wechat_users')->where('id', $id)->find();
+ if (!$row) {
+ return error('用户不存在', 404);
+ }
+
+ $data = [
+ 'id' => (int) $row['id'],
+ 'username' => $row['nickname'] ?? ('用户' . $row['id']),
+ 'nickname' => $row['nickname'] ?? '',
+ 'avatar' => $row['avatar'] ?? '',
+ 'phone' => $row['phone'] ?? '',
+ 'email' => '',
+ 'gender' => (int) ($row['gender'] ?? 0),
+ 'country' => $row['country'] ?? '',
+ 'province' => $row['province'] ?? '',
+ 'city' => $row['city'] ?? '',
+ 'status' => (int) ($row['status'] ?? 1),
+ 'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
+ 'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
+ 'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
+ ];
+
+ // 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
+ $testQuery = Db::name('test_results')->where('userId', $id);
+ if ($enterpriseId) {
+ $testQuery->where('enterpriseId', $enterpriseId);
+ }
+ $tests = $testQuery
+ ->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
+ ->order('createdAt', 'desc')
+ ->select()
+ ->toArray();
+ foreach ($tests as &$t) {
+ $raw = $t['resultData'] ?? '';
+ $t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
+ $t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
+ unset($t['testEnterpriseId']);
+ }
+
+ $data['testCount'] = count($tests);
+ $data['testList'] = $tests;
+ $data['mbtiType'] = $this->extractResultType($tests, 'mbti');
+ $data['pdpType'] = $this->extractResultType($tests, 'pdp');
+ $data['discType'] = $this->extractResultType($tests, 'disc');
+ $data['faceType'] = $this->extractResultType($tests, 'face');
+ $data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
+ $data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
+ $data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
+
+ return success($data);
+ }
+}
diff --git a/api/app/controller/admin/Dashboard.php b/api/app/controller/admin/Dashboard.php
index b8c81d9..fe068d4 100644
--- a/api/app/controller/admin/Dashboard.php
+++ b/api/app/controller/admin/Dashboard.php
@@ -1,179 +1,277 @@
-request->user ?? null;
-
- if (!$user) {
- return error('未登录', 401);
- }
-
- // 验证是否为管理员
- if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- try {
- // admin / enterprise_admin 均只统计本企业数据
- $enterpriseId = $user['enterpriseId'] ?? null;
- if (!$enterpriseId) {
- $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
- $enterpriseId = $adminRow['enterpriseId'] ?? null;
- }
-
- // 企业用户 ID 集合(用于后续统计个人版测试)
- $enterpriseUserIds = [];
- if ($enterpriseId) {
- $enterpriseUserIds = Db::name('wechat_users')
- ->where('enterpriseId', $enterpriseId)
- ->column('id');
- $enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
- }
-
- // 总用户数:wechat_users.enterpriseId = 本企业
- if ($enterpriseId) {
- $totalUsers = count($enterpriseUserIds);
- } else {
- try {
- $totalUsers = (int) Db::name('wechat_users')->count('openid', true);
- } catch (\Throwable $e) {
- $totalUsers = (int) Db::name('wechat_users')->count();
- }
- }
-
- // 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
- if ($enterpriseId) {
- $testsCompleted = (int) Db::name('test_results')
- ->where('enterpriseId', $enterpriseId)
- ->count();
- } else {
- $testsCompleted = (int) Db::name('test_results')->count();
- }
-
- // 今日活跃用户数
- $todayStart = strtotime(date('Y-m-d 00:00:00'));
- $todayEnd = strtotime(date('Y-m-d 23:59:59'));
- $activeQuery = Db::name('test_results')
- ->where('createdAt', '>=', $todayStart)
- ->where('createdAt', '<=', $todayEnd);
- if ($enterpriseId) {
- $activeQuery->where('enterpriseId', $enterpriseId);
- $activeIds = $activeQuery->distinct(true)->column('userId');
- $activeToday = count(array_filter($activeIds));
- } else {
- $activeIds = $activeQuery->distinct(true)->column('userId');
- $activeToday = count(array_filter($activeIds));
- }
-
- // 待审核(暂返回0)
- $pendingReviews = 0;
-
- // 最近 14 天测试趋势
- $days = 14;
- $startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
- $trendQuery = Db::name('test_results')
- ->where('createdAt', '>=', $startDate)
- ->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
- if ($enterpriseId) {
- $trendQuery->where('enterpriseId', $enterpriseId);
- }
- $trendRows = $trendQuery
- ->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
- ->group('d,testType')
- ->order('d', 'asc')
- ->select()
- ->toArray();
-
- // 组装为按日期汇总的数组
- $trendMap = [];
- foreach ($trendRows as $row) {
- $d = $row['d'];
- $type = $row['testType'];
- $cnt = (int) ($row['c'] ?? 0);
- if (!isset($trendMap[$d])) {
- $trendMap[$d] = [
- 'date' => $d,
- 'face' => 0,
- 'mbti' => 0,
- 'disc' => 0,
- 'pdp' => 0,
- 'total' => 0,
- ];
- }
- if (in_array($type, ['face', 'mbti', 'disc', 'pdp'], true)) {
- $trendMap[$d][$type] += $cnt;
- $trendMap[$d]['total'] += $cnt;
- }
- }
-
- // 补齐没有数据的日期
- $trendData = [];
- for ($i = 0; $i < $days; $i++) {
- $d = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days'));
- if (isset($trendMap[$d])) {
- $trendData[] = $trendMap[$d];
- } else {
- $trendData[] = [
- 'date' => $d,
- 'face' => 0,
- 'mbti' => 0,
- 'disc' => 0,
- 'pdp' => 0,
- 'total' => 0,
- ];
- }
- }
-
- return success([
- 'totalUsers' => $totalUsers,
- 'testsCompleted' => $testsCompleted,
- 'activeToday' => $activeToday,
- 'pendingReviews' => $pendingReviews,
- 'testTrends' => $trendData,
- ]);
- } catch (\Exception $e) {
- return error('获取统计数据失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 格式化时间
- * @param int $timestamp
- * @return string
- */
- private function formatTime($timestamp)
- {
- if (!$timestamp) {
- return '';
- }
-
- $now = time();
- $diff = $now - $timestamp;
-
- if ($diff < 60) {
- return '刚刚';
- } elseif ($diff < 3600) {
- return floor($diff / 60) . '分钟前';
- } elseif ($diff < 86400) {
- return floor($diff / 3600) . '小时前';
- } elseif ($diff < 604800) {
- return floor($diff / 86400) . '天前';
- } else {
- return date('Y-m-d H:i', $timestamp);
- }
- }
-}
+request->user ?? null;
+
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ // 验证是否为管理员
+ if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ try {
+ // admin / enterprise_admin 均只统计本企业数据
+ $enterpriseId = $user['enterpriseId'] ?? null;
+ if (!$enterpriseId) {
+ $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
+ $enterpriseId = $adminRow['enterpriseId'] ?? null;
+ }
+
+ // 企业用户 ID 集合(用于后续统计个人版测试)
+ $enterpriseUserIds = [];
+ if ($enterpriseId) {
+ $enterpriseUserIds = Db::name('wechat_users')
+ ->where('enterpriseId', $enterpriseId)
+ ->column('id');
+ $enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
+ }
+
+ // 总用户数:wechat_users.enterpriseId = 本企业
+ if ($enterpriseId) {
+ $totalUsers = count($enterpriseUserIds);
+ } else {
+ try {
+ $totalUsers = (int) Db::name('wechat_users')->count('openid', true);
+ } catch (\Throwable $e) {
+ $totalUsers = (int) Db::name('wechat_users')->count();
+ }
+ }
+
+ // 已完成测试数:严格按 test_results.enterpriseId 归属企业统计
+ if ($enterpriseId) {
+ $testsCompleted = (int) Db::name('test_results')
+ ->where('enterpriseId', $enterpriseId)
+ ->count();
+ } else {
+ $testsCompleted = (int) Db::name('test_results')->count();
+ }
+
+ // 今日活跃用户数
+ $todayStart = strtotime(date('Y-m-d 00:00:00'));
+ $todayEnd = strtotime(date('Y-m-d 23:59:59'));
+ $activeQuery = Db::name('test_results')
+ ->where('createdAt', '>=', $todayStart)
+ ->where('createdAt', '<=', $todayEnd);
+ if ($enterpriseId) {
+ $activeQuery->where('enterpriseId', $enterpriseId);
+ $activeIds = $activeQuery->distinct(true)->column('userId');
+ $activeToday = count(array_filter($activeIds));
+ } else {
+ $activeIds = $activeQuery->distinct(true)->column('userId');
+ $activeToday = count(array_filter($activeIds));
+ }
+
+ // 待审核(暂返回0)
+ $pendingReviews = 0;
+
+ // 最近 14 天测试趋势
+ $days = 14;
+ $startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
+ $trendQuery = Db::name('test_results')
+ ->where('createdAt', '>=', $startDate)
+ ->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
+ if ($enterpriseId) {
+ $trendQuery->where('enterpriseId', $enterpriseId);
+ }
+ $trendRows = $trendQuery
+ ->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
+ ->group('d,testType')
+ ->order('d', 'asc')
+ ->select()
+ ->toArray();
+
+ // 组装为按日期汇总的数组
+ $trendMap = [];
+ foreach ($trendRows as $row) {
+ $d = $row['d'];
+ $type = $row['testType'];
+ $cnt = (int) ($row['c'] ?? 0);
+ if (!isset($trendMap[$d])) {
+ $trendMap[$d] = [
+ 'date' => $d,
+ 'face' => 0,
+ 'mbti' => 0,
+ 'disc' => 0,
+ 'pdp' => 0,
+ 'total' => 0,
+ ];
+ }
+ if (in_array($type, ['face', 'mbti', 'disc', 'pdp'], true)) {
+ $trendMap[$d][$type] += $cnt;
+ $trendMap[$d]['total'] += $cnt;
+ }
+ }
+
+ // 补齐没有数据的日期
+ $trendData = [];
+ for ($i = 0; $i < $days; $i++) {
+ $d = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days'));
+ if (isset($trendMap[$d])) {
+ $trendData[] = $trendMap[$d];
+ } else {
+ $trendData[] = [
+ 'date' => $d,
+ 'face' => 0,
+ 'mbti' => 0,
+ 'disc' => 0,
+ 'pdp' => 0,
+ 'total' => 0,
+ ];
+ }
+ }
+
+ $topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
+
+ return success([
+ 'totalUsers' => $totalUsers,
+ 'testsCompleted' => $testsCompleted,
+ 'activeToday' => $activeToday,
+ 'pendingReviews' => $pendingReviews,
+ 'testTrends' => $trendData,
+ 'topTestUsers' => $topTestUsers,
+ ]);
+ } catch (\Exception $e) {
+ return error('获取统计数据失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 按测试完成次数排序,取前 N 名小程序用户(与列表页口径一致:test_results 按企业过滤)
+ */
+ private function buildTopTestUsers(?int $enterpriseId, int $limit = 10): array
+ {
+ $limit = min(max($limit, 1), 50);
+ $q = Db::name('test_results')->field('userId, COUNT(*) as cnt')->group('userId')->order('cnt', 'desc')->limit($limit);
+ if ($enterpriseId) {
+ $q->where('enterpriseId', $enterpriseId);
+ }
+ $rankRows = $q->select()->toArray();
+ if (empty($rankRows)) {
+ return [];
+ }
+ $uids = array_values(array_filter(array_map(static function ($r) {
+ return (int) ($r['userId'] ?? 0);
+ }, $rankRows)));
+ $countMap = [];
+ foreach ($rankRows as $r) {
+ $uid = (int) ($r['userId'] ?? 0);
+ if ($uid > 0) {
+ $countMap[$uid] = (int) ($r['cnt'] ?? 0);
+ }
+ }
+ if (empty($uids)) {
+ return [];
+ }
+
+ $users = Db::name('wechat_users')
+ ->whereIn('id', $uids)
+ ->field('id,nickname,phone,avatar,createdAt')
+ ->select()
+ ->toArray();
+ $userMap = [];
+ foreach ($users as $u) {
+ $userMap[(int) $u['id']] = $u;
+ }
+
+ $trQuery = Db::name('test_results')->whereIn('userId', $uids);
+ if ($enterpriseId) {
+ $trQuery->where('enterpriseId', $enterpriseId);
+ }
+ $testRows = $trQuery
+ ->field('userId, testType, resultData, createdAt')
+ ->order('createdAt', 'desc')
+ ->select()
+ ->toArray();
+
+ $testsByUser = [];
+ foreach ($testRows as $row) {
+ $uid = (int) ($row['userId'] ?? 0);
+ if ($uid <= 0) {
+ continue;
+ }
+ if (!isset($testsByUser[$uid])) {
+ $testsByUser[$uid] = [];
+ }
+ $raw = $row['resultData'] ?? '';
+ $testsByUser[$uid][] = [
+ 'testType' => $row['testType'] ?? '',
+ 'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
+ 'createdAt' => (int) ($row['createdAt'] ?? 0),
+ ];
+ }
+
+ $out = [];
+ foreach ($uids as $uid) {
+ $wu = $userMap[$uid] ?? null;
+ $tests = $testsByUser[$uid] ?? [];
+ $lastAt = 0;
+ foreach ($tests as $t) {
+ $lastAt = max($lastAt, (int) ($t['createdAt'] ?? 0));
+ }
+ $out[] = [
+ 'id' => $uid,
+ 'username' => $wu ? ($wu['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid),
+ 'nickname' => $wu ? ($wu['nickname'] ?? '') : '',
+ 'phone' => $wu ? ($wu['phone'] ?? '') : '',
+ 'avatar' => $wu ? ($wu['avatar'] ?? '') : '',
+ 'testCount' => $countMap[$uid] ?? 0,
+ 'lastTestAt' => $lastAt > 0 ? $lastAt : null,
+ 'mbtiType' => $this->extractResultType($tests, 'mbti'),
+ 'pdpType' => $this->extractResultType($tests, 'pdp'),
+ 'discType' => $this->extractResultType($tests, 'disc'),
+ 'faceMbtiType' => $this->extractFaceSubType($tests, 'mbti'),
+ 'faceDiscType' => $this->extractFaceSubType($tests, 'disc'),
+ 'facePdpType' => $this->extractFaceSubType($tests, 'pdp'),
+ ];
+ }
+
+ return $out;
+ }
+
+ /**
+ * 格式化时间
+ * @param int $timestamp
+ * @return string
+ */
+ private function formatTime($timestamp)
+ {
+ if (!$timestamp) {
+ return '';
+ }
+
+ $now = time();
+ $diff = $now - $timestamp;
+
+ if ($diff < 60) {
+ return '刚刚';
+ } elseif ($diff < 3600) {
+ return floor($diff / 60) . '分钟前';
+ } elseif ($diff < 86400) {
+ return floor($diff / 3600) . '小时前';
+ } elseif ($diff < 604800) {
+ return floor($diff / 86400) . '天前';
+ } else {
+ return date('Y-m-d H:i', $timestamp);
+ }
+ }
+}
diff --git a/api/app/controller/admin/Order.php b/api/app/controller/admin/Order.php
index 38535ba..c3ab79c 100644
--- a/api/app/controller/admin/Order.php
+++ b/api/app/controller/admin/Order.php
@@ -1,168 +1,171 @@
-request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- $page = (int) Request::param('page', 1);
- $pageSize = (int) Request::param('pageSize', 20);
- $pageSize = min(max($pageSize, 1), 100);
- $keyword = trim(Request::param('keyword', ''));
- $status = trim(Request::param('status', ''));
- $productType = trim(Request::param('productType', ''));
-
- // admin / enterprise_admin 均只能看本企业订单
- $enterpriseId = $user['enterpriseId'] ?? null;
- if (!$enterpriseId) {
- $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
- $enterpriseId = $adminRow['enterpriseId'] ?? null;
- }
-
- $query = Db::name('orders');
-
- if ($enterpriseId !== null) {
- $query->where('enterpriseId', $enterpriseId);
- }
- if ($status !== '') {
- $query->where('status', $status);
- }
- if ($productType !== '') {
- $query->where('productType', $productType);
- }
- if ($keyword !== '') {
- if (is_numeric($keyword)) {
- $query->where(function ($q) use ($keyword) {
- $q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
- });
- } else {
- $userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
- $userIdsMatch = array_values(array_filter($userIdsMatch));
- $query->where(function ($q) use ($keyword, $userIdsMatch) {
- $q->whereLike('orderNo', '%' . $keyword . '%');
- if (!empty($userIdsMatch)) {
- $q->whereOr('userId', 'in', $userIdsMatch);
- }
- });
- }
- }
-
- $query->order('createdAt', 'desc');
- $total = (int) (clone $query)->count();
- $list = (clone $query)->page($page, $pageSize)->select()->toArray();
-
- $userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
- $usersMap = [];
- if (!empty($userIds)) {
- $users = Db::name('wechat_users')
- ->where('id', 'in', $userIds)
- ->field('id, nickname, phone')
- ->select()
- ->toArray();
- foreach ($users as $u) {
- $usersMap[(int) $u['id']] = $u;
- }
- }
-
- $orderIds = array_column($list, 'id');
- $testsByOrder = [];
- if (!empty($orderIds)) {
- $tests = Db::name('test_results')
- ->where('orderId', 'in', $orderIds)
- ->field('id, orderId, userId, testType, resultData, createdAt')
- ->order('createdAt', 'desc')
- ->select()
- ->toArray();
- foreach ($tests as $t) {
- $oid = (int) ($t['orderId'] ?? 0);
- if ($oid <= 0) {
- continue;
- }
- if (!isset($testsByOrder[$oid])) {
- $testsByOrder[$oid] = [];
- }
- $raw = $t['resultData'] ?? '';
- $resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
- $testsByOrder[$oid][] = [
- 'id' => (int) $t['id'],
- 'testType' => $t['testType'] ?? '',
- 'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
- 'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
- ];
- }
- }
-
- foreach ($list as &$row) {
- $uid = (int) ($row['userId'] ?? 0);
- $u = $usersMap[$uid] ?? null;
- $row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
- $row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
- $row['testData'] = $testsByOrder[$row['id']] ?? [];
- }
-
- return paginate_response($list, $total, $page, $pageSize);
- }
-
- /**
- * 从 resultData 字符串中提取简要结果(用于列表展示)
- */
- private function extractResultSummary(string $testType, string $resultStr): string
- {
- if ($resultStr === '') {
- return '-';
- }
- $data = json_decode($resultStr, true);
- if (!is_array($data)) {
- return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
- }
- $type = strtolower($testType);
-
- if ($type === 'mbti') {
- return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
- }
- if ($type === 'disc') {
- $desc = $data['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($data['dominantType'])) {
- return (string) $data['dominantType'] . '型';
- }
- return (string) ($data['disc'] ?? '');
- }
- if ($type === 'pdp') {
- $desc = $data['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($data['dominantType'])) {
- return (string) $data['dominantType'];
- }
- return (string) ($data['pdp'] ?? '');
- }
- if ($type === 'face' || $type === 'ai') {
- return '人脸分析';
- }
-
- return (string) ($data['type'] ?? $data['result'] ?? '');
- }
-}
+request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin', 'superadmin'])) {
+ return error('无权限访问', 403);
+ }
+
+ $page = (int) Request::param('page', 1);
+ $pageSize = (int) Request::param('pageSize', 20);
+ $pageSize = min(max($pageSize, 1), 100);
+ $keyword = trim(Request::param('keyword', ''));
+ $status = trim(Request::param('status', ''));
+ $productType = trim(Request::param('productType', ''));
+
+ // 超管:全平台订单;其余管理员仅本企业
+ $enterpriseId = null;
+ if (($user['role'] ?? '') !== 'superadmin') {
+ $enterpriseId = $user['enterpriseId'] ?? null;
+ if (!$enterpriseId) {
+ $adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
+ $enterpriseId = $adminRow['enterpriseId'] ?? null;
+ }
+ }
+
+ $query = Db::name('orders');
+
+ if ($enterpriseId !== null) {
+ $query->where('enterpriseId', $enterpriseId);
+ }
+ if ($status !== '') {
+ $query->where('status', $status);
+ }
+ if ($productType !== '') {
+ $query->where('productType', $productType);
+ }
+ if ($keyword !== '') {
+ if (is_numeric($keyword)) {
+ $query->where(function ($q) use ($keyword) {
+ $q->whereLike('orderNo', '%' . $keyword . '%')->whereOr('userId', (int) $keyword);
+ });
+ } else {
+ $userIdsMatch = Db::name('wechat_users')->where('nickname|phone', 'like', '%' . $keyword . '%')->column('id');
+ $userIdsMatch = array_values(array_filter($userIdsMatch));
+ $query->where(function ($q) use ($keyword, $userIdsMatch) {
+ $q->whereLike('orderNo', '%' . $keyword . '%');
+ if (!empty($userIdsMatch)) {
+ $q->whereOr('userId', 'in', $userIdsMatch);
+ }
+ });
+ }
+ }
+
+ $query->order('createdAt', 'desc');
+ $total = (int) (clone $query)->count();
+ $list = (clone $query)->page($page, $pageSize)->select()->toArray();
+
+ $userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
+ $usersMap = [];
+ if (!empty($userIds)) {
+ $users = Db::name('wechat_users')
+ ->where('id', 'in', $userIds)
+ ->field('id, nickname, phone')
+ ->select()
+ ->toArray();
+ foreach ($users as $u) {
+ $usersMap[(int) $u['id']] = $u;
+ }
+ }
+
+ $orderIds = array_column($list, 'id');
+ $testsByOrder = [];
+ if (!empty($orderIds)) {
+ $tests = Db::name('test_results')
+ ->where('orderId', 'in', $orderIds)
+ ->field('id, orderId, userId, testType, resultData, createdAt')
+ ->order('createdAt', 'desc')
+ ->select()
+ ->toArray();
+ foreach ($tests as $t) {
+ $oid = (int) ($t['orderId'] ?? 0);
+ if ($oid <= 0) {
+ continue;
+ }
+ if (!isset($testsByOrder[$oid])) {
+ $testsByOrder[$oid] = [];
+ }
+ $raw = $t['resultData'] ?? '';
+ $resultStr = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
+ $testsByOrder[$oid][] = [
+ 'id' => (int) $t['id'],
+ 'testType' => $t['testType'] ?? '',
+ 'resultSummary' => $this->extractResultSummary($t['testType'] ?? '', $resultStr),
+ 'createdAt' => isset($t['createdAt']) ? (int) $t['createdAt'] : null,
+ ];
+ }
+ }
+
+ foreach ($list as &$row) {
+ $uid = (int) ($row['userId'] ?? 0);
+ $u = $usersMap[$uid] ?? null;
+ $row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
+ $row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
+ $row['testData'] = $testsByOrder[$row['id']] ?? [];
+ }
+
+ return paginate_response($list, $total, $page, $pageSize);
+ }
+
+ /**
+ * 从 resultData 字符串中提取简要结果(用于列表展示)
+ */
+ private function extractResultSummary(string $testType, string $resultStr): string
+ {
+ if ($resultStr === '') {
+ return '-';
+ }
+ $data = json_decode($resultStr, true);
+ if (!is_array($data)) {
+ return mb_substr($resultStr, 0, 30) . (mb_strlen($resultStr) > 30 ? '…' : '');
+ }
+ $type = strtolower($testType);
+
+ if ($type === 'mbti') {
+ return (string) ($data['mbtiType'] ?? $data['type'] ?? $data['result'] ?? '');
+ }
+ if ($type === 'disc') {
+ $desc = $data['description']['type'] ?? null;
+ if (is_string($desc) && $desc !== '') {
+ return $desc;
+ }
+ if (!empty($data['dominantType'])) {
+ return (string) $data['dominantType'] . '型';
+ }
+ return (string) ($data['disc'] ?? '');
+ }
+ if ($type === 'pdp') {
+ $desc = $data['description']['type'] ?? null;
+ if (is_string($desc) && $desc !== '') {
+ return $desc;
+ }
+ if (!empty($data['dominantType'])) {
+ return (string) $data['dominantType'];
+ }
+ return (string) ($data['pdp'] ?? '');
+ }
+ if ($type === 'face' || $type === 'ai') {
+ return '人脸分析';
+ }
+
+ return (string) ($data['type'] ?? $data['result'] ?? '');
+ }
+}
diff --git a/api/app/controller/admin/Question.php b/api/app/controller/admin/Question.php
index e2f19a4..4a2836b 100644
--- a/api/app/controller/admin/Question.php
+++ b/api/app/controller/admin/Question.php
@@ -48,10 +48,14 @@ class Question extends BaseController
// 如果指定了企业ID,优先查询企业题库
// 如果没有企业题库,则查询超管题库(enterpriseId = NULL)
if ($enterpriseId !== null) {
- // 先检查企业是否有自己的题库
- $enterpriseQuestionCount = QuestionModel::where('enterpriseId', $enterpriseId)
- ->where('type', $type ?: ['mbti', 'disc', 'pdp'])
- ->count();
+ // 先检查企业是否有自己的题库(未指定 type 时需统计 mbti/disc/pdp 三类)
+ $countQuery = QuestionModel::where('enterpriseId', $enterpriseId);
+ if ($type !== '') {
+ $countQuery->where('type', $type);
+ } else {
+ $countQuery->whereIn('type', ['mbti', 'disc', 'pdp']);
+ }
+ $enterpriseQuestionCount = $countQuery->count();
if ($enterpriseQuestionCount > 0) {
// 使用企业题库
diff --git a/api/app/controller/admin/Settings.php b/api/app/controller/admin/Settings.php
index f265e4c..802752e 100644
--- a/api/app/controller/admin/Settings.php
+++ b/api/app/controller/admin/Settings.php
@@ -1,418 +1,486 @@
-request->user ?? null;
-
- if (!$user) {
- return error('未登录', 401);
- }
-
- // 验证是否为管理员
- if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- try {
- // 获取当前管理员用户名
- $jwtUsername = $user['username'] ?? null;
- $username = 'admin';
-
- if ($jwtUsername) {
- $currentUser = UserModel::where('username', $jwtUsername)
- ->whereIn('role', ['admin', 'enterprise_admin'])
- ->find();
- if ($currentUser) {
- $username = $currentUser->username;
- } else {
- $username = $jwtUsername;
- }
- }
-
- return success([
- 'username' => $username
- ]);
- } catch (\Exception $e) {
- return error('获取配置失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 获取可用字体列表
- * GET /api/v1/admin/settings/fonts
- */
- public function getFonts()
- {
- $user = $this->request->user ?? null;
- if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
- $fonts = \app\common\service\PosterService::getAvailableFonts();
- return success([
- 'fonts' => $fonts,
- 'fontDir' => root_path() . 'public/fonts/',
- 'dirExist' => is_dir(root_path() . 'public/fonts/'),
- ]);
- }
-
- /**
- * 获取海报配置
- * GET /api/v1/admin/settings/poster
- * 有 enterpriseId 则读企业专属行,否则读全局(enterprise_id=0)
- */
- public function getPosterConfig()
- {
- $user = $this->request->user ?? null;
- if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- $eid = (int)($user['enterpriseId'] ?? 0);
- $row = self::getConfig('poster_config', $eid);
- $poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
- return success(['poster' => $poster]);
- }
-
- /**
- * 保存海报配置
- * PUT /api/v1/admin/settings/poster
- */
- public function updatePosterConfig()
- {
- $user = $this->request->user ?? null;
- if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- $input = json_decode($this->request->getContent(), true);
- if (!is_array($input)) {
- $input = [];
- }
- $data = [
- 'bgColor' => $input['bgColor'] ?? '#ffffff',
- 'bgImage' => $input['bgImage'] ?? '',
- 'elements' => $input['elements'] ?? []
- ];
- $eid = (int)($user['enterpriseId'] ?? 0);
-
- try {
- self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
- return success(null, '海报配置已保存');
- } catch (\Exception $e) {
- return error('保存失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 读取配置:key + enterprise_id,有企业专属则取,否则降级到 enterprise_id=0
- */
- private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
- {
- $row = Db::name('system_config')
- ->where('key', $key)
- ->where('enterprise_id', $enterpriseId)
- ->find();
- if ($row && !empty($row['value'])) {
- $val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
- if (is_array($val)) return $val;
- }
- if ($fallbackGlobal && $enterpriseId > 0) {
- $row = Db::name('system_config')
- ->where('key', $key)
- ->where('enterprise_id', 0)
- ->find();
- if ($row && !empty($row['value'])) {
- $val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
- if (is_array($val)) return $val;
- }
- }
- return null;
- }
-
- /**
- * 保存配置:key + enterprise_id,存在则 update,否则 insert
- */
- private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
- {
- $now = time();
- $json = json_encode($value, JSON_UNESCAPED_UNICODE);
- $exists = Db::name('system_config')
- ->where('key', $key)
- ->where('enterprise_id', $enterpriseId)
- ->find();
- if ($exists) {
- Db::name('system_config')
- ->where('key', $key)
- ->where('enterprise_id', $enterpriseId)
- ->update(['value' => $json, 'updatedAt' => $now]);
- } else {
- Db::name('system_config')->insert([
- 'key' => $key,
- 'enterprise_id' => $enterpriseId,
- 'value' => $json,
- 'description' => $description,
- 'createdAt' => $now,
- 'updatedAt' => $now,
- ]);
- }
- }
-
- /**
- * 安全解码 JSON(处理可能的多重编码)
- */
- private static function decodeJsonSafe($raw): ?array
- {
- if (!$raw) return null;
- $val = $raw;
- for ($i = 0; $i < 5 && is_string($val); $i++) {
- $decoded = json_decode($val, true);
- if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break;
- $val = $decoded;
- }
- return is_array($val) ? $val : null;
- }
-
- /**
- * 获取小程序配置
- * 读取全局 text_config(enterprise_id=0)作为默认值,再用企业专属行覆盖
- * GET /api/v1/admin/settings/miniprogram
- */
- public function getMiniprogramConfig()
- {
- $user = $this->request->user ?? null;
- if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- try {
- $eid = (int)($user['enterpriseId'] ?? 0);
-
- // 全局小程序名称(仅超管可改,此处只读)
- $miniprogramName = '神仙团队AI性格测试';
- $siteInfo = Db::name('system_config')
- ->where('key', 'site_info')
- ->where('enterprise_id', 0)
- ->find();
- if ($siteInfo && !empty($siteInfo['value'])) {
- $val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
- $miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
- }
-
- $tcDefaults = [
- 'analyzingTitle' => '正在分析中',
- 'startButtonText' => '开始面相测试',
- 'startButtonEnterprise' => '开始面部测试',
- 'reportTitle' => '分析报告',
- 'aiAnalysisText' => '智能分析',
- ];
-
- // 全局文案(enterprise_id=0)作为基础
- $globalTc = self::getConfig('text_config', 0);
- $textConfigData = $globalTc
- ? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
- : $tcDefaults;
-
- // 企业专属文案 + 小程序名称 覆盖
- if ($eid > 0) {
- $eidTc = self::getConfig('text_config', $eid);
- if ($eidTc) {
- $textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
- if (!empty($eidTc['miniprogramName'])) {
- $miniprogramName = (string) $eidTc['miniprogramName'];
- }
- }
- }
-
- return success([
- 'miniprogramName' => $miniprogramName,
- 'textConfig' => $textConfigData,
- ]);
- } catch (\Exception $e) {
- return error('获取配置失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 更新小程序配置
- * 写入 text_config 行:enterprise_id={eid}(有企业)或 0(无企业)
- * PUT /api/v1/admin/settings/miniprogram
- */
- public function updateMiniprogramConfig()
- {
- $user = $this->request->user ?? null;
- if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- $rawBody = $this->request->getContent();
- if (empty($rawBody)) {
- $rawBody = file_get_contents('php://input');
- }
- $input = $rawBody ? json_decode($rawBody, true) : null;
- if (!is_array($input)) {
- $input = [
- 'miniprogramName' => Request::param('miniprogramName', ''),
- 'textConfig' => Request::param('textConfig', []),
- ];
- }
-
- $miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
- $textConfig = $input['textConfig'] ?? [];
-
- if ($miniprogramName === '') {
- return error('小程序名称不能为空', 400);
- }
-
- $tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
- $tcDefaults = [
- 'analyzingTitle' => '正在分析中',
- 'startButtonText' => '开始面相测试',
- 'startButtonEnterprise' => '开始面部测试',
- 'reportTitle' => '分析报告',
- 'aiAnalysisText' => '智能分析',
- ];
- $tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
- $tcMerge = array_merge($tcDefaults, $tcData);
- $eid = (int)($user['enterpriseId'] ?? 0);
-
- try {
- // eid=0:更新 site_info 的小程序名称(全局)
- if ($eid === 0) {
- $siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
- $siteInfo = $siteRow && !empty($siteRow['value'])
- ? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
- : [];
- $siteInfo = is_array($siteInfo) ? $siteInfo : [];
- $siteInfo['miniprogramName'] = $miniprogramName;
- $siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
- $siteInfo['updatedAt'] = time();
- self::saveConfig('site_info', $siteInfo, 0, '站点信息');
- } else {
- // 企业专属:把 miniprogramName 一并写入 text_config
- $tcMerge['miniprogramName'] = $miniprogramName;
- }
-
- // 统一写到 text_config(企业行已含 miniprogramName,全局行不含)
- self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid})" : '小程序文案配置(全局)');
- return success([
- 'miniprogramName' => $miniprogramName,
- 'textConfig' => $tcMerge,
- ], '小程序配置已保存');
- } catch (\Exception $e) {
- return error('保存失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 更新管理员账户信息
- * @return \think\response\Json
- */
- public function updateCredentials()
- {
- $user = $this->request->user ?? null;
-
- if (!$user) {
- return error('未登录', 401);
- }
-
- // 验证是否为管理员
- if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
- return error('无权限访问', 403);
- }
-
- // 兼容 axios JSON PUT 与表单提交
- $rawBody = $this->request->getContent();
- if (empty($rawBody)) {
- $rawBody = file_get_contents('php://input');
- }
- $input = $rawBody ? json_decode($rawBody, true) : null;
- if (!is_array($input)) {
- $input = [];
- }
-
- $username = trim((string)($input['username'] ?? Request::param('username', '')));
- $currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', ''));
- $newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', ''));
- $confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', ''));
-
- if (empty($username)) {
- return error('用户名不能为空', 400);
- }
-
- try {
- // 优先使用JWT中的username来查找用户
- $jwtUsername = $user['username'] ?? null;
-
- if (empty($jwtUsername)) {
- return error('无法获取用户信息,请重新登录', 400);
- }
-
- // 直接通过username查找用户
- $userModel = UserModel::where('username', $jwtUsername)
- ->whereIn('role', ['admin', 'enterprise_admin'])
- ->find();
-
- if (!$userModel) {
- return error('用户不存在,请检查登录状态', 404);
- }
-
- // 如果要修改密码,需要验证当前密码
- if (!empty($newPassword)) {
- if (empty($currentPassword)) {
- return error('请输入当前密码', 400);
- }
-
- if ($newPassword !== $confirmPassword) {
- return error('两次输入的密码不一致', 400);
- }
-
- // 验证当前密码(User 模型已有原始加密密码)
- if (!password_verify($currentPassword, $userModel->password)) {
- return error('当前密码错误', 400);
- }
-
- // 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密
- $userModel->password = $newPassword;
- }
-
- // 更新用户名
- if ($username !== $userModel->username) {
- // 检查用户名是否已存在(排除当前用户)
- $exists = UserModel::where('username', $username)
- ->where('id', '<>', $userModel->id)
- ->find();
-
- if ($exists) {
- return error('用户名已存在', 400);
- }
-
- $userModel->username = $username;
- }
-
- $userModel->save();
-
- return success([
- 'username' => $userModel->username
- ], '账户信息已更新');
- } catch (\Exception $e) {
- return error('更新失败:' . $e->getMessage(), 500);
- }
- }
-}
-
+request->user ?? null;
+
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ // 验证是否为管理员
+ if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ try {
+ // 获取当前管理员用户名
+ $jwtUsername = $user['username'] ?? null;
+ $username = 'admin';
+
+ if ($jwtUsername) {
+ $currentUser = UserModel::where('username', $jwtUsername)
+ ->whereIn('role', ['admin', 'enterprise_admin'])
+ ->find();
+ if ($currentUser) {
+ $username = $currentUser->username;
+ } else {
+ $username = $jwtUsername;
+ }
+ }
+
+ return success([
+ 'username' => $username
+ ]);
+ } catch (\Exception $e) {
+ return error('获取配置失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 获取可用字体列表
+ * GET /api/v1/admin/settings/fonts
+ */
+ public function getFonts()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+ $fonts = \app\common\service\PosterService::getAvailableFonts();
+ return success([
+ 'fonts' => $fonts,
+ 'fontDir' => root_path() . 'public/fonts/',
+ 'dirExist' => is_dir(root_path() . 'public/fonts/'),
+ ]);
+ }
+
+ /**
+ * 获取海报配置
+ * GET /api/v1/admin/settings/poster
+ * 有 enterpriseId 则读企业专属行,否则读全局(enterprise_id=0)
+ */
+ public function getPosterConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ $eid = (int)($user['enterpriseId'] ?? 0);
+ $row = self::getConfig('poster_config', $eid);
+ $poster = $row ?: ['bgColor' => '#ffffff', 'bgImage' => '', 'elements' => []];
+ return success(['poster' => $poster]);
+ }
+
+ /**
+ * 保存海报配置
+ * PUT /api/v1/admin/settings/poster
+ */
+ public function updatePosterConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ $input = json_decode($this->request->getContent(), true);
+ if (!is_array($input)) {
+ $input = [];
+ }
+ $data = [
+ 'bgColor' => $input['bgColor'] ?? '#ffffff',
+ 'bgImage' => $input['bgImage'] ?? '',
+ 'elements' => $input['elements'] ?? []
+ ];
+ $eid = (int)($user['enterpriseId'] ?? 0);
+
+ try {
+ self::saveConfig('poster_config', $data, $eid, '分销海报可视化配置');
+ return success(null, '海报配置已保存');
+ } catch (\Exception $e) {
+ return error('保存失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 读取配置:key + enterprise_id,有企业专属则取,否则降级到 enterprise_id=0
+ */
+ private static function getConfig(string $key, int $enterpriseId = 0, bool $fallbackGlobal = false): ?array
+ {
+ $row = Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', $enterpriseId)
+ ->find();
+ if ($row && !empty($row['value'])) {
+ $val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
+ if (is_array($val)) return $val;
+ }
+ if ($fallbackGlobal && $enterpriseId > 0) {
+ $row = Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', 0)
+ ->find();
+ if ($row && !empty($row['value'])) {
+ $val = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
+ if (is_array($val)) return $val;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 保存配置:key + enterprise_id,存在则 update,否则 insert
+ */
+ private static function saveConfig(string $key, array $value, int $enterpriseId = 0, string $description = ''): void
+ {
+ $now = time();
+ $json = json_encode($value, JSON_UNESCAPED_UNICODE);
+ $exists = Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', $enterpriseId)
+ ->find();
+ if ($exists) {
+ Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', $enterpriseId)
+ ->update(['value' => $json, 'updatedAt' => $now]);
+ } else {
+ Db::name('system_config')->insert([
+ 'key' => $key,
+ 'enterprise_id' => $enterpriseId,
+ 'value' => $json,
+ 'description' => $description,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ }
+ }
+
+ /**
+ * 安全解码 JSON(处理可能的多重编码)
+ */
+ private static function decodeJsonSafe($raw): ?array
+ {
+ if (!$raw) return null;
+ $val = $raw;
+ for ($i = 0; $i < 5 && is_string($val); $i++) {
+ $decoded = json_decode($val, true);
+ if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break;
+ $val = $decoded;
+ }
+ return is_array($val) ? $val : null;
+ }
+
+ /**
+ * 获取小程序配置
+ * 读取全局 text_config(enterprise_id=0)作为默认值,再用企业专属行覆盖
+ * GET /api/v1/admin/settings/miniprogram
+ */
+ public function getMiniprogramConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ try {
+ $eid = (int)($user['enterpriseId'] ?? 0);
+
+ // 全局小程序名称(仅超管可改,此处只读)
+ $miniprogramName = '神仙团队AI性格测试';
+ $siteInfo = Db::name('system_config')
+ ->where('key', 'site_info')
+ ->where('enterprise_id', 0)
+ ->find();
+ if ($siteInfo && !empty($siteInfo['value'])) {
+ $val = is_string($siteInfo['value']) ? json_decode($siteInfo['value'], true) : $siteInfo['value'];
+ $miniprogramName = (string) ($val['miniprogramName'] ?? $val['siteName'] ?? $miniprogramName);
+ }
+
+ $tcDefaults = [
+ 'analyzingTitle' => '正在分析中',
+ 'startButtonText' => '开始面相测试',
+ 'startButtonEnterprise' => '开始面部测试',
+ 'reportTitle' => '分析报告',
+ 'aiAnalysisText' => '智能分析',
+ ];
+
+ // 全局文案(enterprise_id=0)作为基础
+ $globalTc = self::getConfig('text_config', 0);
+ $textConfigData = $globalTc
+ ? array_merge($tcDefaults, array_intersect_key($globalTc, $tcDefaults))
+ : $tcDefaults;
+
+ // 企业专属文案 + 小程序名称 覆盖
+ if ($eid > 0) {
+ $eidTc = self::getConfig('text_config', $eid);
+ if ($eidTc) {
+ $textConfigData = array_merge($textConfigData, array_intersect_key($eidTc, $tcDefaults));
+ if (!empty($eidTc['miniprogramName'])) {
+ $miniprogramName = (string) $eidTc['miniprogramName'];
+ }
+ }
+ }
+
+ return success([
+ 'miniprogramName' => $miniprogramName,
+ 'textConfig' => $textConfigData,
+ ]);
+ } catch (\Exception $e) {
+ return error('获取配置失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 飞书获客 Webhook(全局 enterprise_id=0)
+ * GET /api/v1/admin/settings/feishu-lead
+ */
+ public function getFeishuLeadConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+ $cfg = FeishuLeadWebhookService::getConfig();
+ return success([
+ 'enabled' => !empty($cfg['enabled']),
+ 'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
+ 'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
+ ]);
+ }
+
+ /**
+ * PUT /api/v1/admin/settings/feishu-lead
+ */
+ public function updateFeishuLeadConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+ $raw = $this->request->getContent();
+ $input = $raw ? json_decode($raw, true) : [];
+ if (!is_array($input)) {
+ $input = [];
+ }
+ $enabled = !empty($input['enabled']);
+ $webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
+ $contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
+ if ($contactPerson === '') {
+ $contactPerson = '运营';
+ }
+ if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
+ return error('Webhook 须以 http(s) 开头', 400);
+ }
+ $json = json_encode([
+ 'enabled' => $enabled,
+ 'webhookUrl' => $webhookUrl,
+ 'contactPerson' => $contactPerson,
+ ], JSON_UNESCAPED_UNICODE);
+ $now = time();
+ $key = FeishuLeadWebhookService::CONFIG_KEY;
+ $exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
+ if ($exists) {
+ Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', 0)
+ ->update(['value' => $json, 'updatedAt' => $now]);
+ } else {
+ Db::name('system_config')->insert([
+ 'key' => $key,
+ 'enterprise_id' => 0,
+ 'value' => $json,
+ 'description' => '飞书获客 Webhook',
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ }
+ return success(null, '已保存');
+ }
+
+ /**
+ * 更新小程序配置
+ * 写入 text_config 行:enterprise_id={eid}(有企业)或 0(无企业)
+ * PUT /api/v1/admin/settings/miniprogram
+ */
+ public function updateMiniprogramConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ $rawBody = $this->request->getContent();
+ if (empty($rawBody)) {
+ $rawBody = file_get_contents('php://input');
+ }
+ $input = $rawBody ? json_decode($rawBody, true) : null;
+ if (!is_array($input)) {
+ $input = [
+ 'miniprogramName' => Request::param('miniprogramName', ''),
+ 'textConfig' => Request::param('textConfig', []),
+ ];
+ }
+
+ $miniprogramName = trim((string) ($input['miniprogramName'] ?? ''));
+ $textConfig = $input['textConfig'] ?? [];
+
+ if ($miniprogramName === '') {
+ return error('小程序名称不能为空', 400);
+ }
+
+ $tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
+ $tcDefaults = [
+ 'analyzingTitle' => '正在分析中',
+ 'startButtonText' => '开始面相测试',
+ 'startButtonEnterprise' => '开始面部测试',
+ 'reportTitle' => '分析报告',
+ 'aiAnalysisText' => '智能分析',
+ ];
+ $tcData = is_array($textConfig) ? array_intersect_key($textConfig, array_flip($tcKeys)) : [];
+ $tcMerge = array_merge($tcDefaults, $tcData);
+ $eid = (int)($user['enterpriseId'] ?? 0);
+
+ try {
+ // eid=0:更新 site_info 的小程序名称(全局)
+ if ($eid === 0) {
+ $siteRow = Db::name('system_config')->where('key', 'site_info')->where('enterprise_id', 0)->find();
+ $siteInfo = $siteRow && !empty($siteRow['value'])
+ ? (is_string($siteRow['value']) ? json_decode($siteRow['value'], true) : $siteRow['value'])
+ : [];
+ $siteInfo = is_array($siteInfo) ? $siteInfo : [];
+ $siteInfo['miniprogramName'] = $miniprogramName;
+ $siteInfo['siteName'] = $siteInfo['siteName'] ?? $miniprogramName;
+ $siteInfo['updatedAt'] = time();
+ self::saveConfig('site_info', $siteInfo, 0, '站点信息');
+ } else {
+ // 企业专属:把 miniprogramName 一并写入 text_config
+ $tcMerge['miniprogramName'] = $miniprogramName;
+ }
+
+ // 统一写到 text_config(企业行已含 miniprogramName,全局行不含)
+ self::saveConfig('text_config', $tcMerge, $eid, $eid > 0 ? "小程序文案配置(企业{$eid})" : '小程序文案配置(全局)');
+ return success([
+ 'miniprogramName' => $miniprogramName,
+ 'textConfig' => $tcMerge,
+ ], '小程序配置已保存');
+ } catch (\Exception $e) {
+ return error('保存失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 更新管理员账户信息
+ * @return \think\response\Json
+ */
+ public function updateCredentials()
+ {
+ $user = $this->request->user ?? null;
+
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ // 验证是否为管理员
+ if (!in_array($user['role'], ['admin', 'enterprise_admin'])) {
+ return error('无权限访问', 403);
+ }
+
+ // 兼容 axios JSON PUT 与表单提交
+ $rawBody = $this->request->getContent();
+ if (empty($rawBody)) {
+ $rawBody = file_get_contents('php://input');
+ }
+ $input = $rawBody ? json_decode($rawBody, true) : null;
+ if (!is_array($input)) {
+ $input = [];
+ }
+
+ $username = trim((string)($input['username'] ?? Request::param('username', '')));
+ $currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', ''));
+ $newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', ''));
+ $confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', ''));
+
+ if (empty($username)) {
+ return error('用户名不能为空', 400);
+ }
+
+ try {
+ // 优先使用JWT中的username来查找用户
+ $jwtUsername = $user['username'] ?? null;
+
+ if (empty($jwtUsername)) {
+ return error('无法获取用户信息,请重新登录', 400);
+ }
+
+ // 直接通过username查找用户
+ $userModel = UserModel::where('username', $jwtUsername)
+ ->whereIn('role', ['admin', 'enterprise_admin'])
+ ->find();
+
+ if (!$userModel) {
+ return error('用户不存在,请检查登录状态', 404);
+ }
+
+ // 如果要修改密码,需要验证当前密码
+ if (!empty($newPassword)) {
+ if (empty($currentPassword)) {
+ return error('请输入当前密码', 400);
+ }
+
+ if ($newPassword !== $confirmPassword) {
+ return error('两次输入的密码不一致', 400);
+ }
+
+ // 验证当前密码(User 模型已有原始加密密码)
+ if (!password_verify($currentPassword, $userModel->password)) {
+ return error('当前密码错误', 400);
+ }
+
+ // 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密
+ $userModel->password = $newPassword;
+ }
+
+ // 更新用户名
+ if ($username !== $userModel->username) {
+ // 检查用户名是否已存在(排除当前用户)
+ $exists = UserModel::where('username', $username)
+ ->where('id', '<>', $userModel->id)
+ ->find();
+
+ if ($exists) {
+ return error('用户名已存在', 400);
+ }
+
+ $userModel->username = $username;
+ }
+
+ $userModel->save();
+
+ return success([
+ 'username' => $userModel->username
+ ], '账户信息已更新');
+ } catch (\Exception $e) {
+ return error('更新失败:' . $e->getMessage(), 500);
+ }
+ }
+}
+
diff --git a/api/app/controller/admin/concern/ExtractsTestResults.php b/api/app/controller/admin/concern/ExtractsTestResults.php
new file mode 100644
index 0000000..95cc3fd
--- /dev/null
+++ b/api/app/controller/admin/concern/ExtractsTestResults.php
@@ -0,0 +1,101 @@
+ 0], 'ok');
+ }
+ if (count($events) > 50) {
+ return error('单次最多 50 条', 400);
+ }
+
+ $userId = null;
+ $openid = null;
+ $token = JwtService::getTokenFromRequest($this->request);
+ if ($token) {
+ $payload = JwtService::verifyToken($token);
+ if ($payload && ($payload['source'] ?? '') === 'wechat') {
+ $userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
+ }
+ }
+
+ $now = date('Y-m-d H:i:s');
+ $rows = [];
+ foreach ($events as $ev) {
+ if (!is_array($ev)) {
+ continue;
+ }
+ $name = isset($ev['event_name']) ? trim((string) $ev['event_name']) : '';
+ if ($name === '' || strlen($name) > 128) {
+ continue;
+ }
+ $pagePath = isset($ev['page_path']) ? mb_substr(trim((string) $ev['page_path']), 0, 255) : '';
+ $props = $ev['props'] ?? null;
+ $propsJson = null;
+ if ($props !== null && $props !== []) {
+ $propsJson = json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+ if (strlen($propsJson) > 8000) {
+ $propsJson = mb_substr($propsJson, 0, 8000);
+ }
+ }
+ $clientTs = isset($ev['client_ts']) ? (int) $ev['client_ts'] : null;
+ $rowOpenid = null;
+ if (!$userId && isset($ev['openid'])) {
+ $rowOpenid = mb_substr(trim((string) $ev['openid']), 0, 64) ?: null;
+ }
+ $rows[] = [
+ 'userId' => $userId,
+ 'openid' => $rowOpenid,
+ 'eventName' => $name,
+ 'pagePath' => $pagePath ?: null,
+ 'propsJson' => $propsJson,
+ 'clientTs' => $clientTs ?: null,
+ 'createdAt' => $now,
+ ];
+ }
+ if (count($rows) === 0) {
+ return success(['accepted' => 0], 'ok');
+ }
+ try {
+ Db::name('analytics_events')->insertAll($rows);
+ } catch (\Throwable $e) {
+ // 表未创建时不抛 500,避免小程序端刷屏;超管端「小程序埋点」会提示建表 SQL
+ return success(['accepted' => 0, 'skipped' => true], 'ok');
+ }
+ return success(['accepted' => count($rows)], 'ok');
+ }
+}
diff --git a/api/app/controller/api/Auth.php b/api/app/controller/api/Auth.php
index cc241b5..943d4e7 100644
--- a/api/app/controller/api/Auth.php
+++ b/api/app/controller/api/Auth.php
@@ -1,525 +1,536 @@
-where('username', $username)
- // ->find();
-
- if (!$user) {
- return error('用户名或密码错误', 401);
- }
-
- // 验证密码
- if (!password_verify($password, $user['password'])) {
- return error('用户名或密码错误', 401);
- }
-
- // 检查状态
- if ($user['status'] != 1) {
- return error('账号已被禁用', 403);
- }
-
- // 更新登录信息(使用时间戳,驼峰命名)
- Db::name('users')
- ->where('id', $user['id'])
- ->update([
- 'lastLoginTime' => time(),
- 'lastLoginIp' => Request::ip(),
- 'updatedAt' => time()
- ]);
-
- // 生成Token
- $payload = [
- 'user_id' => $user['id'],
- 'username' => $user['username'],
- 'role' => $user['role']
- ];
-
- $token = JwtService::generateToken($payload);
-
- return success([
- 'token' => $token,
- 'expires_in' => config('jwt.expire'),
- 'user' => [
- 'id' => $user['id'],
- 'username' => $user['username'],
- 'nickname' => $user['nickname'] ?? $user['username'],
- 'email' => $user['email'] ?? '',
- 'avatar' => $user['avatar'] ?? '',
- 'role' => $user['role']
- ]
- ], '登录成功');
- }
-
- /**
- * 用户注册(前端)
- * @return \think\response\Json
- */
- public function register()
- {
- $data = Request::post();
-
- // 数据验证
- if (empty($data['username']) || empty($data['password'])) {
- return error('用户名和密码不能为空', 400);
- }
-
- // 检查用户名是否已存在
- if (Db::name('users')->where('username', $data['username'])->find()) {
- return error('用户名已存在', 400);
- }
-
- // 检查邮箱是否已存在
- if (!empty($data['email']) && Db::name('users')->where('email', $data['email'])->find()) {
- return error('邮箱已被注册', 400);
- }
-
- // 注意:mbti_users表只存储管理员和超管,前端用户需要存储在单独的表中
- // 这里暂时返回错误,需要创建前端用户表后再实现
- return error('前端用户注册功能暂未实现,请联系管理员', 501);
-
- // 创建用户(如果将来有前端用户表,使用以下代码)
- // $userId = Db::name('frontend_users')->insertGetId([
- // 'username' => $data['username'],
- // 'password' => password_hash($data['password'], PASSWORD_DEFAULT),
- // 'email' => $data['email'] ?? '',
- // 'status' => 1,
- // 'created_at' => time(),
- // 'updated_at' => time()
- // ]);
-
- $user = Db::name('users')->where('id', $userId)->find();
- unset($user['password']);
-
- return success($user, '注册成功');
- }
-
- /**
- * 获取当前用户信息(需要认证)
- * 小程序用户(source=wechat)从 mbti_wechat_users 读取,否则从 mbti_users 读取
- * @return \think\response\Json
- */
- public function me()
- {
- $user = $this->request->user ?? null;
-
- if (!$user) {
- return error('未登录', 401);
- }
-
- $source = $user['source'] ?? null;
- $userId = $user['user_id'] ?? $user['userId'] ?? null;
-
- if ($source === 'wechat' && $userId) {
- $wechatUser = Db::name('wechat_users')->where('id', $userId)->find();
- if (!$wechatUser) {
- return error('用户不存在', 404);
- }
- unset($wechatUser['sessionKey'], $wechatUser['openid']);
- $wechatUser['avatarUrl'] = $wechatUser['avatar'] ?? '';
- $eid = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null ? (int) $wechatUser['enterpriseId'] : null;
- $wechatUser['hasEnterprise'] = $eid > 0;
- $wechatUser['enterpriseId'] = $eid;
- return success($wechatUser);
- }
-
- $userModel = Db::name('users')->where('id', $userId)->find();
- if (!$userModel) {
- return error('用户不存在', 404);
- }
-
- unset($userModel['password']);
-
- return success($userModel);
- }
-
- /**
- * 退出登录(需要认证)
- * @return \think\response\Json
- */
- public function logout()
- {
- $user = $this->request->user ?? null;
-
- if ($user && isset($user['user_id'])) {
- JwtService::deleteToken((int) $user['user_id'], $user['source'] ?? null);
- }
-
- return success(null, '退出成功');
- }
-
- /**
- * 刷新Token
- * @return \think\response\Json
- */
- public function refresh()
- {
- $token = JwtService::getTokenFromRequest($this->request);
-
- if (!$token) {
- return error('未提供Token', 401);
- }
-
- $newToken = JwtService::refreshToken($token);
-
- if (!$newToken) {
- return error('Token无效或已过期', 401);
- }
-
- return success([
- 'token' => $newToken,
- 'expires_in' => config('jwt.expire')
- ], '刷新成功');
- }
-
- /**
- * 微信小程序登录:code 换 openid,查/建用户,返回 token 与用户信息
- * POST api/auth/wechat body: { "code": "xxx" }
- * @return \think\response\Json
- */
- public function wechatLogin()
- {
- $code = Request::param('code', '');
- if ($code === '') {
- return error('缺少 code', 400);
- }
-
- $session = WechatService::jscode2session($code);
- if (isset($session['errcode']) && $session['errcode'] !== 0) {
- return error($session['errmsg'] ?? '微信登录失败', 400);
- }
-
- $openid = $session['openid'];
- //$openid = 'oucCB15WDKCdwfNo-fpyS72iY5IQ';
- $sessionKey = $session['session_key'] ?? '';
- $unionid = $session['unionid'] ?? null;
-
- $wechatUser = Db::name('wechat_users')->where('openid', $openid)->find();
- $now = time();
- $ip = Request::ip();
-
- if ($wechatUser) {
- Db::name('wechat_users')->where('id', $wechatUser['id'])->update([
- 'sessionKey' => $sessionKey,
- 'unionid' => $unionid,
- 'lastLoginAt' => $now,
- 'lastLoginIp' => $ip,
- 'updatedAt' => $now,
- ]);
- $wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find();
- } else {
- $id = Db::name('wechat_users')->insertGetId([
- 'openid' => $openid,
- 'unionid' => $unionid,
- 'sessionKey' => $sessionKey,
- 'nickname' => null,
- 'avatar' => null,
- 'phone' => null,
- 'gender' => 0,
- 'country' => null,
- 'province' => null,
- 'city' => null,
- 'status' => 1,
- 'lastLoginAt' => $now,
- 'lastLoginIp' => $ip,
- 'createdAt' => $now,
- 'updatedAt' => $now,
- ]);
- $wechatUser = Db::name('wechat_users')->where('id', $id)->find();
- }
-
- if (($wechatUser['status'] ?? 1) != 1) {
- return error('账号已被禁用', 403);
- }
-
- $payload = [
- 'user_id' => (int) $wechatUser['id'],
- 'source' => 'wechat',
- ];
- $token = JwtService::generateToken($payload);
-
- $userId = (int) $wechatUser['id'];
- // 企业绑定取自 wechat_users.enterpriseId(企业分享测试链接时更新,个人分享不更新)
- $enterpriseId = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null
- ? (int) $wechatUser['enterpriseId']
- : null;
- $hasEnterprise = $enterpriseId > 0;
-
- $out = [
- 'id' => $userId,
- 'openid' => $openid,
- 'nickname' => $wechatUser['nickname'] ?? '',
- 'avatar' => $wechatUser['avatar'] ?? '',
- 'avatarUrl' => $wechatUser['avatar'] ?? '',
- 'phone' => $wechatUser['phone'] ?? '',
- 'gender' => (int) ($wechatUser['gender'] ?? 0),
- 'country' => $wechatUser['country'] ?? '',
- 'province' => $wechatUser['province'] ?? '',
- 'city' => $wechatUser['city'] ?? '',
- 'birthday' => $wechatUser['birthday'] ?? '',
- 'hasEnterprise' => $hasEnterprise,
- 'enterpriseId' => $enterpriseId,
- ];
-
- return success([
- 'token' => $token,
- 'expires_in' => config('jwt.expire'),
- 'user' => $out,
- ], '登录成功');
- }
-
- /**
- * 更新小程序用户资料(昵称、头像等),需要认证且为微信用户
- * PUT api/auth/wechat/profile body: { "nickname": "xxx", "avatar": "url", "gender", "country", "province", "city" }
- * @return \think\response\Json
- */
- public function updateWechatProfile()
- {
- $user = $this->request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (($user['source'] ?? '') !== 'wechat') {
- return error('仅支持小程序用户更新资料', 403);
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- if ($userId <= 0) {
- return error('用户不存在', 404);
- }
-
- // PUT请求的数据在body中,Content-Type为application/json时需要特殊处理
- $contentType = Request::header('content-type', '');
- $input = [];
-
- if (stripos($contentType, 'application/json') !== false) {
- // JSON格式的请求体,需要从原始内容中解析
- $rawContent = Request::getContent();
- if ($rawContent) {
- $input = json_decode($rawContent, true) ?: [];
- }
- } else {
- // 表单格式的请求体
- $input = Request::post() ?: Request::put() ?: [];
- }
-
- // 如果还是空,尝试从param获取(兼容性处理)
- if (empty($input)) {
- $input = Request::param();
- }
-
- // 记录接收到的数据(调试用)
- \think\facade\Log::info('更新用户资料请求', [
- 'userId' => $userId,
- 'input' => $input,
- 'method' => Request::method(),
- 'contentType' => $contentType,
- 'rawContent' => Request::getContent()
- ]);
-
- $allow = ['nickname', 'avatar', 'gender', 'country', 'province', 'city', 'birthday'];
- $data = [];
- foreach ($allow as $k) {
- if (isset($input[$k]) && $input[$k] !== null && $input[$k] !== '') {
- $v = $input[$k];
- if ($k === 'avatar') {
- $data['avatar'] = is_string($v) ? $v : '';
- } elseif ($k === 'nickname') {
- $data['nickname'] = is_string($v) ? mb_substr(trim($v), 0, 100) : '';
- } elseif ($k === 'birthday') {
- $data['birthday'] = is_string($v) ? preg_replace('/[^\d\-]/', '', trim($v)) : '';
- } elseif ($k === 'gender') {
- $data['gender'] = (int) $v;
- } else {
- $data[$k] = is_string($v) ? trim($v) : '';
- }
- }
- }
-
- if (empty($data)) {
- \think\facade\Log::warning('更新用户资料:没有可更新的字段', ['input' => $input]);
- return error('没有可更新的字段', 400);
- }
-
- $data['updatedAt'] = time();
- \think\facade\Log::info('更新用户资料SQL', ['userId' => $userId, 'data' => $data]);
-
- $result = Db::name('wechat_users')->where('id', $userId)->update($data);
-
- \think\facade\Log::info('更新用户资料结果', ['userId' => $userId, 'affectedRows' => $result]);
-
- $row = Db::name('wechat_users')->where('id', $userId)->find();
- unset($row['sessionKey'], $row['openid']);
- $row['avatarUrl'] = $row['avatar'] ?? '';
-
- return success($row, '更新成功');
- }
-
- /**
- * 小程序获取手机号:用 getPhoneNumber 返回的 code 换手机号并写入当前用户
- * POST api/auth/wechat/phone body: { "code": "xxx" } 需登录且为微信用户
- * @return \think\response\Json
- */
- public function wechatPhone()
- {
- $user = $this->request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (($user['source'] ?? '') !== 'wechat') {
- return error('仅支持小程序用户', 403);
- }
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- if ($userId <= 0) {
- return error('用户不存在', 404);
- }
- $contentType = Request::header('content-type', '');
- $input = [];
- if (stripos($contentType, 'application/json') !== false) {
- $rawContent = Request::getContent();
- if ($rawContent) {
- $input = json_decode($rawContent, true) ?: [];
- }
- } else {
- $input = Request::post() ?: [];
- }
- if (empty($input)) {
- $input = Request::param();
- }
- $code = $input['code'] ?? '';
- if ($code === '') {
- return error('缺少 code', 400);
- }
-
- // 调试日志:记录收到的手机号 code(仅保留前几位防止泄露)
- \think\facade\Log::info('WechatPhone 请求', [
- 'userId' => $userId,
- 'codeHead' => substr($code, 0, 8) . '***',
- ]);
-
- $phoneResult = WechatService::getPhoneNumber($code);
- if (isset($phoneResult['errcode'])) {
- \think\facade\Log::warning('WechatPhone 获取手机号失败', [
- 'userId' => $userId,
- 'codeHead' => substr($code, 0, 8) . '***',
- 'errcode' => $phoneResult['errcode'] ?? null,
- 'errmsg' => $phoneResult['errmsg'] ?? null,
- ]);
- return error(($phoneResult['errmsg'] ?? '获取手机号失败') . ' (code inval)', 400);
- }
- $phone = $phoneResult['purePhoneNumber'] ?? $phoneResult['phoneNumber'] ?? '';
- if ($phone === '') {
- return error('未获取到手机号', 400);
- }
-
- Db::name('wechat_users')->where('id', $userId)->update([
- 'phone' => $phone,
- 'updatedAt' => time(),
- ]);
- $row = Db::name('wechat_users')->where('id', $userId)->find();
- unset($row['sessionKey'], $row['openid']);
- $row['avatarUrl'] = $row['avatar'] ?? '';
-
- return success([
- 'phone' => $phone,
- 'user' => $row,
- ], '获取成功');
- }
-
- /**
- * 小程序扫码企业邀请后绑定企业:更新 wechat_users.enterpriseId
- * POST api/auth/wechat/bind-enterprise body: { "enterpriseId": 123 }
- */
- public function wechatBindEnterprise()
- {
- $user = $this->request->user ?? null;
- if (!$user) {
- return error('未登录', 401);
- }
- if (($user['source'] ?? '') !== 'wechat') {
- return error('仅支持小程序用户', 403);
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- if ($userId <= 0) {
- return error('用户不存在', 404);
- }
-
- $contentType = Request::header('content-type', '');
- $input = [];
- if (stripos($contentType, 'application/json') !== false) {
- $rawContent = Request::getContent();
- if ($rawContent) {
- $input = json_decode($rawContent, true) ?: [];
- }
- } else {
- $input = Request::post() ?: [];
- }
- if (empty($input)) {
- $input = Request::param();
- }
-
- $enterpriseId = (int) ($input['enterpriseId'] ?? 0);
- if ($enterpriseId <= 0) {
- return error('缺少或非法的 enterpriseId', 400);
- }
-
- $ent = Db::name('enterprises')
- ->where('id', $enterpriseId)
- ->where('status', '<>','disabled')
- ->find();
- if (!$ent) {
- return error('企业不存在或已禁用', 404);
- }
-
- Db::name('wechat_users')->where('id', $userId)->update([
- 'enterpriseId' => $enterpriseId,
- 'updatedAt' => time(),
- ]);
-
- $row = Db::name('wechat_users')->where('id', $userId)->find();
- if (!$row) {
- return error('用户不存在', 404);
- }
- unset($row['sessionKey'], $row['openid']);
- $row['avatarUrl'] = $row['avatar'] ?? '';
- $eid = isset($row['enterpriseId']) && $row['enterpriseId'] !== '' && $row['enterpriseId'] !== null ? (int) $row['enterpriseId'] : null;
- $row['hasEnterprise'] = $eid > 0;
- $row['enterpriseId'] = $eid;
- $row['enterpriseName'] = $ent['name'] ?? '';
-
- return success($row, '绑定企业成功');
- }
-}
-
+where('username', $username)
+ // ->find();
+
+ if (!$user) {
+ return error('用户名或密码错误', 401);
+ }
+
+ // 验证密码
+ if (!password_verify($password, $user['password'])) {
+ return error('用户名或密码错误', 401);
+ }
+
+ // 检查状态
+ if ($user['status'] != 1) {
+ return error('账号已被禁用', 403);
+ }
+
+ // 更新登录信息(使用时间戳,驼峰命名)
+ Db::name('users')
+ ->where('id', $user['id'])
+ ->update([
+ 'lastLoginTime' => time(),
+ 'lastLoginIp' => Request::ip(),
+ 'updatedAt' => time()
+ ]);
+
+ // 生成Token
+ $payload = [
+ 'user_id' => $user['id'],
+ 'username' => $user['username'],
+ 'role' => $user['role']
+ ];
+
+ $token = JwtService::generateToken($payload);
+
+ return success([
+ 'token' => $token,
+ 'expires_in' => config('jwt.expire'),
+ 'user' => [
+ 'id' => $user['id'],
+ 'username' => $user['username'],
+ 'nickname' => $user['nickname'] ?? $user['username'],
+ 'email' => $user['email'] ?? '',
+ 'avatar' => $user['avatar'] ?? '',
+ 'role' => $user['role']
+ ]
+ ], '登录成功');
+ }
+
+ /**
+ * 用户注册(前端)
+ * @return \think\response\Json
+ */
+ public function register()
+ {
+ $data = Request::post();
+
+ // 数据验证
+ if (empty($data['username']) || empty($data['password'])) {
+ return error('用户名和密码不能为空', 400);
+ }
+
+ // 检查用户名是否已存在
+ if (Db::name('users')->where('username', $data['username'])->find()) {
+ return error('用户名已存在', 400);
+ }
+
+ // 检查邮箱是否已存在
+ if (!empty($data['email']) && Db::name('users')->where('email', $data['email'])->find()) {
+ return error('邮箱已被注册', 400);
+ }
+
+ // 注意:mbti_users表只存储管理员和超管,前端用户需要存储在单独的表中
+ // 这里暂时返回错误,需要创建前端用户表后再实现
+ return error('前端用户注册功能暂未实现,请联系管理员', 501);
+
+ // 创建用户(如果将来有前端用户表,使用以下代码)
+ // $userId = Db::name('frontend_users')->insertGetId([
+ // 'username' => $data['username'],
+ // 'password' => password_hash($data['password'], PASSWORD_DEFAULT),
+ // 'email' => $data['email'] ?? '',
+ // 'status' => 1,
+ // 'created_at' => time(),
+ // 'updated_at' => time()
+ // ]);
+
+ $user = Db::name('users')->where('id', $userId)->find();
+ unset($user['password']);
+
+ return success($user, '注册成功');
+ }
+
+ /**
+ * 获取当前用户信息(需要认证)
+ * 小程序用户(source=wechat)从 mbti_wechat_users 读取,否则从 mbti_users 读取
+ * @return \think\response\Json
+ */
+ public function me()
+ {
+ $user = $this->request->user ?? null;
+
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $source = $user['source'] ?? null;
+ $userId = $user['user_id'] ?? $user['userId'] ?? null;
+
+ if ($source === 'wechat' && $userId) {
+ $wechatUser = Db::name('wechat_users')->where('id', $userId)->find();
+ if (!$wechatUser) {
+ return error('用户不存在', 404);
+ }
+ unset($wechatUser['sessionKey'], $wechatUser['openid']);
+ $wechatUser['avatarUrl'] = $wechatUser['avatar'] ?? '';
+ $eid = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null ? (int) $wechatUser['enterpriseId'] : null;
+ $wechatUser['hasEnterprise'] = $eid > 0;
+ $wechatUser['enterpriseId'] = $eid;
+ return success($wechatUser);
+ }
+
+ $userModel = Db::name('users')->where('id', $userId)->find();
+ if (!$userModel) {
+ return error('用户不存在', 404);
+ }
+
+ unset($userModel['password']);
+
+ return success($userModel);
+ }
+
+ /**
+ * 退出登录(需要认证)
+ * @return \think\response\Json
+ */
+ public function logout()
+ {
+ $user = $this->request->user ?? null;
+
+ if ($user && isset($user['user_id'])) {
+ JwtService::deleteToken((int) $user['user_id'], $user['source'] ?? null);
+ }
+
+ return success(null, '退出成功');
+ }
+
+ /**
+ * 刷新Token
+ * @return \think\response\Json
+ */
+ public function refresh()
+ {
+ $token = JwtService::getTokenFromRequest($this->request);
+
+ if (!$token) {
+ return error('未提供Token', 401);
+ }
+
+ $newToken = JwtService::refreshToken($token);
+
+ if (!$newToken) {
+ return error('Token无效或已过期', 401);
+ }
+
+ return success([
+ 'token' => $newToken,
+ 'expires_in' => config('jwt.expire')
+ ], '刷新成功');
+ }
+
+ /**
+ * 微信小程序登录:code 换 openid,查/建用户,返回 token 与用户信息
+ * POST api/auth/wechat body: { "code": "xxx" }
+ * @return \think\response\Json
+ */
+ public function wechatLogin()
+ {
+ $code = Request::param('code', '');
+ if ($code === '') {
+ return error('缺少 code', 400);
+ }
+
+ $session = WechatService::jscode2session($code);
+ if (isset($session['errcode']) && $session['errcode'] !== 0) {
+ return error($session['errmsg'] ?? '微信登录失败', 400);
+ }
+
+ $openid = $session['openid'];
+ //$openid = 'oucCB15WDKCdwfNo-fpyS72iY5IQ';
+ $sessionKey = $session['session_key'] ?? '';
+ $unionid = $session['unionid'] ?? null;
+
+ $wechatUser = Db::name('wechat_users')->where('openid', $openid)->find();
+ $now = time();
+ $ip = Request::ip();
+
+ if ($wechatUser) {
+ Db::name('wechat_users')->where('id', $wechatUser['id'])->update([
+ 'sessionKey' => $sessionKey,
+ 'unionid' => $unionid,
+ 'lastLoginAt' => $now,
+ 'lastLoginIp' => $ip,
+ 'updatedAt' => $now,
+ ]);
+ $wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find();
+ } else {
+ $id = Db::name('wechat_users')->insertGetId([
+ 'openid' => $openid,
+ 'unionid' => $unionid,
+ 'sessionKey' => $sessionKey,
+ 'nickname' => null,
+ 'avatar' => null,
+ 'phone' => null,
+ 'gender' => 0,
+ 'country' => null,
+ 'province' => null,
+ 'city' => null,
+ 'status' => 1,
+ 'lastLoginAt' => $now,
+ 'lastLoginIp' => $ip,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ $wechatUser = Db::name('wechat_users')->where('id', $id)->find();
+ }
+
+ if (($wechatUser['status'] ?? 1) != 1) {
+ return error('账号已被禁用', 403);
+ }
+
+ $payload = [
+ 'user_id' => (int) $wechatUser['id'],
+ 'source' => 'wechat',
+ ];
+ $token = JwtService::generateToken($payload);
+
+ $userId = (int) $wechatUser['id'];
+ // 企业绑定取自 wechat_users.enterpriseId(企业分享测试链接时更新,个人分享不更新)
+ $enterpriseId = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null
+ ? (int) $wechatUser['enterpriseId']
+ : null;
+ $hasEnterprise = $enterpriseId > 0;
+
+ $out = [
+ 'id' => $userId,
+ 'openid' => $openid,
+ 'nickname' => $wechatUser['nickname'] ?? '',
+ 'avatar' => $wechatUser['avatar'] ?? '',
+ 'avatarUrl' => $wechatUser['avatar'] ?? '',
+ 'phone' => $wechatUser['phone'] ?? '',
+ 'gender' => (int) ($wechatUser['gender'] ?? 0),
+ 'country' => $wechatUser['country'] ?? '',
+ 'province' => $wechatUser['province'] ?? '',
+ 'city' => $wechatUser['city'] ?? '',
+ 'birthday' => $wechatUser['birthday'] ?? '',
+ 'hasEnterprise' => $hasEnterprise,
+ 'enterpriseId' => $enterpriseId,
+ ];
+
+ return success([
+ 'token' => $token,
+ 'expires_in' => config('jwt.expire'),
+ 'user' => $out,
+ ], '登录成功');
+ }
+
+ /**
+ * 更新小程序用户资料(昵称、头像等),需要认证且为微信用户
+ * PUT api/auth/wechat/profile body: { "nickname": "xxx", "avatar": "url", "gender", "country", "province", "city" }
+ * @return \think\response\Json
+ */
+ public function updateWechatProfile()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (($user['source'] ?? '') !== 'wechat') {
+ return error('仅支持小程序用户更新资料', 403);
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ if ($userId <= 0) {
+ return error('用户不存在', 404);
+ }
+
+ // PUT请求的数据在body中,Content-Type为application/json时需要特殊处理
+ $contentType = Request::header('content-type', '');
+ $input = [];
+
+ if (stripos($contentType, 'application/json') !== false) {
+ // JSON格式的请求体,需要从原始内容中解析
+ $rawContent = Request::getContent();
+ if ($rawContent) {
+ $input = json_decode($rawContent, true) ?: [];
+ }
+ } else {
+ // 表单格式的请求体
+ $input = Request::post() ?: Request::put() ?: [];
+ }
+
+ // 如果还是空,尝试从param获取(兼容性处理)
+ if (empty($input)) {
+ $input = Request::param();
+ }
+
+ // 记录接收到的数据(调试用)
+ \think\facade\Log::info('更新用户资料请求', [
+ 'userId' => $userId,
+ 'input' => $input,
+ 'method' => Request::method(),
+ 'contentType' => $contentType,
+ 'rawContent' => Request::getContent()
+ ]);
+
+ $allow = ['nickname', 'avatar', 'gender', 'country', 'province', 'city', 'birthday'];
+ $data = [];
+ foreach ($allow as $k) {
+ if (isset($input[$k]) && $input[$k] !== null && $input[$k] !== '') {
+ $v = $input[$k];
+ if ($k === 'avatar') {
+ $data['avatar'] = is_string($v) ? $v : '';
+ } elseif ($k === 'nickname') {
+ $data['nickname'] = is_string($v) ? mb_substr(trim($v), 0, 100) : '';
+ } elseif ($k === 'birthday') {
+ $data['birthday'] = is_string($v) ? preg_replace('/[^\d\-]/', '', trim($v)) : '';
+ } elseif ($k === 'gender') {
+ $data['gender'] = (int) $v;
+ } else {
+ $data[$k] = is_string($v) ? trim($v) : '';
+ }
+ }
+ }
+
+ if (empty($data)) {
+ \think\facade\Log::warning('更新用户资料:没有可更新的字段', ['input' => $input]);
+ return error('没有可更新的字段', 400);
+ }
+
+ $data['updatedAt'] = time();
+ \think\facade\Log::info('更新用户资料SQL', ['userId' => $userId, 'data' => $data]);
+
+ $result = Db::name('wechat_users')->where('id', $userId)->update($data);
+
+ \think\facade\Log::info('更新用户资料结果', ['userId' => $userId, 'affectedRows' => $result]);
+
+ $row = Db::name('wechat_users')->where('id', $userId)->find();
+ unset($row['sessionKey'], $row['openid']);
+ $row['avatarUrl'] = $row['avatar'] ?? '';
+
+ return success($row, '更新成功');
+ }
+
+ /**
+ * 小程序获取手机号:用 getPhoneNumber 返回的 code 换手机号并写入当前用户
+ * POST api/auth/wechat/phone body: { "code": "xxx" } 需登录且为微信用户
+ * @return \think\response\Json
+ */
+ public function wechatPhone()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (($user['source'] ?? '') !== 'wechat') {
+ return error('仅支持小程序用户', 403);
+ }
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ if ($userId <= 0) {
+ return error('用户不存在', 404);
+ }
+ $contentType = Request::header('content-type', '');
+ $input = [];
+ if (stripos($contentType, 'application/json') !== false) {
+ $rawContent = Request::getContent();
+ if ($rawContent) {
+ $input = json_decode($rawContent, true) ?: [];
+ }
+ } else {
+ $input = Request::post() ?: [];
+ }
+ if (empty($input)) {
+ $input = Request::param();
+ }
+ $code = $input['code'] ?? '';
+ if ($code === '') {
+ return error('缺少 code', 400);
+ }
+
+ // 调试日志:记录收到的手机号 code(仅保留前几位防止泄露)
+ \think\facade\Log::info('WechatPhone 请求', [
+ 'userId' => $userId,
+ 'codeHead' => substr($code, 0, 8) . '***',
+ ]);
+
+ $phoneResult = WechatService::getPhoneNumber($code);
+ if (isset($phoneResult['errcode'])) {
+ \think\facade\Log::warning('WechatPhone 获取手机号失败', [
+ 'userId' => $userId,
+ 'codeHead' => substr($code, 0, 8) . '***',
+ 'errcode' => $phoneResult['errcode'] ?? null,
+ 'errmsg' => $phoneResult['errmsg'] ?? null,
+ ]);
+ return error(($phoneResult['errmsg'] ?? '获取手机号失败') . ' (code inval)', 400);
+ }
+ $phone = $phoneResult['purePhoneNumber'] ?? $phoneResult['phoneNumber'] ?? '';
+ if ($phone === '') {
+ return error('未获取到手机号', 400);
+ }
+
+ $prevRow = Db::name('wechat_users')->where('id', $userId)->field('phone')->find();
+ $hadPhone = $prevRow && trim((string) ($prevRow['phone'] ?? '')) !== '';
+
+ Db::name('wechat_users')->where('id', $userId)->update([
+ 'phone' => $phone,
+ 'updatedAt' => time(),
+ ]);
+
+ if (!$hadPhone) {
+ try {
+ FeishuLeadWebhookService::onPhoneBound($userId, $phone);
+ } catch (\Throwable $e) {
+ }
+ }
+ $row = Db::name('wechat_users')->where('id', $userId)->find();
+ unset($row['sessionKey'], $row['openid']);
+ $row['avatarUrl'] = $row['avatar'] ?? '';
+
+ return success([
+ 'phone' => $phone,
+ 'user' => $row,
+ ], '获取成功');
+ }
+
+ /**
+ * 小程序扫码企业邀请后绑定企业:更新 wechat_users.enterpriseId
+ * POST api/auth/wechat/bind-enterprise body: { "enterpriseId": 123 }
+ */
+ public function wechatBindEnterprise()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user) {
+ return error('未登录', 401);
+ }
+ if (($user['source'] ?? '') !== 'wechat') {
+ return error('仅支持小程序用户', 403);
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ if ($userId <= 0) {
+ return error('用户不存在', 404);
+ }
+
+ $contentType = Request::header('content-type', '');
+ $input = [];
+ if (stripos($contentType, 'application/json') !== false) {
+ $rawContent = Request::getContent();
+ if ($rawContent) {
+ $input = json_decode($rawContent, true) ?: [];
+ }
+ } else {
+ $input = Request::post() ?: [];
+ }
+ if (empty($input)) {
+ $input = Request::param();
+ }
+
+ $enterpriseId = (int) ($input['enterpriseId'] ?? 0);
+ if ($enterpriseId <= 0) {
+ return error('缺少或非法的 enterpriseId', 400);
+ }
+
+ $ent = Db::name('enterprises')
+ ->where('id', $enterpriseId)
+ ->where('status', '<>','disabled')
+ ->find();
+ if (!$ent) {
+ return error('企业不存在或已禁用', 404);
+ }
+
+ Db::name('wechat_users')->where('id', $userId)->update([
+ 'enterpriseId' => $enterpriseId,
+ 'updatedAt' => time(),
+ ]);
+
+ $row = Db::name('wechat_users')->where('id', $userId)->find();
+ if (!$row) {
+ return error('用户不存在', 404);
+ }
+ unset($row['sessionKey'], $row['openid']);
+ $row['avatarUrl'] = $row['avatar'] ?? '';
+ $eid = isset($row['enterpriseId']) && $row['enterpriseId'] !== '' && $row['enterpriseId'] !== null ? (int) $row['enterpriseId'] : null;
+ $row['hasEnterprise'] = $eid > 0;
+ $row['enterpriseId'] = $eid;
+ $row['enterpriseName'] = $ent['name'] ?? '';
+
+ return success($row, '绑定企业成功');
+ }
+}
+
diff --git a/api/app/controller/api/Distribution.php b/api/app/controller/api/Distribution.php
index ed5bb6d..a3dea3c 100644
--- a/api/app/controller/api/Distribution.php
+++ b/api/app/controller/api/Distribution.php
@@ -1,1466 +1,1472 @@
-resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $inviteeId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $inviterId = (int) Request::param('inviterId', 0);
- $enterpriseId = Request::param('eid', null);
- $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null;
- $scope = $enterpriseId ? 'enterprise' : 'personal';
-
- // 自绑校验
- if ($inviterId <= 0 || $inviterId === $inviteeId) {
- return success(null, '无需绑定');
- }
-
- // 企业版:推荐人必须是该企业成员
- if ($scope === 'enterprise') {
- $inviter = Db::name('wechat_users')
- ->where('id', $inviterId)
- ->field('id, enterpriseId')
- ->find();
- if (!$inviter || (int) $inviter['enterpriseId'] !== $enterpriseId) {
- return success(null, '无需绑定');
- }
- }
-
- $now = time();
-
- // 禁止互相绑定(仅有效期内):A 曾邀请过 B 且 A→B 未过期时,B 不能再成为 A 的推荐人;若 A→B 已过期则允许 A 绑定 B
- $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 success(null, '无需绑定');
- }
- $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 {
- // ── 不同推荐人 → 抢绑(覆盖,记录旧推荐人)
- Db::name('distribution_bindings')
- ->where('id', $existing['id'])
- ->update([
- 'prevInviterId' => (int) $existing['inviterId'],
- 'inviterId' => $inviterId,
- 'expireAt' => $expireAt,
- 'status' => 'active',
- 'overriddenAt' => $now,
- 'updatedAt' => $now,
- ]);
- }
-
- return success(['expireAt' => $expireAt], '绑定成功');
- }
-
- /**
- * 过期待收款提现:status=2 超过24小时未确认收款的,自动退回余额并标记为已过期
- */
- private function expirePendingWithdrawals()
- {
- $now = time();
- $limit = $now - self::WITHDRAW_WAIT_EXPIRE_SEC;
- $list = Db::name('distribution_withdrawals')
- ->where('status', 2)
- ->select()
- ->toArray();
- foreach ($list as $row) {
- $ts = (int) ($row['auditAt'] ?? $row['updatedAt'] ?? $row['createdAt'] ?? 0);
- if ($ts <= 0 || $ts >= $limit) {
- continue;
- }
- $id = (int) $row['id'];
- $userId = (int) $row['userId'];
- $amountFen = (int) $row['amountFen'];
- if ($amountFen <= 0) {
- continue;
- }
- Db::startTrans();
- try {
- Db::name('wechat_users')
- ->where('id', $userId)
- ->inc('walletBalance', $amountFen)
- ->update(['updatedAt' => $now]);
- Db::name('distribution_withdrawals')->where('id', $id)->update([
- 'status' => 4,
- 'auditNote' => '超时未确认收款,已自动退回余额',
- 'auditAt' => $now,
- 'updatedAt' => $now,
- ]);
- Db::commit();
- Log::info('提现过期自动退回', ['id' => $id, 'userId' => $userId, 'amountFen' => $amountFen]);
- } catch (\Throwable $e) {
- Db::rollback();
- Log::error('expirePendingWithdrawals error: ' . $e->getMessage());
- }
- }
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/stats
- // 推广中心统计数据(余额、总收益、待入账、绑定数、付款数)
- // ─────────────────────────────────────────────────────────────
- public function stats()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $now = time();
-
- $this->expirePendingWithdrawals();
-
- // 直接读取 wechat_users 中的钱包字段(持久化数据)
- $walletBalanceFen = 0;
- $totalEarnedFen = 0;
- $pendingFen = 0;
- try {
- $walletRow = Db::name('wechat_users')
- ->where('id', $inviterId)
- ->field('walletBalance, walletTotalEarned, walletPending')
- ->find();
- if ($walletRow) {
- $walletBalanceFen = (int) ($walletRow['walletBalance'] ?? 0);
- $totalEarnedFen = (int) ($walletRow['walletTotalEarned'] ?? 0);
- $pendingFen = (int) ($walletRow['walletPending'] ?? 0);
- }
- } catch (\Exception $e) {
- // 表结构异常时静默降级为 0
- }
-
- // 绑定中人数(active 且未过期)
- $bindingCount = 0;
- try {
- $bindingCount = Db::name('distribution_bindings')
- ->where('inviterId', $inviterId)
- ->where('status', 'active')
- ->where('expireAt', '>', $now)
- ->count();
- } catch (\Exception $e) {}
-
- // 已付款人数
- $paidCount = 0;
- try {
- $paidCount = Db::name('commission_records')
- ->where('inviterId', $inviterId)
- ->whereIn('status', ['paid', 'frozen'])
- ->distinct(true)
- ->count('inviteeId');
- } catch (\Exception $e) {}
-
- // 即将到期(7天内过期)
- $expiringCount = 0;
- try {
- $expiringCount = Db::name('distribution_bindings')
- ->where('inviterId', $inviterId)
- ->where('status', 'active')
- ->where('expireAt', '>', $now)
- ->where('expireAt', '<=', $now + 7 * 86400)
- ->count();
- } catch (\Exception $e) {}
-
- // 总邀请人数(历史所有绑定过的唯一用户数)
- $totalInvite = 0;
- try {
- $totalInvite = Db::name('distribution_bindings')
- ->where('inviterId', $inviterId)
- ->distinct(true)
- ->count('inviteeId');
- } catch (\Exception $e) {}
-
- // 根据用户所属企业读取分销配置(显示开关 + 推广中心标题)
- $enterpriseId = 0;
- try {
- $wu = Db::name('wechat_users')->where('id', $inviterId)->field('enterpriseId')->find();
- $enterpriseId = $wu && isset($wu['enterpriseId']) ? (int) $wu['enterpriseId'] : 0;
- } catch (\Exception $e) {}
- $distConfig = null;
- try {
- $distRow = Db::name('system_config')
- ->where('key', 'distribution')
- ->where('enterprise_id', $enterpriseId)
- ->find();
- if (!$distRow && $enterpriseId > 0) {
- $distRow = Db::name('system_config')
- ->where('key', 'distribution')
- ->where('enterprise_id', 0)
- ->find();
- }
- if ($distRow && $distRow['value']) {
- $distConfig = is_string($distRow['value']) ? json_decode($distRow['value'], true) : $distRow['value'];
- }
- } catch (\Exception $e) {}
- $distributionEnabled = ($distConfig['enabled'] ?? true);
- $promoCenterTitle = trim((string)($distConfig['promoCenterTitle'] ?? '推广中心')) ?: '推广中心';
- $commissionRate = (int)($distConfig['commissionRate'] ?? 90);
- $bindingDays = (int)($distConfig['bindingDays'] ?? 30);
-
- // 提现规则仅超管可配置,只读 enterprise_id=0 的全局配置
- $globalDistConfig = null;
- try {
- $globalRow = Db::name('system_config')
- ->where('key', 'distribution')
- ->where('enterprise_id', 0)
- ->find();
- if ($globalRow && $globalRow['value']) {
- $globalDistConfig = is_string($globalRow['value']) ? json_decode($globalRow['value'], true) : $globalRow['value'];
- }
- } catch (\Exception $e) {}
- $cfg = is_array($globalDistConfig) ? $globalDistConfig : [];
- $minWithdrawFen = (int)($cfg['minWithdrawFen'] ?? 100);
- $maxWithdrawFen = (int)($cfg['maxWithdrawFen'] ?? 0);
- $withdrawFee = (float)($cfg['withdrawFee'] ?? 0);
- $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
- $withdrawMinYuan = number_format($minWithdrawFen / 100, 2, '.', '');
- $withdrawMaxYuan = $maxWithdrawFen > 0 ? number_format($maxWithdrawFen / 100, 2, '.', '') : null;
- $withdrawFeePct = round($withdrawFee, 1);
-
- // 读取「人脸分析」测试佣金配置,用于前端展示规则说明
- $faceSetting = null;
- try {
- $faceSetting = self::resolveTestSetting('face', $enterpriseId > 0 ? $enterpriseId : null);
- } catch (\Throwable $e) {
- $faceSetting = null;
- }
- $faceType = $faceSetting['commissionType'] ?? null;
- $faceRate = isset($faceSetting['commissionRate']) ? (int)$faceSetting['commissionRate'] : null;
- $faceAmountFen = isset($faceSetting['commissionAmountFen']) ? (int)$faceSetting['commissionAmountFen'] : null;
- $faceAmountYuan = $faceAmountFen !== null ? number_format($faceAmountFen / 100, 2, '.', '') : null;
- $faceNoPayment = !empty($faceSetting['noPayment']);
-
- return success([
- 'walletBalance' => number_format($walletBalanceFen / 100, 2, '.', ''),
- 'totalEarned' => number_format($totalEarnedFen / 100, 2, '.', ''),
- 'pendingAmount' => number_format($pendingFen / 100, 2, '.', ''),
- 'bindingCount' => $bindingCount,
- 'paidCount' => $paidCount,
- 'expiringCount' => $expiringCount,
- 'totalInvite' => $totalInvite,
- 'distributionEnabled' => $distributionEnabled,
- 'promoCenterTitle' => $promoCenterTitle,
- 'commissionRate' => $commissionRate,
- 'bindingDays' => $bindingDays,
- 'testCommissionType' => $faceType,
- 'testCommissionRate' => $faceRate,
- 'testCommissionAmount'=> $faceAmountYuan,
- 'testNoPayment' => $faceNoPayment,
- 'withdrawMinYuan' => $withdrawMinYuan,
- 'withdrawMaxYuan' => $withdrawMaxYuan,
- 'withdrawFeePct' => $withdrawFeePct,
- 'requireWithdrawAudit'=> $requireAudit,
- ]);
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/bindings
- // 我邀请的用户列表(分页,tab: 0=绑定中 1=已付款 2=已过期)
- // ─────────────────────────────────────────────────────────────
- public function bindings()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $tab = (int) Request::param('tab', 0);
- $page = max(1, (int) Request::param('page', 1));
- $pageSize = min(50, (int) Request::param('pageSize', 20));
- $now = time();
-
- $query = Db::name('distribution_bindings')
- ->alias('b')
- ->leftJoin('wechat_users u', 'b.inviteeId = u.id')
- ->field('b.id, b.inviteeId, b.expireAt, b.status, b.createdAt, b.overriddenAt,
- u.nickname, u.avatar')
- ->where('b.inviterId', $inviterId);
-
- switch ($tab) {
- case 1: // 已付款
- $paidInviteeIds = Db::name('commission_records')
- ->where('inviterId', $inviterId)
- ->whereIn('status', ['paid', 'frozen'])
- ->column('inviteeId');
- if (empty($paidInviteeIds)) {
- return success(['list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize]);
- }
- $query->whereIn('b.inviteeId', array_unique($paidInviteeIds));
- break;
- case 2: // 已过期
- $query->where(function ($q) use ($now) {
- $q->where('b.status', 'overridden')
- ->whereOr(function ($q2) use ($now) {
- $q2->where('b.status', 'active')->where('b.expireAt', '<=', $now);
- });
- });
- break;
- default: // 绑定中
- $query->where('b.status', 'active')->where('b.expireAt', '>', $now);
- break;
- }
-
- $total = (clone $query)->count();
- $list = $query->order('b.updatedAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- foreach ($list as &$row) {
- $row['expireAt'] = (int) $row['expireAt'];
- $row['remainDays'] = max(0, (int) ceil(($row['expireAt'] - $now) / 86400));
- $row['avatar'] = $row['avatar'] ?: '';
- $row['nickname'] = $row['nickname'] ?: '微信用户';
- }
-
- return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/commissions
- // 我的佣金记录(分页)
- // ─────────────────────────────────────────────────────────────
- public function commissions()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $page = max(1, (int) Request::param('page', 1));
- $pageSize = min(50, (int) Request::param('pageSize', 20));
-
- $list = Db::name('commission_records')
- ->alias('c')
- ->leftJoin('wechat_users u', 'c.inviteeId = u.id')
- ->field('c.id, c.commissionFen, c.orderAmount, c.status, c.scope, c.createdAt,
- c.frozenAt, c.unfrozenAt, u.nickname, u.avatar')
- ->where('c.inviterId', $inviterId)
- ->order('c.createdAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- $total = Db::name('commission_records')
- ->where('inviterId', $inviterId)
- ->count();
-
- foreach ($list as &$row) {
- $row['commissionYuan'] = number_format($row['commissionFen'] / 100, 2, '.', '');
- $row['orderYuan'] = number_format($row['orderAmount'] / 100, 2, '.', '');
- $row['nickname'] = $row['nickname'] ?: '微信用户';
- $row['avatar'] = $row['avatar'] ?: '';
- }
-
- return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
- }
-
- // ─────────────────────────────────────────────────────────────
- // POST /api/distribution/withdraw
- // 申请提现
- // ─────────────────────────────────────────────────────────────
- public function withdraw()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $this->expirePendingWithdrawals();
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $amountFen = (int) Request::param('amountFen', 0);
- $scope = Request::param('scope', 'personal');
- $eid = $scope === 'enterprise' ? (int) Request::param('eid', 0) : null;
-
- list($minFen, $maxFen) = self::getWithdrawLimits($scope, $eid);
- if ($amountFen < $minFen) {
- return error('最低提现金额为 ' . round($minFen / 100, 2) . ' 元', 400);
- }
- if ($maxFen > 0 && $amountFen > $maxFen) {
- return error('最高提现金额为 ' . round($maxFen / 100, 2) . ' 元', 400);
- }
-
- $wallet = Db::name('wechat_users')
- ->where('id', $userId)
- ->field('walletBalance')
- ->find();
-
- if (!$wallet || (int) $wallet['walletBalance'] < $amountFen) {
- return error('余额不足', 400);
- }
-
- // 检查是否有待审核的提现申请(status=0 审核中)
- $pending = Db::name('distribution_withdrawals')
- ->where('userId', $userId)
- ->where('status', 0)
- ->count();
- if ($pending > 0) {
- return error('您有待处理的提现申请,请等待审核完成后再次申请', 400);
- }
-
- // 手续费(分):按全局配置 enterprise_id=0 的 withdrawFee 比例计算
- $cfg = self::getDistributionConfig($scope ?: 'personal', $eid);
- $feePct = (float)($cfg['withdrawFee'] ?? 0);
- $feeFen = (int) round($amountFen * $feePct / 100);
- $actualFen = $amountFen - $feeFen;
- if ($actualFen < $minFen) {
- return error('实际到账金额不得低于最低提现金额 ' . number_format($minFen / 100, 2, '.', '') . ' 元', 400);
- }
-
- $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
-
- $now = time();
- Db::startTrans();
- try {
- // 冻结余额
- Db::name('wechat_users')
- ->where('id', $userId)
- ->dec('walletBalance', $amountFen)
- ->update(['updatedAt' => $now]);
-
- // 写入提现申请:status=0 审核中,并记录手续费(需 insertGetId 以便免审核时更新)
- $withdrawId = Db::name('distribution_withdrawals')->insertGetId([
- 'userId' => $userId,
- 'amountFen' => $amountFen,
- 'feeFen' => $feeFen,
- 'status' => 0,
- 'createdAt' => $now,
- 'updatedAt' => $now,
- ]);
-
- Db::commit();
- } catch (\Exception $e) {
- Db::rollback();
- return error('申请提现失败:' . $e->getMessage(), 500);
- }
-
- // 免审核:自动发起微信转账
- if (!$requireAudit && $withdrawId > 0) {
- $wechatUser = Db::name('wechat_users')->where('id', $userId)->field('openid')->find();
- $openid = $wechatUser['openid'] ?? '';
- if (empty($openid)) {
- Db::startTrans();
- try {
- Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
- Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
- 'status' => 1,
- 'auditNote' => '无 openid 无法自动打款,请联系管理员',
- 'auditAt' => time(),
- 'updatedAt' => time(),
- ]);
- Db::commit();
- } catch (\Exception $e) {
- Db::rollback();
- }
- return error('无法自动打款:未绑定微信 openid,请联系管理员', 400);
- }
-
- try {
- $outBillNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999) . $withdrawId;
- $service = new \app\common\service\WechatTransferService();
- $result = $service->createTransfer([
- 'out_bill_no' => $outBillNo,
- 'openid' => $openid,
- 'transfer_amount' => $amountFen,
- 'transfer_remark' => '推广佣金提现',
- 'transfer_scene_id' => env('TRANSFER_SCENE_ID', '1005'),
- 'transfer_scene_report_infos' => [
- ['info_type' => '岗位类型', 'info_content' => '推广人员'],
- ['info_type' => '报酬说明', 'info_content' => '推广佣金提现'],
- ],
- 'notify_url' => env('WITHDRAW_NOTIFY_URL', ''),
- ]);
-
- if ($result['success'] === true) {
- $wechatData = $result['data'] ?? [];
- $transferBillNo = $wechatData['transfer_bill_no'] ?? $wechatData['batch_id'] ?? null;
- $wechatState = $wechatData['state'] ?? $wechatData['batch_status'] ?? 'PROCESSING';
- Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
- 'status' => 2,
- 'auditAt' => $now,
- 'updatedAt' => $now,
- 'pay_type' => 'wechat',
- 'out_bill_no' => $outBillNo,
- 'transfer_bill_no' => $transferBillNo,
- 'wechat_pay_state' => $wechatState,
- 'transfer_scene_id' => $wechatData['transfer_scene_id'] ?? env('TRANSFER_SCENE_ID', '1005'),
- 'package_info' => $wechatData['package_info'] ?? '',
- 'mch_id' => env('MCH_ID', null),
- ]);
- return success(null, '提现申请已提交,已自动发起微信转账');
- }
-
- $err = $result['error'] ?? [];
- $code = $err['code'] ?? 'UNKNOWN';
- $msg = $err['message'] ?? '微信转账接口调用失败';
- Db::startTrans();
- try {
- Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
- Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
- 'status' => 1,
- 'auditNote' => "微信转账发起失败({$code}):{$msg}",
- 'auditAt' => time(),
- 'updatedAt' => time(),
- ]);
- Db::commit();
- } catch (\Exception $e) {
- Db::rollback();
- }
- return error("自动打款失败({$code}):{$msg}", 500);
- } catch (\Exception $e) {
- Db::startTrans();
- try {
- Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
- Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
- 'status' => 1,
- 'auditNote' => '自动打款异常:' . $e->getMessage(),
- 'auditAt' => time(),
- 'updatedAt' => time(),
- ]);
- Db::commit();
- } catch (\Exception $ex) {
- Db::rollback();
- }
- return error('自动打款异常:' . $e->getMessage(), 500);
- }
- }
-
- return success(null, '提现申请已提交,请等待审核');
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/withdrawals
- // 我的提现记录
- // ─────────────────────────────────────────────────────────────
- public function withdrawals()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $page = max(1, (int) Request::param('page', 1));
- $pageSize = min(50, (int) Request::param('pageSize', 20));
-
- $this->expirePendingWithdrawals();
-
- $list = Db::name('distribution_withdrawals')
- ->where('userId', $userId)
- ->order('createdAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- $total = Db::name('distribution_withdrawals')
- ->where('userId', $userId)
- ->count();
-
- foreach ($list as &$row) {
- $row['amountYuan'] = number_format($row['amountFen'] / 100, 2, '.', '');
- $feeFen = (int)($row['feeFen'] ?? 0);
- $row['feeYuan'] = number_format($feeFen / 100, 2, '.', '');
- }
-
- return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
- }
-
- // ─────────────────────────────────────────────────────────────
- // POST /api/distribution/withdrawals/query-transfer
- // 用户确认收款后,主动查询微信转账单状态并更新本地订单(及时刷新)
- // 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012716437
- // ─────────────────────────────────────────────────────────────
- public function queryTransfer()
- {
- $user = $this->resolveUser();
- if (!$user) {
- return error('未登录', 401);
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $id = (int) Request::param('id', 0);
- if ($id <= 0) {
- return error('参数错误', 400);
- }
-
- $record = Db::name('distribution_withdrawals')
- ->where('id', $id)
- ->where('userId', $userId)
- ->find();
- if (!$record) {
- return error('提现记录不存在或无权限', 404);
- }
-
- $outBillNo = trim((string) ($record['out_bill_no'] ?? ''));
- if (!$outBillNo) {
- return error('该提现单暂无商户单号,无法查询', 400);
- }
-
- try {
- $service = new \app\common\service\WechatTransferService();
- $result = $service->queryByOutBillNo($outBillNo);
- } catch (\Throwable $e) {
- Log::error('queryTransfer WechatTransferService error: ' . $e->getMessage());
- return error('查询转账状态失败:' . $e->getMessage(), 500);
- }
-
- if ($result['success'] !== true || empty($result['data'])) {
- $err = $result['error'] ?? [];
- return error('查询失败:' . ($err['message'] ?? '未知错误'), 500);
- }
-
- $data = $result['data'];
- $state = trim((string) ($data['state'] ?? ''));
- $now = time();
- $billNo = $data['transfer_bill_no'] ?? null;
-
- if ($state === 'SUCCESS') {
- Db::name('distribution_withdrawals')->where('id', $id)->update([
- 'status' => 3,
- 'wechat_pay_state' => $state,
- 'transfer_bill_no' => $billNo,
- 'transferAt' => $now,
- 'updatedAt' => $now,
- ]);
- return success(['status' => 3, 'statusLabel' => '已收款'], '已收款');
- }
-
- if ($state === 'FAIL') {
- Db::startTrans();
- try {
- Db::name('wechat_users')
- ->where('id', $record['userId'])
- ->inc('walletBalance', (int) $record['amountFen'])
- ->update(['updatedAt' => $now]);
- Db::name('distribution_withdrawals')->where('id', $id)->update([
- 'status' => 1,
- 'auditNote' => $data['fail_reason'] ?? '微信转账失败',
- 'wechat_pay_state' => $state,
- 'transfer_bill_no' => $billNo,
- 'updatedAt' => $now,
- ]);
- Db::commit();
- } catch (\Throwable $e) {
- Db::rollback();
- Log::error('queryTransfer FAIL rollback: ' . $e->getMessage());
- return error('更新失败', 500);
- }
- return success(['status' => 1, 'statusLabel' => '已驳回'], '转账失败,余额已退回');
- }
-
- return success(['status' => (int) $record['status'], 'state' => $state], '状态未变更');
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/qrcode
- // 生成当前用户专属小程序推广码,直接输出 PNG 二进制流
- // 可选参数:scope=personal|enterprise;eid=企业ID(仅 scope=enterprise 时有效)
- // ─────────────────────────────────────────────────────────────
- public function qrcode()
- {
- $user = $this->resolveUser();
- if (!$user) {
- http_response_code(401);
- exit('Unauthorized');
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $scope = Request::param('scope', '');
- // 显式传 scope=personal 时强制个人版,不认 eid
- $enterpriseId = ($scope === 'personal') ? null : Request::param('eid', null);
- $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null;
-
- // scene 参数:uid=用户ID(+eid=企业ID),最长 32 字符
- if ($enterpriseId) {
- $scene = "uid={$userId}&eid={$enterpriseId}";
- $page = 'pages/enterprise/index';
- } else {
- $scene = "uid={$userId}";
- $page = 'pages/index/index';
- }
-
- // 调用微信接口生成小程序码
- $result = WechatService::getWxacodeUnlimited($scene, $page, 280);
-
- if (isset($result['errcode'])) {
- http_response_code(500);
- exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($result['errmsg'] ?? '未知错误')]));
- }
-
- // 直接输出 PNG 二进制流,供 wx.downloadFile 使用
- header('Content-Type: image/png');
- header('Cache-Control: max-age=3600');
- echo $result['binary'];
- exit();
- }
-
- // ─────────────────────────────────────────────────────────────
- // GET /api/distribution/poster
- // 生成完整海报(后端合成头像+二维码),直接输出 PNG
- // ─────────────────────────────────────────────────────────────
- public function poster()
- {
- $user = $this->resolveUser();
- if (!$user) {
- http_response_code(401);
- exit('Unauthorized');
- }
-
- $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
- $scope = Request::param('scope', '');
- // scope=personal 时不读 eid 参数
- $eidParam = ($scope === 'personal') ? null : Request::param('eid', null);
- $enterpriseId = $eidParam !== null ? (int) $eidParam : null;
-
- if ($userId <= 0) {
- http_response_code(400);
- exit(json_encode(['code' => 400, 'msg' => '用户信息异常']));
- }
-
- $wechatUser = Db::name('wechat_users')
- ->where('id', $userId)
- ->field('id, nickname, avatar, enterpriseId')
- ->find();
-
- // 未显式传 eid 且非强制个人版时,从用户 DB 记录自动取企业 ID
- if ($enterpriseId === null && $scope !== 'personal' && !empty($wechatUser['enterpriseId'])) {
- $enterpriseId = (int) $wechatUser['enterpriseId'];
- }
- $userData = [
- 'id' => $userId,
- 'nickname' => $wechatUser['nickname'] ?? '好友',
- 'avatar' => $wechatUser['avatar'] ?? '',
- ];
-
- $scene = $enterpriseId ? "uid={$userId}&eid={$enterpriseId}" : "uid={$userId}";
- $page = $enterpriseId ? 'pages/enterprise/index' : 'pages/index/index';
- $qrResult = WechatService::getWxacodeUnlimited($scene, $page, 280);
-
- if (isset($qrResult['errcode'])) {
- http_response_code(500);
- exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($qrResult['errmsg'] ?? '')]));
- }
-
- $avatarBinary = null;
- if (!empty($userData['avatar'])) {
- $avatarBinary = PosterService::fetchImage($userData['avatar']);
- }
-
- try {
- $png = PosterService::buildFromConfig($userData, $qrResult['binary'], $avatarBinary, $enterpriseId);
- } catch (\Throwable $e) {
- http_response_code(500);
- exit(json_encode(['code' => 500, 'msg' => '海报合成失败:' . $e->getMessage()]));
- }
-
- header('Content-Type: image/png');
- header('Cache-Control: max-age=3600');
- echo $png;
- exit();
- }
-
- // ─────────────────────────────────────────────────────────────
- // 内部方法:订单付款成功后结算佣金(由 Payment/notify 调用)
- // $orderId: orders.id (整数)
- // ─────────────────────────────────────────────────────────────
- public static function settleCommission(int $orderId): void
- {
- $order = Db::name('orders')
- ->where('id', $orderId)
- ->field('id, userId, enterpriseId, amount, status')
- ->find();
-
- if (!$order || $order['status'] !== 'paid') {
- return;
- }
-
- $inviteeId = (int) $order['userId'];
- $orderAmount = (int) $order['amount'];
- $enterpriseId = !empty($order['enterpriseId']) ? (int) $order['enterpriseId'] : null;
- $scope = $enterpriseId ? 'enterprise' : 'personal';
- $now = time();
-
- // 【第一步】精确匹配:scope + enterpriseId 与订单完全一致
- $binding = Db::name('distribution_bindings')
- ->where('inviteeId', $inviteeId)
- ->where('scope', $scope)
- ->where(function ($q) use ($enterpriseId) {
- if ($enterpriseId) {
- $q->where('enterpriseId', $enterpriseId);
- } else {
- $q->whereNull('enterpriseId');
- }
- })
- ->where('status', 'active')
- ->where('expireAt', '>', time())
- ->find();
-
- // 【第二步】回退匹配:精确未命中时,查找任意有效的 personal 绑定
- // 跨 scope 场景(如企业版订单但推荐人仅持有 personal 绑定),以个人版配置+平台资金结算
- $fallbackScope = $scope;
- $fallbackEnterpriseId = $enterpriseId;
- if (!$binding && $scope === 'enterprise') {
- $binding = self::findActivePersonalBinding($inviteeId, $enterpriseId);
- if ($binding) {
- $fallbackScope = 'personal';
- $fallbackEnterpriseId = !empty($binding['enterpriseId']) ? (int) $binding['enterpriseId'] : null;
- }
- }
-
- if (!$binding) {
- return;
- }
-
- // 实际用于结算的 scope/enterpriseId(可能已回退为 personal)
- $scope = $fallbackScope;
- $enterpriseId = $fallbackEnterpriseId;
-
- $inviterId = (int) $binding['inviterId'];
-
- // 从订单关联的 test_results 中取 testType,用于读取 per-test 佣金配置
- $testType = null;
- try {
- $tr = Db::name('test_results')->where('orderId', $orderId)->field('testType')->find();
- if ($tr) $testType = $tr['testType'] === 'ai' ? 'face' : ($tr['testType'] ?? null);
- } catch (\Throwable $e) {}
-
- // 读取佣金配置(优先 per-test testSettings,回退全局)
- list($rate, $amountFen) = self::getTestCommissionConfig($testType, $scope, $enterpriseId);
- $commissionFen = 0;
- if ($amountFen > 0) {
- $commissionFen = $amountFen;
- $rate = 0;
- } elseif ($rate > 0) {
- $commissionFen = (int) floor($orderAmount * $rate / 100);
- }
- if ($commissionFen <= 0) {
- return;
- }
-
- // 避免同一订单重复结算
- $exists = Db::name('commission_records')
- ->where('orderId', $orderId)
- ->find();
- if ($exists) {
- return;
- }
-
- Db::startTrans();
- try {
- $commissionStatus = 'pending';
-
- if ($enterpriseId) {
- // 企业上下文:优先从企业余额扣款,余额不足则冻结
- $enterprise = Db::name('enterprises')
- ->where('id', $enterpriseId)
- ->field('id, balance')
- ->lock(true)
- ->find();
-
- $balanceFen = (int) ($enterprise['balance'] ?? 0);
-
- if ($enterprise && $balanceFen >= $commissionFen) {
- // 余额充足,直接结算
- $newBalanceFen = $balanceFen - $commissionFen;
- Db::name('enterprises')
- ->where('id', $enterpriseId)
- ->update([
- 'balance' => $newBalanceFen,
- 'updatedAt' => $now,
- ]);
-
- Db::name('finance_records')->insert([
- 'enterpriseId' => $enterpriseId,
- 'type' => 'consume',
- 'amount' => $commissionFen,
- 'balanceBefore' => $balanceFen,
- 'balanceAfter' => $newBalanceFen,
- 'description' => self::buildCommissionFinanceDescription($inviteeId, $testType, 'order_paid'),
- 'orderId' => $orderId,
- 'createdAt' => $now,
- ]);
-
- // 推荐人钱包入账
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletBalance', $commissionFen)
- ->inc('walletTotalEarned', $commissionFen)
- ->update(['updatedAt' => $now]);
-
- $commissionStatus = 'paid';
- } else {
- // 余额不足,冻结
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletPending', $commissionFen)
- ->update(['updatedAt' => $now]);
-
- $commissionStatus = 'frozen';
- }
- } else {
- // 无企业上下文:平台直接发放(入账钱包)
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletBalance', $commissionFen)
- ->inc('walletTotalEarned', $commissionFen)
- ->update(['updatedAt' => $now]);
-
- $commissionStatus = 'paid';
- }
-
- // 写佣金记录
- Db::name('commission_records')->insert([
- 'agentId' => $inviterId,
- 'orderId' => $orderId,
- 'scope' => $scope,
- 'enterpriseId' => $enterpriseId,
- 'inviterId' => $inviterId,
- 'inviteeId' => $inviteeId,
- 'bindingId' => (int) $binding['id'],
- 'commissionRate'=> $rate,
- 'orderAmount' => $orderAmount,
- 'commissionFen' => $commissionFen,
- 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''),
- 'status' => $commissionStatus,
- 'frozenAt' => $commissionStatus === 'frozen' ? $now : null,
- 'paidAt' => $commissionStatus === 'paid' ? $now : null,
- 'createdAt' => $now,
- 'updatedAt' => $now,
- ]);
-
- Db::commit();
- } catch (\Exception $e) {
- Db::rollback();
- }
- }
-
- // ─────────────────────────────────────────────────────────────
- // 内部方法:企业充值后解冻冻结中的佣金
- // $enterpriseId: 企业ID
- // ─────────────────────────────────────────────────────────────
- public static function unfreezeCommissions(int $enterpriseId): void
- {
- $now = time();
-
- // 找出该企业所有冻结中的佣金(按时间升序,先冻先解)
- $frozenList = Db::name('commission_records')
- ->where('enterpriseId', $enterpriseId)
- ->where('status', 'frozen')
- ->order('createdAt', 'asc')
- ->select()
- ->toArray();
-
- if (empty($frozenList)) {
- return;
- }
-
- $enterprise = Db::name('enterprises')
- ->where('id', $enterpriseId)
- ->field('id, balance')
- ->lock(true)
- ->find();
-
- if (!$enterprise) {
- return;
- }
-
- $balanceFen = (int) ($enterprise['balance'] ?? 0);
-
- Db::startTrans();
- try {
- foreach ($frozenList as $record) {
- $commissionFen = (int) $record['commissionFen'];
- if ($balanceFen < $commissionFen) {
- break;
- }
-
- $balanceFen -= $commissionFen;
- $inviterId = (int) $record['inviterId'];
-
- // 更新佣金记录状态
- Db::name('commission_records')
- ->where('id', $record['id'])
- ->update([
- 'status' => 'paid',
- 'paidAt' => $now,
- 'unfrozenAt' => $now,
- 'updatedAt' => $now,
- ]);
-
- $recordTestType = null;
- if (($record['commissionSource'] ?? '') === 'test_completion') {
- $recordTestType = Db::name('test_results')
- ->where('id', (int) ($record['testResultId'] ?? 0))
- ->value('testType');
- } elseif (!empty($record['orderId'])) {
- $recordTestType = self::getOrderTestType((int) $record['orderId']);
- }
-
- Db::name('finance_records')->insert([
- 'enterpriseId' => $enterpriseId,
- 'type' => 'consume',
- 'amount' => $commissionFen,
- 'balanceBefore' => $balanceFen + $commissionFen,
- 'balanceAfter' => $balanceFen,
- 'description' => self::buildCommissionFinanceDescription((int) ($record['inviteeId'] ?? 0), $recordTestType, 'unfrozen'),
- 'orderId' => !empty($record['orderId']) ? (int) $record['orderId'] : null,
- 'createdAt' => $now,
- ]);
-
- // 推荐人钱包:pending 转 balance
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletBalance', $commissionFen)
- ->dec('walletPending', $commissionFen)
- ->inc('walletTotalEarned', $commissionFen)
- ->update(['updatedAt' => $now]);
- }
-
- // 更新企业余额
- Db::name('enterprises')
- ->where('id', $enterpriseId)
- ->update([
- 'balance' => $balanceFen,
- 'updatedAt' => $now,
- ]);
-
- Db::commit();
- } catch (\Exception $e) {
- Db::rollback();
- }
- }
-
- /**
- * 读取提现上下限(分)
- * @return array{int,int} [minFen, maxFen] maxFen=0 表示不限制
- */
- private static function getWithdrawLimits(string $scope, ?int $enterpriseId): array
- {
- $cfg = self::getDistributionConfig($scope, $enterpriseId);
- $minFen = max(self::MIN_WITHDRAW_FEN, min(self::MAX_WITHDRAW_FEN, (int)($cfg['minWithdrawFen'] ?? self::MIN_WITHDRAW_FEN)));
- $maxFen = (int)($cfg['maxWithdrawFen'] ?? 0);
- if ($maxFen > 0) {
- $maxFen = min(self::MAX_WITHDRAW_FEN, $maxFen);
- }
- return [$minFen, $maxFen];
- }
-
- /**
- * 读取分销配置
- * 企业模式:enterprise_id={eid} 行,不存在则降级到 enterprise_id=0 全局行
- * 个人版:enterprise_id=0 行
- */
- private static function getDistributionConfig(string $scope, ?int $enterpriseId): array
- {
- if ($enterpriseId > 0) {
- $config = Db::name('system_config')
- ->where('key', 'distribution')
- ->where('enterprise_id', $enterpriseId)
- ->find();
- if ($config && $config['value']) {
- $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
- if (is_array($cfg)) return $cfg;
- }
- }
- // 全局/个人版配置(enterprise_id=0)
- $config = Db::name('system_config')
- ->where('key', 'distribution')
- ->where('enterprise_id', 0)
- ->find();
- if ($config && $config['value']) {
- $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
- if (is_array($cfg)) return $cfg;
- }
- return [];
- }
-
- /**
- * 读取佣金配置,返回 [rate, amountFen];比例模式 rate>0 amountFen=0,金额模式 amountFen>0 rate=0
- * @deprecated 用 getTestCommissionConfig 替代
- */
- private static function getCommissionConfig(string $scope, ?int $enterpriseId): array
- {
- $cfg = self::getDistributionConfig($scope, $enterpriseId);
- $type = $cfg['commissionType'] ?? 'ratio';
- if ($type === 'amount') {
- $amountFen = (int)($cfg['commissionAmountFen'] ?? 0);
- return [0, $amountFen];
- }
- $rate = (int)($cfg['commissionRate'] ?? 90);
- return [$rate, 0];
- }
-
- /**
- * 读取指定测试类型的佣金配置 [rate, amountFen]
- * 优先使用 testSettings[testType],若无则回退全局 commissionRate/commissionAmountFen
- */
- private static function getTestCommissionConfig(?string $testType, string $scope, ?int $enterpriseId): array
- {
- $ts = self::resolveTestSetting($testType, $enterpriseId);
- if ($ts) {
- $commType = $ts['commissionType'] ?? 'ratio';
- if ($commType === 'amount') {
- return [0, (int)($ts['commissionAmountFen'] ?? 0)];
- }
- return [(int)($ts['commissionRate'] ?? 90), 0];
- }
- return self::getCommissionConfig($scope, $enterpriseId);
- }
-
- /**
- * 解析某测试类型的 testSettings 配置(企业优先,回退全局),返回 null 表示未启用
- */
- private static function resolveTestSetting(?string $testType, ?int $enterpriseId): ?array
- {
- if (!$testType) return null;
- $tryEids = array_filter([$enterpriseId > 0 ? $enterpriseId : null, null], fn($v) => $v !== false);
- foreach ($tryEids as $eid) {
- $cfg = self::getDistributionConfig('personal', $eid);
- $ts = $cfg['testSettings'][$testType] ?? null;
- if ($ts && !empty($ts['enabled'])) {
- return $ts;
- }
- }
- return null;
- }
-
- /**
- * 查找 personal 维度有效绑定:
- * 1. 优先 `enterpriseId = 当前企业`
- * 2. 其次 `enterpriseId IS NULL`
- */
- private static function findActivePersonalBinding(int $inviteeId, ?int $enterpriseId): ?array
- {
- if ($enterpriseId > 0) {
- $binding = Db::name('distribution_bindings')
- ->where('inviteeId', $inviteeId)
- ->where('scope', 'personal')
- ->where('enterpriseId', $enterpriseId)
- ->where('status', 'active')
- ->where('expireAt', '>', time())
- ->find();
- if ($binding) {
- return $binding;
- }
- }
-
- $binding = Db::name('distribution_bindings')
- ->where('inviteeId', $inviteeId)
- ->where('scope', 'personal')
- ->whereNull('enterpriseId')
- ->where('status', 'active')
- ->where('expireAt', '>', time())
- ->find();
-
- return $binding ?: null;
- }
-
- /**
- * 读取佣金比例配置(百分比整数,兼容旧逻辑)
- */
- private static function getCommissionRate(string $scope, ?int $enterpriseId): int
- {
- list($rate, $amountFen) = self::getCommissionConfig($scope, $enterpriseId);
- return $rate;
- }
-
- /**
- * 根据订单读取测试类型
- */
- private static function getOrderTestType(int $orderId): ?string
- {
- if ($orderId <= 0) {
- return null;
- }
-
- $testType = Db::name('test_results')
- ->where('orderId', $orderId)
- ->value('testType');
-
- if (!$testType) {
- return null;
- }
-
- return $testType === 'ai' ? 'face' : $testType;
- }
-
- /**
- * 获取用户展示名称
- */
- private static function getUserDisplayName(int $userId): string
- {
- if ($userId <= 0) {
- return '未知用户';
- }
-
- $nickname = Db::name('wechat_users')
- ->where('id', $userId)
- ->value('nickname');
-
- return $nickname ? (string) $nickname : ('用户' . $userId);
- }
-
- /**
- * 测试类型文案
- */
- private static function getTestTypeLabel(?string $testType): string
- {
- $normalized = $testType === 'ai' ? 'face' : (string) $testType;
- $map = [
- 'face' => '人脸',
- 'mbti' => 'MBTI',
- 'disc' => 'DISC',
- 'pdp' => 'PDP',
- ];
-
- return $map[$normalized] ?? strtoupper($normalized ?: '未知测试');
- }
-
- /**
- * 企业财务流水中的佣金支出说明
- */
- private static function buildCommissionFinanceDescription(int $inviteeId, ?string $testType, string $scene): string
- {
- $userName = self::getUserDisplayName($inviteeId);
- $testLabel = self::getTestTypeLabel($testType);
-
- if ($scene === 'unfrozen') {
- return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销(冻结后解冻)';
- }
-
- return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销';
- }
-
- // ─────────────────────────────────────────────────────────────
- // 内部方法:测试完成后结算「测试完成佣金」(由 Test/submit 调用)
- // 仅适用于 personal scope;只要有有效绑定即可,无需付款。
- // 防重:同一 testResultId 只结算一次。
- // ─────────────────────────────────────────────────────────────
- public static function settleTestCommission(int $testResultId, int $inviteeId, string $testType): void
- {
- // 仅支持指定测试类型
- $allowedTypes = ['face', 'mbti', 'disc', 'pdp'];
- // face/ai 统一归类为 face
- $normalizedType = ($testType === 'ai') ? 'face' : $testType;
- if (!in_array($normalizedType, $allowedTypes, true)) {
- return;
- }
-
- // 读取 testSettings 配置:优先读用户所属企业配置,未配置则回退全局
- $userEid = (int)(Db::name('wechat_users')->where('id', $inviteeId)->value('enterpriseId') ?? 0);
- $tsConfig = self::resolveTestSetting($normalizedType, $userEid);
- if (!$tsConfig || empty($tsConfig['enabled']) || empty($tsConfig['noPayment'])) {
- return;
- }
- list($rate, $amountFen) = self::getTestCommissionConfig($normalizedType, 'personal', $userEid > 0 ? $userEid : null);
- $commissionFen = 0;
- if ($amountFen > 0) {
- $commissionFen = $amountFen;
- } elseif ($rate > 0) {
- // noPayment 场景无订单金额,若为比例则跳过(无金额可算)
- return;
- }
- if ($commissionFen <= 0) {
- return;
- }
-
- // 查找该用户 personal scope 的有效绑定:优先企业 personal 绑定,再回退全局 personal 绑定
- $binding = self::findActivePersonalBinding($inviteeId, $userEid > 0 ? $userEid : null);
- if (!$binding) {
- return;
- }
-
- $inviterId = (int) $binding['inviterId'];
- $recordEnterpriseId = $userEid > 0
- ? $userEid
- : (((int)($binding['enterpriseId'] ?? 0)) > 0 ? (int)$binding['enterpriseId'] : null);
-
- // 防重:同一 testResultId 只允许一条 test_completion 佣金
- $exists = Db::name('commission_records')
- ->where('testResultId', $testResultId)
- ->where('commissionSource', 'test_completion')
- ->find();
- if ($exists) {
- return;
- }
-
- $now = time();
- Db::startTrans();
- try {
- $commissionStatus = 'paid';
-
- if ($recordEnterpriseId) {
- // 企业上下文:先扣企业余额;不足则冻结到后续补余额再解冻
- $enterprise = Db::name('enterprises')
- ->where('id', $recordEnterpriseId)
- ->field('id, balance')
- ->lock(true)
- ->find();
-
- $balanceFen = (int) ($enterprise['balance'] ?? 0);
- if ($enterprise && $balanceFen >= $commissionFen) {
- $newBalanceFen = $balanceFen - $commissionFen;
- Db::name('enterprises')
- ->where('id', $recordEnterpriseId)
- ->update([
- 'balance' => $newBalanceFen,
- 'updatedAt' => $now,
- ]);
-
- Db::name('finance_records')->insert([
- 'enterpriseId' => $recordEnterpriseId,
- 'type' => 'consume',
- 'amount' => $commissionFen,
- 'balanceBefore' => $balanceFen,
- 'balanceAfter' => $newBalanceFen,
- 'description' => self::buildCommissionFinanceDescription($inviteeId, $normalizedType, 'test_completion'),
- 'orderId' => null,
- 'createdAt' => $now,
- ]);
-
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletBalance', $commissionFen)
- ->inc('walletTotalEarned', $commissionFen)
- ->update(['updatedAt' => $now]);
- } else {
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletPending', $commissionFen)
- ->update(['updatedAt' => $now]);
-
- $commissionStatus = 'frozen';
- }
- } else {
- // 无企业上下文时仍由平台直接发放
- Db::name('wechat_users')
- ->where('id', $inviterId)
- ->inc('walletBalance', $commissionFen)
- ->inc('walletTotalEarned', $commissionFen)
- ->update(['updatedAt' => $now]);
- }
-
- // 写佣金记录
- Db::name('commission_records')->insert([
- 'agentId' => $inviterId,
- 'orderId' => null,
- 'testResultId' => $testResultId,
- 'commissionSource' => 'test_completion',
- 'scope' => 'personal',
- 'enterpriseId' => $recordEnterpriseId,
- 'inviterId' => $inviterId,
- 'inviteeId' => $inviteeId,
- 'bindingId' => (int) $binding['id'],
- 'commissionRate' => $rate,
- 'orderAmount' => 0,
- 'commissionFen' => $commissionFen,
- 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''),
- 'status' => $commissionStatus,
- 'frozenAt' => $commissionStatus === 'frozen' ? $now : null,
- 'paidAt' => $commissionStatus === 'paid' ? $now : null,
- 'createdAt' => $now,
- 'updatedAt' => $now,
- ]);
-
- Db::commit();
- } catch (\Throwable $e) {
- Db::rollback();
- Log::error('settleTestCommission failed', [
- 'testResultId' => $testResultId,
- 'inviteeId' => $inviteeId,
- 'testType' => $testType,
- 'normalizedType' => $normalizedType,
- 'userEnterpriseId' => $userEid,
- 'bindingId' => (int)($binding['id'] ?? 0),
- 'inviterId' => $inviterId,
- 'commissionFen' => $commissionFen,
- 'message' => $e->getMessage(),
- ]);
- }
- }
-}
+resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $inviteeId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $inviterId = (int) Request::param('inviterId', 0);
+ $enterpriseId = Request::param('eid', null);
+ $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null;
+ $scope = $enterpriseId ? 'enterprise' : 'personal';
+
+ // 自绑校验
+ if ($inviterId <= 0 || $inviterId === $inviteeId) {
+ return success(null, '无需绑定');
+ }
+
+ // 企业版:推荐人必须是该企业成员
+ if ($scope === 'enterprise') {
+ $inviter = Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->field('id, enterpriseId')
+ ->find();
+ if (!$inviter || (int) $inviter['enterpriseId'] !== $enterpriseId) {
+ return success(null, '无需绑定');
+ }
+ }
+
+ $now = time();
+
+ // 禁止互相绑定(仅有效期内):A 曾邀请过 B 且 A→B 未过期时,B 不能再成为 A 的推荐人;若 A→B 已过期则允许 A 绑定 B
+ $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 success(null, '无需绑定');
+ }
+ $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 {
+ // ── 不同推荐人:有效期内禁止更换(对齐「一级一月 / 30 天锁定」策略,参考 Soul 分销规则)
+ $exExpire = (int) ($existing['expireAt'] ?? 0);
+ $exStatus = (string) ($existing['status'] ?? '');
+ if ($exStatus === 'active' && $exExpire > $now) {
+ return success(null, '绑定有效期内暂不可更换推荐人,到期后可重新绑定');
+ }
+ // ── 已过期或非 active → 允许抢绑(覆盖,记录旧推荐人)
+ Db::name('distribution_bindings')
+ ->where('id', $existing['id'])
+ ->update([
+ 'prevInviterId' => (int) $existing['inviterId'],
+ 'inviterId' => $inviterId,
+ 'expireAt' => $expireAt,
+ 'status' => 'active',
+ 'overriddenAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ }
+
+ return success(['expireAt' => $expireAt], '绑定成功');
+ }
+
+ /**
+ * 过期待收款提现:status=2 超过24小时未确认收款的,自动退回余额并标记为已过期
+ */
+ private function expirePendingWithdrawals()
+ {
+ $now = time();
+ $limit = $now - self::WITHDRAW_WAIT_EXPIRE_SEC;
+ $list = Db::name('distribution_withdrawals')
+ ->where('status', 2)
+ ->select()
+ ->toArray();
+ foreach ($list as $row) {
+ $ts = (int) ($row['auditAt'] ?? $row['updatedAt'] ?? $row['createdAt'] ?? 0);
+ if ($ts <= 0 || $ts >= $limit) {
+ continue;
+ }
+ $id = (int) $row['id'];
+ $userId = (int) $row['userId'];
+ $amountFen = (int) $row['amountFen'];
+ if ($amountFen <= 0) {
+ continue;
+ }
+ Db::startTrans();
+ try {
+ Db::name('wechat_users')
+ ->where('id', $userId)
+ ->inc('walletBalance', $amountFen)
+ ->update(['updatedAt' => $now]);
+ Db::name('distribution_withdrawals')->where('id', $id)->update([
+ 'status' => 4,
+ 'auditNote' => '超时未确认收款,已自动退回余额',
+ 'auditAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ Db::commit();
+ Log::info('提现过期自动退回', ['id' => $id, 'userId' => $userId, 'amountFen' => $amountFen]);
+ } catch (\Throwable $e) {
+ Db::rollback();
+ Log::error('expirePendingWithdrawals error: ' . $e->getMessage());
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/stats
+ // 推广中心统计数据(余额、总收益、待入账、绑定数、付款数)
+ // ─────────────────────────────────────────────────────────────
+ public function stats()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $now = time();
+
+ $this->expirePendingWithdrawals();
+
+ // 直接读取 wechat_users 中的钱包字段(持久化数据)
+ $walletBalanceFen = 0;
+ $totalEarnedFen = 0;
+ $pendingFen = 0;
+ try {
+ $walletRow = Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->field('walletBalance, walletTotalEarned, walletPending')
+ ->find();
+ if ($walletRow) {
+ $walletBalanceFen = (int) ($walletRow['walletBalance'] ?? 0);
+ $totalEarnedFen = (int) ($walletRow['walletTotalEarned'] ?? 0);
+ $pendingFen = (int) ($walletRow['walletPending'] ?? 0);
+ }
+ } catch (\Exception $e) {
+ // 表结构异常时静默降级为 0
+ }
+
+ // 绑定中人数(active 且未过期)
+ $bindingCount = 0;
+ try {
+ $bindingCount = Db::name('distribution_bindings')
+ ->where('inviterId', $inviterId)
+ ->where('status', 'active')
+ ->where('expireAt', '>', $now)
+ ->count();
+ } catch (\Exception $e) {}
+
+ // 已付款人数
+ $paidCount = 0;
+ try {
+ $paidCount = Db::name('commission_records')
+ ->where('inviterId', $inviterId)
+ ->whereIn('status', ['paid', 'frozen'])
+ ->distinct(true)
+ ->count('inviteeId');
+ } catch (\Exception $e) {}
+
+ // 即将到期(7天内过期)
+ $expiringCount = 0;
+ try {
+ $expiringCount = Db::name('distribution_bindings')
+ ->where('inviterId', $inviterId)
+ ->where('status', 'active')
+ ->where('expireAt', '>', $now)
+ ->where('expireAt', '<=', $now + 7 * 86400)
+ ->count();
+ } catch (\Exception $e) {}
+
+ // 总邀请人数(历史所有绑定过的唯一用户数)
+ $totalInvite = 0;
+ try {
+ $totalInvite = Db::name('distribution_bindings')
+ ->where('inviterId', $inviterId)
+ ->distinct(true)
+ ->count('inviteeId');
+ } catch (\Exception $e) {}
+
+ // 根据用户所属企业读取分销配置(显示开关 + 推广中心标题)
+ $enterpriseId = 0;
+ try {
+ $wu = Db::name('wechat_users')->where('id', $inviterId)->field('enterpriseId')->find();
+ $enterpriseId = $wu && isset($wu['enterpriseId']) ? (int) $wu['enterpriseId'] : 0;
+ } catch (\Exception $e) {}
+ $distConfig = null;
+ try {
+ $distRow = Db::name('system_config')
+ ->where('key', 'distribution')
+ ->where('enterprise_id', $enterpriseId)
+ ->find();
+ if (!$distRow && $enterpriseId > 0) {
+ $distRow = Db::name('system_config')
+ ->where('key', 'distribution')
+ ->where('enterprise_id', 0)
+ ->find();
+ }
+ if ($distRow && $distRow['value']) {
+ $distConfig = is_string($distRow['value']) ? json_decode($distRow['value'], true) : $distRow['value'];
+ }
+ } catch (\Exception $e) {}
+ $distributionEnabled = ($distConfig['enabled'] ?? true);
+ $promoCenterTitle = trim((string)($distConfig['promoCenterTitle'] ?? '推广中心')) ?: '推广中心';
+ $commissionRate = (int)($distConfig['commissionRate'] ?? 90);
+ $bindingDays = (int)($distConfig['bindingDays'] ?? 30);
+
+ // 提现规则仅超管可配置,只读 enterprise_id=0 的全局配置
+ $globalDistConfig = null;
+ try {
+ $globalRow = Db::name('system_config')
+ ->where('key', 'distribution')
+ ->where('enterprise_id', 0)
+ ->find();
+ if ($globalRow && $globalRow['value']) {
+ $globalDistConfig = is_string($globalRow['value']) ? json_decode($globalRow['value'], true) : $globalRow['value'];
+ }
+ } catch (\Exception $e) {}
+ $cfg = is_array($globalDistConfig) ? $globalDistConfig : [];
+ $minWithdrawFen = (int)($cfg['minWithdrawFen'] ?? 100);
+ $maxWithdrawFen = (int)($cfg['maxWithdrawFen'] ?? 0);
+ $withdrawFee = (float)($cfg['withdrawFee'] ?? 0);
+ $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
+ $withdrawMinYuan = number_format($minWithdrawFen / 100, 2, '.', '');
+ $withdrawMaxYuan = $maxWithdrawFen > 0 ? number_format($maxWithdrawFen / 100, 2, '.', '') : null;
+ $withdrawFeePct = round($withdrawFee, 1);
+
+ // 读取「人脸分析」测试佣金配置,用于前端展示规则说明
+ $faceSetting = null;
+ try {
+ $faceSetting = self::resolveTestSetting('face', $enterpriseId > 0 ? $enterpriseId : null);
+ } catch (\Throwable $e) {
+ $faceSetting = null;
+ }
+ $faceType = $faceSetting['commissionType'] ?? null;
+ $faceRate = isset($faceSetting['commissionRate']) ? (int)$faceSetting['commissionRate'] : null;
+ $faceAmountFen = isset($faceSetting['commissionAmountFen']) ? (int)$faceSetting['commissionAmountFen'] : null;
+ $faceAmountYuan = $faceAmountFen !== null ? number_format($faceAmountFen / 100, 2, '.', '') : null;
+ $faceNoPayment = !empty($faceSetting['noPayment']);
+
+ return success([
+ 'walletBalance' => number_format($walletBalanceFen / 100, 2, '.', ''),
+ 'totalEarned' => number_format($totalEarnedFen / 100, 2, '.', ''),
+ 'pendingAmount' => number_format($pendingFen / 100, 2, '.', ''),
+ 'bindingCount' => $bindingCount,
+ 'paidCount' => $paidCount,
+ 'expiringCount' => $expiringCount,
+ 'totalInvite' => $totalInvite,
+ 'distributionEnabled' => $distributionEnabled,
+ 'promoCenterTitle' => $promoCenterTitle,
+ 'commissionRate' => $commissionRate,
+ 'bindingDays' => $bindingDays,
+ 'testCommissionType' => $faceType,
+ 'testCommissionRate' => $faceRate,
+ 'testCommissionAmount'=> $faceAmountYuan,
+ 'testNoPayment' => $faceNoPayment,
+ 'withdrawMinYuan' => $withdrawMinYuan,
+ 'withdrawMaxYuan' => $withdrawMaxYuan,
+ 'withdrawFeePct' => $withdrawFeePct,
+ 'requireWithdrawAudit'=> $requireAudit,
+ ]);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/bindings
+ // 我邀请的用户列表(分页,tab: 0=绑定中 1=已付款 2=已过期)
+ // ─────────────────────────────────────────────────────────────
+ public function bindings()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $tab = (int) Request::param('tab', 0);
+ $page = max(1, (int) Request::param('page', 1));
+ $pageSize = min(50, (int) Request::param('pageSize', 20));
+ $now = time();
+
+ $query = Db::name('distribution_bindings')
+ ->alias('b')
+ ->leftJoin('wechat_users u', 'b.inviteeId = u.id')
+ ->field('b.id, b.inviteeId, b.expireAt, b.status, b.createdAt, b.overriddenAt,
+ u.nickname, u.avatar')
+ ->where('b.inviterId', $inviterId);
+
+ switch ($tab) {
+ case 1: // 已付款
+ $paidInviteeIds = Db::name('commission_records')
+ ->where('inviterId', $inviterId)
+ ->whereIn('status', ['paid', 'frozen'])
+ ->column('inviteeId');
+ if (empty($paidInviteeIds)) {
+ return success(['list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize]);
+ }
+ $query->whereIn('b.inviteeId', array_unique($paidInviteeIds));
+ break;
+ case 2: // 已过期
+ $query->where(function ($q) use ($now) {
+ $q->where('b.status', 'overridden')
+ ->whereOr(function ($q2) use ($now) {
+ $q2->where('b.status', 'active')->where('b.expireAt', '<=', $now);
+ });
+ });
+ break;
+ default: // 绑定中
+ $query->where('b.status', 'active')->where('b.expireAt', '>', $now);
+ break;
+ }
+
+ $total = (clone $query)->count();
+ $list = $query->order('b.updatedAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ foreach ($list as &$row) {
+ $row['expireAt'] = (int) $row['expireAt'];
+ $row['remainDays'] = max(0, (int) ceil(($row['expireAt'] - $now) / 86400));
+ $row['avatar'] = $row['avatar'] ?: '';
+ $row['nickname'] = $row['nickname'] ?: '微信用户';
+ }
+
+ return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/commissions
+ // 我的佣金记录(分页)
+ // ─────────────────────────────────────────────────────────────
+ public function commissions()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $page = max(1, (int) Request::param('page', 1));
+ $pageSize = min(50, (int) Request::param('pageSize', 20));
+
+ $list = Db::name('commission_records')
+ ->alias('c')
+ ->leftJoin('wechat_users u', 'c.inviteeId = u.id')
+ ->field('c.id, c.commissionFen, c.orderAmount, c.status, c.scope, c.createdAt,
+ c.frozenAt, c.unfrozenAt, u.nickname, u.avatar')
+ ->where('c.inviterId', $inviterId)
+ ->order('c.createdAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ $total = Db::name('commission_records')
+ ->where('inviterId', $inviterId)
+ ->count();
+
+ foreach ($list as &$row) {
+ $row['commissionYuan'] = number_format($row['commissionFen'] / 100, 2, '.', '');
+ $row['orderYuan'] = number_format($row['orderAmount'] / 100, 2, '.', '');
+ $row['nickname'] = $row['nickname'] ?: '微信用户';
+ $row['avatar'] = $row['avatar'] ?: '';
+ }
+
+ return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // POST /api/distribution/withdraw
+ // 申请提现
+ // ─────────────────────────────────────────────────────────────
+ public function withdraw()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $this->expirePendingWithdrawals();
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $amountFen = (int) Request::param('amountFen', 0);
+ $scope = Request::param('scope', 'personal');
+ $eid = $scope === 'enterprise' ? (int) Request::param('eid', 0) : null;
+
+ list($minFen, $maxFen) = self::getWithdrawLimits($scope, $eid);
+ if ($amountFen < $minFen) {
+ return error('最低提现金额为 ' . round($minFen / 100, 2) . ' 元', 400);
+ }
+ if ($maxFen > 0 && $amountFen > $maxFen) {
+ return error('最高提现金额为 ' . round($maxFen / 100, 2) . ' 元', 400);
+ }
+
+ $wallet = Db::name('wechat_users')
+ ->where('id', $userId)
+ ->field('walletBalance')
+ ->find();
+
+ if (!$wallet || (int) $wallet['walletBalance'] < $amountFen) {
+ return error('余额不足', 400);
+ }
+
+ // 检查是否有待审核的提现申请(status=0 审核中)
+ $pending = Db::name('distribution_withdrawals')
+ ->where('userId', $userId)
+ ->where('status', 0)
+ ->count();
+ if ($pending > 0) {
+ return error('您有待处理的提现申请,请等待审核完成后再次申请', 400);
+ }
+
+ // 手续费(分):按全局配置 enterprise_id=0 的 withdrawFee 比例计算
+ $cfg = self::getDistributionConfig($scope ?: 'personal', $eid);
+ $feePct = (float)($cfg['withdrawFee'] ?? 0);
+ $feeFen = (int) round($amountFen * $feePct / 100);
+ $actualFen = $amountFen - $feeFen;
+ if ($actualFen < $minFen) {
+ return error('实际到账金额不得低于最低提现金额 ' . number_format($minFen / 100, 2, '.', '') . ' 元', 400);
+ }
+
+ $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
+
+ $now = time();
+ Db::startTrans();
+ try {
+ // 冻结余额
+ Db::name('wechat_users')
+ ->where('id', $userId)
+ ->dec('walletBalance', $amountFen)
+ ->update(['updatedAt' => $now]);
+
+ // 写入提现申请:status=0 审核中,并记录手续费(需 insertGetId 以便免审核时更新)
+ $withdrawId = Db::name('distribution_withdrawals')->insertGetId([
+ 'userId' => $userId,
+ 'amountFen' => $amountFen,
+ 'feeFen' => $feeFen,
+ 'status' => 0,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::commit();
+ } catch (\Exception $e) {
+ Db::rollback();
+ return error('申请提现失败:' . $e->getMessage(), 500);
+ }
+
+ // 免审核:自动发起微信转账
+ if (!$requireAudit && $withdrawId > 0) {
+ $wechatUser = Db::name('wechat_users')->where('id', $userId)->field('openid')->find();
+ $openid = $wechatUser['openid'] ?? '';
+ if (empty($openid)) {
+ Db::startTrans();
+ try {
+ Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
+ Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
+ 'status' => 1,
+ 'auditNote' => '无 openid 无法自动打款,请联系管理员',
+ 'auditAt' => time(),
+ 'updatedAt' => time(),
+ ]);
+ Db::commit();
+ } catch (\Exception $e) {
+ Db::rollback();
+ }
+ return error('无法自动打款:未绑定微信 openid,请联系管理员', 400);
+ }
+
+ try {
+ $outBillNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999) . $withdrawId;
+ $service = new \app\common\service\WechatTransferService();
+ $result = $service->createTransfer([
+ 'out_bill_no' => $outBillNo,
+ 'openid' => $openid,
+ 'transfer_amount' => $amountFen,
+ 'transfer_remark' => '推广佣金提现',
+ 'transfer_scene_id' => env('TRANSFER_SCENE_ID', '1005'),
+ 'transfer_scene_report_infos' => [
+ ['info_type' => '岗位类型', 'info_content' => '推广人员'],
+ ['info_type' => '报酬说明', 'info_content' => '推广佣金提现'],
+ ],
+ 'notify_url' => env('WITHDRAW_NOTIFY_URL', ''),
+ ]);
+
+ if ($result['success'] === true) {
+ $wechatData = $result['data'] ?? [];
+ $transferBillNo = $wechatData['transfer_bill_no'] ?? $wechatData['batch_id'] ?? null;
+ $wechatState = $wechatData['state'] ?? $wechatData['batch_status'] ?? 'PROCESSING';
+ Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
+ 'status' => 2,
+ 'auditAt' => $now,
+ 'updatedAt' => $now,
+ 'pay_type' => 'wechat',
+ 'out_bill_no' => $outBillNo,
+ 'transfer_bill_no' => $transferBillNo,
+ 'wechat_pay_state' => $wechatState,
+ 'transfer_scene_id' => $wechatData['transfer_scene_id'] ?? env('TRANSFER_SCENE_ID', '1005'),
+ 'package_info' => $wechatData['package_info'] ?? '',
+ 'mch_id' => env('MCH_ID', null),
+ ]);
+ return success(null, '提现申请已提交,已自动发起微信转账');
+ }
+
+ $err = $result['error'] ?? [];
+ $code = $err['code'] ?? 'UNKNOWN';
+ $msg = $err['message'] ?? '微信转账接口调用失败';
+ Db::startTrans();
+ try {
+ Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
+ Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
+ 'status' => 1,
+ 'auditNote' => "微信转账发起失败({$code}):{$msg}",
+ 'auditAt' => time(),
+ 'updatedAt' => time(),
+ ]);
+ Db::commit();
+ } catch (\Exception $e) {
+ Db::rollback();
+ }
+ return error("自动打款失败({$code}):{$msg}", 500);
+ } catch (\Exception $e) {
+ Db::startTrans();
+ try {
+ Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]);
+ Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([
+ 'status' => 1,
+ 'auditNote' => '自动打款异常:' . $e->getMessage(),
+ 'auditAt' => time(),
+ 'updatedAt' => time(),
+ ]);
+ Db::commit();
+ } catch (\Exception $ex) {
+ Db::rollback();
+ }
+ return error('自动打款异常:' . $e->getMessage(), 500);
+ }
+ }
+
+ return success(null, '提现申请已提交,请等待审核');
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/withdrawals
+ // 我的提现记录
+ // ─────────────────────────────────────────────────────────────
+ public function withdrawals()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $page = max(1, (int) Request::param('page', 1));
+ $pageSize = min(50, (int) Request::param('pageSize', 20));
+
+ $this->expirePendingWithdrawals();
+
+ $list = Db::name('distribution_withdrawals')
+ ->where('userId', $userId)
+ ->order('createdAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ $total = Db::name('distribution_withdrawals')
+ ->where('userId', $userId)
+ ->count();
+
+ foreach ($list as &$row) {
+ $row['amountYuan'] = number_format($row['amountFen'] / 100, 2, '.', '');
+ $feeFen = (int)($row['feeFen'] ?? 0);
+ $row['feeYuan'] = number_format($feeFen / 100, 2, '.', '');
+ }
+
+ return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // POST /api/distribution/withdrawals/query-transfer
+ // 用户确认收款后,主动查询微信转账单状态并更新本地订单(及时刷新)
+ // 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012716437
+ // ─────────────────────────────────────────────────────────────
+ public function queryTransfer()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ return error('未登录', 401);
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $id = (int) Request::param('id', 0);
+ if ($id <= 0) {
+ return error('参数错误', 400);
+ }
+
+ $record = Db::name('distribution_withdrawals')
+ ->where('id', $id)
+ ->where('userId', $userId)
+ ->find();
+ if (!$record) {
+ return error('提现记录不存在或无权限', 404);
+ }
+
+ $outBillNo = trim((string) ($record['out_bill_no'] ?? ''));
+ if (!$outBillNo) {
+ return error('该提现单暂无商户单号,无法查询', 400);
+ }
+
+ try {
+ $service = new \app\common\service\WechatTransferService();
+ $result = $service->queryByOutBillNo($outBillNo);
+ } catch (\Throwable $e) {
+ Log::error('queryTransfer WechatTransferService error: ' . $e->getMessage());
+ return error('查询转账状态失败:' . $e->getMessage(), 500);
+ }
+
+ if ($result['success'] !== true || empty($result['data'])) {
+ $err = $result['error'] ?? [];
+ return error('查询失败:' . ($err['message'] ?? '未知错误'), 500);
+ }
+
+ $data = $result['data'];
+ $state = trim((string) ($data['state'] ?? ''));
+ $now = time();
+ $billNo = $data['transfer_bill_no'] ?? null;
+
+ if ($state === 'SUCCESS') {
+ Db::name('distribution_withdrawals')->where('id', $id)->update([
+ 'status' => 3,
+ 'wechat_pay_state' => $state,
+ 'transfer_bill_no' => $billNo,
+ 'transferAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ return success(['status' => 3, 'statusLabel' => '已收款'], '已收款');
+ }
+
+ if ($state === 'FAIL') {
+ Db::startTrans();
+ try {
+ Db::name('wechat_users')
+ ->where('id', $record['userId'])
+ ->inc('walletBalance', (int) $record['amountFen'])
+ ->update(['updatedAt' => $now]);
+ Db::name('distribution_withdrawals')->where('id', $id)->update([
+ 'status' => 1,
+ 'auditNote' => $data['fail_reason'] ?? '微信转账失败',
+ 'wechat_pay_state' => $state,
+ 'transfer_bill_no' => $billNo,
+ 'updatedAt' => $now,
+ ]);
+ Db::commit();
+ } catch (\Throwable $e) {
+ Db::rollback();
+ Log::error('queryTransfer FAIL rollback: ' . $e->getMessage());
+ return error('更新失败', 500);
+ }
+ return success(['status' => 1, 'statusLabel' => '已驳回'], '转账失败,余额已退回');
+ }
+
+ return success(['status' => (int) $record['status'], 'state' => $state], '状态未变更');
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/qrcode
+ // 生成当前用户专属小程序推广码,直接输出 PNG 二进制流
+ // 可选参数:scope=personal|enterprise;eid=企业ID(仅 scope=enterprise 时有效)
+ // ─────────────────────────────────────────────────────────────
+ public function qrcode()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ http_response_code(401);
+ exit('Unauthorized');
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $scope = Request::param('scope', '');
+ // 显式传 scope=personal 时强制个人版,不认 eid
+ $enterpriseId = ($scope === 'personal') ? null : Request::param('eid', null);
+ $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null;
+
+ // scene 参数:uid=用户ID(+eid=企业ID),最长 32 字符
+ if ($enterpriseId) {
+ $scene = "uid={$userId}&eid={$enterpriseId}";
+ $page = 'pages/enterprise/index';
+ } else {
+ $scene = "uid={$userId}";
+ $page = 'pages/index/index';
+ }
+
+ // 调用微信接口生成小程序码
+ $result = WechatService::getWxacodeUnlimited($scene, $page, 280);
+
+ if (isset($result['errcode'])) {
+ http_response_code(500);
+ exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($result['errmsg'] ?? '未知错误')]));
+ }
+
+ // 直接输出 PNG 二进制流,供 wx.downloadFile 使用
+ header('Content-Type: image/png');
+ header('Cache-Control: max-age=3600');
+ echo $result['binary'];
+ exit();
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // GET /api/distribution/poster
+ // 生成完整海报(后端合成头像+二维码),直接输出 PNG
+ // ─────────────────────────────────────────────────────────────
+ public function poster()
+ {
+ $user = $this->resolveUser();
+ if (!$user) {
+ http_response_code(401);
+ exit('Unauthorized');
+ }
+
+ $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
+ $scope = Request::param('scope', '');
+ // scope=personal 时不读 eid 参数
+ $eidParam = ($scope === 'personal') ? null : Request::param('eid', null);
+ $enterpriseId = $eidParam !== null ? (int) $eidParam : null;
+
+ if ($userId <= 0) {
+ http_response_code(400);
+ exit(json_encode(['code' => 400, 'msg' => '用户信息异常']));
+ }
+
+ $wechatUser = Db::name('wechat_users')
+ ->where('id', $userId)
+ ->field('id, nickname, avatar, enterpriseId')
+ ->find();
+
+ // 未显式传 eid 且非强制个人版时,从用户 DB 记录自动取企业 ID
+ if ($enterpriseId === null && $scope !== 'personal' && !empty($wechatUser['enterpriseId'])) {
+ $enterpriseId = (int) $wechatUser['enterpriseId'];
+ }
+ $userData = [
+ 'id' => $userId,
+ 'nickname' => $wechatUser['nickname'] ?? '好友',
+ 'avatar' => $wechatUser['avatar'] ?? '',
+ ];
+
+ $scene = $enterpriseId ? "uid={$userId}&eid={$enterpriseId}" : "uid={$userId}";
+ $page = $enterpriseId ? 'pages/enterprise/index' : 'pages/index/index';
+ $qrResult = WechatService::getWxacodeUnlimited($scene, $page, 280);
+
+ if (isset($qrResult['errcode'])) {
+ http_response_code(500);
+ exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($qrResult['errmsg'] ?? '')]));
+ }
+
+ $avatarBinary = null;
+ if (!empty($userData['avatar'])) {
+ $avatarBinary = PosterService::fetchImage($userData['avatar']);
+ }
+
+ try {
+ $png = PosterService::buildFromConfig($userData, $qrResult['binary'], $avatarBinary, $enterpriseId);
+ } catch (\Throwable $e) {
+ http_response_code(500);
+ exit(json_encode(['code' => 500, 'msg' => '海报合成失败:' . $e->getMessage()]));
+ }
+
+ header('Content-Type: image/png');
+ header('Cache-Control: max-age=3600');
+ echo $png;
+ exit();
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 内部方法:订单付款成功后结算佣金(由 Payment/notify 调用)
+ // $orderId: orders.id (整数)
+ // ─────────────────────────────────────────────────────────────
+ public static function settleCommission(int $orderId): void
+ {
+ $order = Db::name('orders')
+ ->where('id', $orderId)
+ ->field('id, userId, enterpriseId, amount, status')
+ ->find();
+
+ if (!$order || $order['status'] !== 'paid') {
+ return;
+ }
+
+ $inviteeId = (int) $order['userId'];
+ $orderAmount = (int) $order['amount'];
+ $enterpriseId = !empty($order['enterpriseId']) ? (int) $order['enterpriseId'] : null;
+ $scope = $enterpriseId ? 'enterprise' : 'personal';
+ $now = time();
+
+ // 【第一步】精确匹配:scope + enterpriseId 与订单完全一致
+ $binding = Db::name('distribution_bindings')
+ ->where('inviteeId', $inviteeId)
+ ->where('scope', $scope)
+ ->where(function ($q) use ($enterpriseId) {
+ if ($enterpriseId) {
+ $q->where('enterpriseId', $enterpriseId);
+ } else {
+ $q->whereNull('enterpriseId');
+ }
+ })
+ ->where('status', 'active')
+ ->where('expireAt', '>', time())
+ ->find();
+
+ // 【第二步】回退匹配:精确未命中时,查找任意有效的 personal 绑定
+ // 跨 scope 场景(如企业版订单但推荐人仅持有 personal 绑定),以个人版配置+平台资金结算
+ $fallbackScope = $scope;
+ $fallbackEnterpriseId = $enterpriseId;
+ if (!$binding && $scope === 'enterprise') {
+ $binding = self::findActivePersonalBinding($inviteeId, $enterpriseId);
+ if ($binding) {
+ $fallbackScope = 'personal';
+ $fallbackEnterpriseId = !empty($binding['enterpriseId']) ? (int) $binding['enterpriseId'] : null;
+ }
+ }
+
+ if (!$binding) {
+ return;
+ }
+
+ // 实际用于结算的 scope/enterpriseId(可能已回退为 personal)
+ $scope = $fallbackScope;
+ $enterpriseId = $fallbackEnterpriseId;
+
+ $inviterId = (int) $binding['inviterId'];
+
+ // 从订单关联的 test_results 中取 testType,用于读取 per-test 佣金配置
+ $testType = null;
+ try {
+ $tr = Db::name('test_results')->where('orderId', $orderId)->field('testType')->find();
+ if ($tr) $testType = $tr['testType'] === 'ai' ? 'face' : ($tr['testType'] ?? null);
+ } catch (\Throwable $e) {}
+
+ // 读取佣金配置(优先 per-test testSettings,回退全局)
+ list($rate, $amountFen) = self::getTestCommissionConfig($testType, $scope, $enterpriseId);
+ $commissionFen = 0;
+ if ($amountFen > 0) {
+ $commissionFen = $amountFen;
+ $rate = 0;
+ } elseif ($rate > 0) {
+ $commissionFen = (int) floor($orderAmount * $rate / 100);
+ }
+ if ($commissionFen <= 0) {
+ return;
+ }
+
+ // 避免同一订单重复结算
+ $exists = Db::name('commission_records')
+ ->where('orderId', $orderId)
+ ->find();
+ if ($exists) {
+ return;
+ }
+
+ Db::startTrans();
+ try {
+ $commissionStatus = 'pending';
+
+ if ($enterpriseId) {
+ // 企业上下文:优先从企业余额扣款,余额不足则冻结
+ $enterprise = Db::name('enterprises')
+ ->where('id', $enterpriseId)
+ ->field('id, balance')
+ ->lock(true)
+ ->find();
+
+ $balanceFen = (int) ($enterprise['balance'] ?? 0);
+
+ if ($enterprise && $balanceFen >= $commissionFen) {
+ // 余额充足,直接结算
+ $newBalanceFen = $balanceFen - $commissionFen;
+ Db::name('enterprises')
+ ->where('id', $enterpriseId)
+ ->update([
+ 'balance' => $newBalanceFen,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::name('finance_records')->insert([
+ 'enterpriseId' => $enterpriseId,
+ 'type' => 'consume',
+ 'amount' => $commissionFen,
+ 'balanceBefore' => $balanceFen,
+ 'balanceAfter' => $newBalanceFen,
+ 'description' => self::buildCommissionFinanceDescription($inviteeId, $testType, 'order_paid'),
+ 'orderId' => $orderId,
+ 'createdAt' => $now,
+ ]);
+
+ // 推荐人钱包入账
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletBalance', $commissionFen)
+ ->inc('walletTotalEarned', $commissionFen)
+ ->update(['updatedAt' => $now]);
+
+ $commissionStatus = 'paid';
+ } else {
+ // 余额不足,冻结
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletPending', $commissionFen)
+ ->update(['updatedAt' => $now]);
+
+ $commissionStatus = 'frozen';
+ }
+ } else {
+ // 无企业上下文:平台直接发放(入账钱包)
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletBalance', $commissionFen)
+ ->inc('walletTotalEarned', $commissionFen)
+ ->update(['updatedAt' => $now]);
+
+ $commissionStatus = 'paid';
+ }
+
+ // 写佣金记录
+ Db::name('commission_records')->insert([
+ 'agentId' => $inviterId,
+ 'orderId' => $orderId,
+ 'scope' => $scope,
+ 'enterpriseId' => $enterpriseId,
+ 'inviterId' => $inviterId,
+ 'inviteeId' => $inviteeId,
+ 'bindingId' => (int) $binding['id'],
+ 'commissionRate'=> $rate,
+ 'orderAmount' => $orderAmount,
+ 'commissionFen' => $commissionFen,
+ 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''),
+ 'status' => $commissionStatus,
+ 'frozenAt' => $commissionStatus === 'frozen' ? $now : null,
+ 'paidAt' => $commissionStatus === 'paid' ? $now : null,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::commit();
+ } catch (\Exception $e) {
+ Db::rollback();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 内部方法:企业充值后解冻冻结中的佣金
+ // $enterpriseId: 企业ID
+ // ─────────────────────────────────────────────────────────────
+ public static function unfreezeCommissions(int $enterpriseId): void
+ {
+ $now = time();
+
+ // 找出该企业所有冻结中的佣金(按时间升序,先冻先解)
+ $frozenList = Db::name('commission_records')
+ ->where('enterpriseId', $enterpriseId)
+ ->where('status', 'frozen')
+ ->order('createdAt', 'asc')
+ ->select()
+ ->toArray();
+
+ if (empty($frozenList)) {
+ return;
+ }
+
+ $enterprise = Db::name('enterprises')
+ ->where('id', $enterpriseId)
+ ->field('id, balance')
+ ->lock(true)
+ ->find();
+
+ if (!$enterprise) {
+ return;
+ }
+
+ $balanceFen = (int) ($enterprise['balance'] ?? 0);
+
+ Db::startTrans();
+ try {
+ foreach ($frozenList as $record) {
+ $commissionFen = (int) $record['commissionFen'];
+ if ($balanceFen < $commissionFen) {
+ break;
+ }
+
+ $balanceFen -= $commissionFen;
+ $inviterId = (int) $record['inviterId'];
+
+ // 更新佣金记录状态
+ Db::name('commission_records')
+ ->where('id', $record['id'])
+ ->update([
+ 'status' => 'paid',
+ 'paidAt' => $now,
+ 'unfrozenAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+
+ $recordTestType = null;
+ if (($record['commissionSource'] ?? '') === 'test_completion') {
+ $recordTestType = Db::name('test_results')
+ ->where('id', (int) ($record['testResultId'] ?? 0))
+ ->value('testType');
+ } elseif (!empty($record['orderId'])) {
+ $recordTestType = self::getOrderTestType((int) $record['orderId']);
+ }
+
+ Db::name('finance_records')->insert([
+ 'enterpriseId' => $enterpriseId,
+ 'type' => 'consume',
+ 'amount' => $commissionFen,
+ 'balanceBefore' => $balanceFen + $commissionFen,
+ 'balanceAfter' => $balanceFen,
+ 'description' => self::buildCommissionFinanceDescription((int) ($record['inviteeId'] ?? 0), $recordTestType, 'unfrozen'),
+ 'orderId' => !empty($record['orderId']) ? (int) $record['orderId'] : null,
+ 'createdAt' => $now,
+ ]);
+
+ // 推荐人钱包:pending 转 balance
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletBalance', $commissionFen)
+ ->dec('walletPending', $commissionFen)
+ ->inc('walletTotalEarned', $commissionFen)
+ ->update(['updatedAt' => $now]);
+ }
+
+ // 更新企业余额
+ Db::name('enterprises')
+ ->where('id', $enterpriseId)
+ ->update([
+ 'balance' => $balanceFen,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::commit();
+ } catch (\Exception $e) {
+ Db::rollback();
+ }
+ }
+
+ /**
+ * 读取提现上下限(分)
+ * @return array{int,int} [minFen, maxFen] maxFen=0 表示不限制
+ */
+ private static function getWithdrawLimits(string $scope, ?int $enterpriseId): array
+ {
+ $cfg = self::getDistributionConfig($scope, $enterpriseId);
+ $minFen = max(self::MIN_WITHDRAW_FEN, min(self::MAX_WITHDRAW_FEN, (int)($cfg['minWithdrawFen'] ?? self::MIN_WITHDRAW_FEN)));
+ $maxFen = (int)($cfg['maxWithdrawFen'] ?? 0);
+ if ($maxFen > 0) {
+ $maxFen = min(self::MAX_WITHDRAW_FEN, $maxFen);
+ }
+ return [$minFen, $maxFen];
+ }
+
+ /**
+ * 读取分销配置
+ * 企业模式:enterprise_id={eid} 行,不存在则降级到 enterprise_id=0 全局行
+ * 个人版:enterprise_id=0 行
+ */
+ private static function getDistributionConfig(string $scope, ?int $enterpriseId): array
+ {
+ if ($enterpriseId > 0) {
+ $config = Db::name('system_config')
+ ->where('key', 'distribution')
+ ->where('enterprise_id', $enterpriseId)
+ ->find();
+ if ($config && $config['value']) {
+ $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
+ if (is_array($cfg)) return $cfg;
+ }
+ }
+ // 全局/个人版配置(enterprise_id=0)
+ $config = Db::name('system_config')
+ ->where('key', 'distribution')
+ ->where('enterprise_id', 0)
+ ->find();
+ if ($config && $config['value']) {
+ $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
+ if (is_array($cfg)) return $cfg;
+ }
+ return [];
+ }
+
+ /**
+ * 读取佣金配置,返回 [rate, amountFen];比例模式 rate>0 amountFen=0,金额模式 amountFen>0 rate=0
+ * @deprecated 用 getTestCommissionConfig 替代
+ */
+ private static function getCommissionConfig(string $scope, ?int $enterpriseId): array
+ {
+ $cfg = self::getDistributionConfig($scope, $enterpriseId);
+ $type = $cfg['commissionType'] ?? 'ratio';
+ if ($type === 'amount') {
+ $amountFen = (int)($cfg['commissionAmountFen'] ?? 0);
+ return [0, $amountFen];
+ }
+ $rate = (int)($cfg['commissionRate'] ?? 90);
+ return [$rate, 0];
+ }
+
+ /**
+ * 读取指定测试类型的佣金配置 [rate, amountFen]
+ * 优先使用 testSettings[testType],若无则回退全局 commissionRate/commissionAmountFen
+ */
+ private static function getTestCommissionConfig(?string $testType, string $scope, ?int $enterpriseId): array
+ {
+ $ts = self::resolveTestSetting($testType, $enterpriseId);
+ if ($ts) {
+ $commType = $ts['commissionType'] ?? 'ratio';
+ if ($commType === 'amount') {
+ return [0, (int)($ts['commissionAmountFen'] ?? 0)];
+ }
+ return [(int)($ts['commissionRate'] ?? 90), 0];
+ }
+ return self::getCommissionConfig($scope, $enterpriseId);
+ }
+
+ /**
+ * 解析某测试类型的 testSettings 配置(企业优先,回退全局),返回 null 表示未启用
+ */
+ private static function resolveTestSetting(?string $testType, ?int $enterpriseId): ?array
+ {
+ if (!$testType) return null;
+ $tryEids = array_filter([$enterpriseId > 0 ? $enterpriseId : null, null], fn($v) => $v !== false);
+ foreach ($tryEids as $eid) {
+ $cfg = self::getDistributionConfig('personal', $eid);
+ $ts = $cfg['testSettings'][$testType] ?? null;
+ if ($ts && !empty($ts['enabled'])) {
+ return $ts;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 查找 personal 维度有效绑定:
+ * 1. 优先 `enterpriseId = 当前企业`
+ * 2. 其次 `enterpriseId IS NULL`
+ */
+ private static function findActivePersonalBinding(int $inviteeId, ?int $enterpriseId): ?array
+ {
+ if ($enterpriseId > 0) {
+ $binding = Db::name('distribution_bindings')
+ ->where('inviteeId', $inviteeId)
+ ->where('scope', 'personal')
+ ->where('enterpriseId', $enterpriseId)
+ ->where('status', 'active')
+ ->where('expireAt', '>', time())
+ ->find();
+ if ($binding) {
+ return $binding;
+ }
+ }
+
+ $binding = Db::name('distribution_bindings')
+ ->where('inviteeId', $inviteeId)
+ ->where('scope', 'personal')
+ ->whereNull('enterpriseId')
+ ->where('status', 'active')
+ ->where('expireAt', '>', time())
+ ->find();
+
+ return $binding ?: null;
+ }
+
+ /**
+ * 读取佣金比例配置(百分比整数,兼容旧逻辑)
+ */
+ private static function getCommissionRate(string $scope, ?int $enterpriseId): int
+ {
+ list($rate, $amountFen) = self::getCommissionConfig($scope, $enterpriseId);
+ return $rate;
+ }
+
+ /**
+ * 根据订单读取测试类型
+ */
+ private static function getOrderTestType(int $orderId): ?string
+ {
+ if ($orderId <= 0) {
+ return null;
+ }
+
+ $testType = Db::name('test_results')
+ ->where('orderId', $orderId)
+ ->value('testType');
+
+ if (!$testType) {
+ return null;
+ }
+
+ return $testType === 'ai' ? 'face' : $testType;
+ }
+
+ /**
+ * 获取用户展示名称
+ */
+ private static function getUserDisplayName(int $userId): string
+ {
+ if ($userId <= 0) {
+ return '未知用户';
+ }
+
+ $nickname = Db::name('wechat_users')
+ ->where('id', $userId)
+ ->value('nickname');
+
+ return $nickname ? (string) $nickname : ('用户' . $userId);
+ }
+
+ /**
+ * 测试类型文案
+ */
+ private static function getTestTypeLabel(?string $testType): string
+ {
+ $normalized = $testType === 'ai' ? 'face' : (string) $testType;
+ $map = [
+ 'face' => '人脸',
+ 'mbti' => 'MBTI',
+ 'disc' => 'DISC',
+ 'pdp' => 'PDP',
+ ];
+
+ return $map[$normalized] ?? strtoupper($normalized ?: '未知测试');
+ }
+
+ /**
+ * 企业财务流水中的佣金支出说明
+ */
+ private static function buildCommissionFinanceDescription(int $inviteeId, ?string $testType, string $scene): string
+ {
+ $userName = self::getUserDisplayName($inviteeId);
+ $testLabel = self::getTestTypeLabel($testType);
+
+ if ($scene === 'unfrozen') {
+ return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销(冻结后解冻)';
+ }
+
+ return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销';
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 内部方法:测试完成后结算「测试完成佣金」(由 Test/submit 调用)
+ // 仅适用于 personal scope;只要有有效绑定即可,无需付款。
+ // 防重:同一 testResultId 只结算一次。
+ // ─────────────────────────────────────────────────────────────
+ public static function settleTestCommission(int $testResultId, int $inviteeId, string $testType): void
+ {
+ // 仅支持指定测试类型
+ $allowedTypes = ['face', 'mbti', 'disc', 'pdp'];
+ // face/ai 统一归类为 face
+ $normalizedType = ($testType === 'ai') ? 'face' : $testType;
+ if (!in_array($normalizedType, $allowedTypes, true)) {
+ return;
+ }
+
+ // 读取 testSettings 配置:优先读用户所属企业配置,未配置则回退全局
+ $userEid = (int)(Db::name('wechat_users')->where('id', $inviteeId)->value('enterpriseId') ?? 0);
+ $tsConfig = self::resolveTestSetting($normalizedType, $userEid);
+ if (!$tsConfig || empty($tsConfig['enabled']) || empty($tsConfig['noPayment'])) {
+ return;
+ }
+ list($rate, $amountFen) = self::getTestCommissionConfig($normalizedType, 'personal', $userEid > 0 ? $userEid : null);
+ $commissionFen = 0;
+ if ($amountFen > 0) {
+ $commissionFen = $amountFen;
+ } elseif ($rate > 0) {
+ // noPayment 场景无订单金额,若为比例则跳过(无金额可算)
+ return;
+ }
+ if ($commissionFen <= 0) {
+ return;
+ }
+
+ // 查找该用户 personal scope 的有效绑定:优先企业 personal 绑定,再回退全局 personal 绑定
+ $binding = self::findActivePersonalBinding($inviteeId, $userEid > 0 ? $userEid : null);
+ if (!$binding) {
+ return;
+ }
+
+ $inviterId = (int) $binding['inviterId'];
+ $recordEnterpriseId = $userEid > 0
+ ? $userEid
+ : (((int)($binding['enterpriseId'] ?? 0)) > 0 ? (int)$binding['enterpriseId'] : null);
+
+ // 防重:同一 testResultId 只允许一条 test_completion 佣金
+ $exists = Db::name('commission_records')
+ ->where('testResultId', $testResultId)
+ ->where('commissionSource', 'test_completion')
+ ->find();
+ if ($exists) {
+ return;
+ }
+
+ $now = time();
+ Db::startTrans();
+ try {
+ $commissionStatus = 'paid';
+
+ if ($recordEnterpriseId) {
+ // 企业上下文:先扣企业余额;不足则冻结到后续补余额再解冻
+ $enterprise = Db::name('enterprises')
+ ->where('id', $recordEnterpriseId)
+ ->field('id, balance')
+ ->lock(true)
+ ->find();
+
+ $balanceFen = (int) ($enterprise['balance'] ?? 0);
+ if ($enterprise && $balanceFen >= $commissionFen) {
+ $newBalanceFen = $balanceFen - $commissionFen;
+ Db::name('enterprises')
+ ->where('id', $recordEnterpriseId)
+ ->update([
+ 'balance' => $newBalanceFen,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::name('finance_records')->insert([
+ 'enterpriseId' => $recordEnterpriseId,
+ 'type' => 'consume',
+ 'amount' => $commissionFen,
+ 'balanceBefore' => $balanceFen,
+ 'balanceAfter' => $newBalanceFen,
+ 'description' => self::buildCommissionFinanceDescription($inviteeId, $normalizedType, 'test_completion'),
+ 'orderId' => null,
+ 'createdAt' => $now,
+ ]);
+
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletBalance', $commissionFen)
+ ->inc('walletTotalEarned', $commissionFen)
+ ->update(['updatedAt' => $now]);
+ } else {
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletPending', $commissionFen)
+ ->update(['updatedAt' => $now]);
+
+ $commissionStatus = 'frozen';
+ }
+ } else {
+ // 无企业上下文时仍由平台直接发放
+ Db::name('wechat_users')
+ ->where('id', $inviterId)
+ ->inc('walletBalance', $commissionFen)
+ ->inc('walletTotalEarned', $commissionFen)
+ ->update(['updatedAt' => $now]);
+ }
+
+ // 写佣金记录
+ Db::name('commission_records')->insert([
+ 'agentId' => $inviterId,
+ 'orderId' => null,
+ 'testResultId' => $testResultId,
+ 'commissionSource' => 'test_completion',
+ 'scope' => 'personal',
+ 'enterpriseId' => $recordEnterpriseId,
+ 'inviterId' => $inviterId,
+ 'inviteeId' => $inviteeId,
+ 'bindingId' => (int) $binding['id'],
+ 'commissionRate' => $rate,
+ 'orderAmount' => 0,
+ 'commissionFen' => $commissionFen,
+ 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''),
+ 'status' => $commissionStatus,
+ 'frozenAt' => $commissionStatus === 'frozen' ? $now : null,
+ 'paidAt' => $commissionStatus === 'paid' ? $now : null,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::commit();
+ } catch (\Throwable $e) {
+ Db::rollback();
+ Log::error('settleTestCommission failed', [
+ 'testResultId' => $testResultId,
+ 'inviteeId' => $inviteeId,
+ 'testType' => $testType,
+ 'normalizedType' => $normalizedType,
+ 'userEnterpriseId' => $userEid,
+ 'bindingId' => (int)($binding['id'] ?? 0),
+ 'inviterId' => $inviterId,
+ 'commissionFen' => $commissionFen,
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+}
diff --git a/api/app/controller/api/Payment.php b/api/app/controller/api/Payment.php
index c0771d4..ad9ed4e 100644
--- a/api/app/controller/api/Payment.php
+++ b/api/app/controller/api/Payment.php
@@ -5,6 +5,7 @@ use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
+use app\common\service\FeishuLeadWebhookService;
use think\facade\Request;
use think\facade\Db;
@@ -313,6 +314,8 @@ class Payment extends BaseController
return error('订单不存在', 404);
}
+ $prevStatus = (string) ($order['status'] ?? '');
+
// 仅允许从 pending → 其他状态,避免重复更新已完成订单
if ($order['status'] !== 'pending' && $order['status'] !== 'paid') {
return success(null, '订单状态已更新,无需重复通知');
@@ -339,6 +342,12 @@ class Payment extends BaseController
// 支付成功时:将关联该订单的测试结果标记为已付款,并记录当时付款金额(分)
if ($status === 'success') {
+ if ($prevStatus === 'pending') {
+ try {
+ FeishuLeadWebhookService::onOrderPaid((int) $order['id'], (int) ($order['userId'] ?? 0));
+ } catch (\Throwable $e) {
+ }
+ }
$paidAmountFen = isset($order['amount']) ? (int) $order['amount'] : 0;
Db::name('test_results')
@@ -458,6 +467,11 @@ class Payment extends BaseController
// 佣金结算失败不影响主流程
}
}
+
+ try {
+ FeishuLeadWebhookService::onOrderPaid((int) $localOrder['id'], (int) ($localOrder['userId'] ?? 0));
+ } catch (\Throwable $e) {
+ }
}
}
}
diff --git a/api/app/controller/superadmin/Analytics.php b/api/app/controller/superadmin/Analytics.php
new file mode 100644
index 0000000..4374eef
--- /dev/null
+++ b/api/app/controller/superadmin/Analytics.php
@@ -0,0 +1,100 @@
+field('eventName, COUNT(*) AS cnt')
+ ->where('createdAt', '>=', $since)
+ ->group('eventName')
+ ->order('cnt', 'desc')
+ ->select()
+ ->toArray();
+
+ $total = Db::name('analytics_events')
+ ->where('createdAt', '>=', $since)
+ ->count();
+
+ return success([
+ 'days' => $days,
+ 'total' => (int) $total,
+ 'list' => $list,
+ 'tableMissing' => false,
+ ]);
+ } catch (\Throwable $e) {
+ return success([
+ 'days' => $days,
+ 'total' => 0,
+ 'list' => [],
+ 'tableMissing' => true,
+ ]);
+ }
+ }
+
+ /**
+ * GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
+ */
+ public function events()
+ {
+ $days = min(90, max(1, (int) Request::param('days', 7)));
+ $since = date('Y-m-d H:i:s', time() - $days * 86400);
+ $page = max(1, (int) Request::param('page', 1));
+ $pageSize = min(100, max(10, (int) Request::param('pageSize', 50)));
+
+ try {
+ $total = (int) Db::name('analytics_events')
+ ->where('createdAt', '>=', $since)
+ ->count();
+ $offset = ($page - 1) * $pageSize;
+ $rows = Db::name('analytics_events')
+ ->where('createdAt', '>=', $since)
+ ->order('id', 'desc')
+ ->limit($offset, $pageSize)
+ ->select()
+ ->toArray();
+
+ foreach ($rows as &$r) {
+ if (!empty($r['propsJson'])) {
+ $decoded = json_decode($r['propsJson'], true);
+ $r['props'] = is_array($decoded) ? $decoded : null;
+ } else {
+ $r['props'] = null;
+ }
+ unset($r['propsJson']);
+ }
+ unset($r);
+
+ return success([
+ 'list' => $rows,
+ 'total' => $total,
+ 'page' => $page,
+ 'pageSize' => $pageSize,
+ 'tableMissing' => false,
+ ]);
+ } catch (\Throwable $e) {
+ return success([
+ 'list' => [],
+ 'total' => 0,
+ 'page' => $page,
+ 'pageSize' => $pageSize,
+ 'tableMissing' => true,
+ ]);
+ }
+ }
+}
diff --git a/api/app/controller/superadmin/AppUser.php b/api/app/controller/superadmin/AppUser.php
index 9fc0135..4fe81d7 100644
--- a/api/app/controller/superadmin/AppUser.php
+++ b/api/app/controller/superadmin/AppUser.php
@@ -1,525 +1,566 @@
-request->user ?? null;
- if (!$user || ($user['role'] ?? '') !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- // 用户数按 openid 去重
- try {
- $totalUsers = (int) Db::name('wechat_users')->count('openid', true);
- } catch (\Throwable $e) {
- $totalUsers = (int) Db::name('wechat_users')->count();
- }
- $last30d = time() - 30 * 86400;
-
- // 全部池:去重后的测试用户 & 近 30 天活跃用户(按 userId 去重)
- // 这里使用逻辑表名 test_results,底层会自动加前缀生成 mbti_test_results
- $testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
- $testedUsers = count(array_filter($testedUserIds));
-
- $activeUserIds = Db::name('test_results')
- ->where('createdAt', '>=', $last30d)
- ->distinct(true)
- ->column('userId');
- $activeUsers = count(array_filter($activeUserIds));
-
- $userCards = [
- [
- 'type' => 'all',
- 'name' => '全部用户',
- 'total' => $totalUsers,
- 'active' => $activeUsers,
- 'tested' => $testedUsers
- ]
- ];
-
- try {
- // 个人池:enterpriseId 为空的测试用户,按 userId 去重
- $individualIds = Db::name('test_results')
- ->where(function ($q) {
- $q->whereNull('enterpriseId')->whereOr('enterpriseId', '');
- })
- ->distinct(true)
- ->column('userId');
- $individualTotal = count(array_filter($individualIds));
-
- $individualActiveIds = Db::name('test_results')
- ->where('createdAt', '>=', $last30d)
- ->where(function ($q) {
- $q->whereNull('enterpriseId')->whereOr('enterpriseId', '');
- })
- ->distinct(true)
- ->column('userId');
- $individualActive = count(array_filter($individualActiveIds));
- $userCards[] = [
- 'type' => 'individual',
- 'name' => '个人用户(无企业)',
- 'total' => $individualTotal,
- 'active' => $individualActive,
- 'tested' => $individualTotal
- ];
- } catch (\Throwable $e) {
- $userCards[] = [
- 'type' => 'individual',
- 'name' => '个人用户(无企业)',
- 'total' => 0,
- 'active' => 0,
- 'tested' => 0
- ];
- }
-
- $enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
- foreach ($enterprises as $e) {
- $eid = $e['id'];
- try {
- $ids = Db::name('test_results')
- ->where('enterpriseId', $eid)
- ->distinct(true)
- ->column('userId');
- $total = count(array_filter($ids));
-
- $activeIds = Db::name('test_results')
- ->where('enterpriseId', $eid)
- ->where('createdAt', '>=', $last30d)
- ->distinct(true)
- ->column('userId');
- $active = count(array_filter($activeIds));
- } catch (\Throwable $ex) {
- $total = 0;
- $active = 0;
- }
- $userCards[] = [
- 'type' => 'enterprise',
- 'enterpriseId' => $eid,
- 'name' => $e['name'] ?? ('企业' . $eid),
- 'total' => $total,
- 'active' => $active,
- 'tested' => $total
- ];
- }
-
- // MBTI 类型分布:按用户去重,每人只计其最新一次 MBTI 结果
- $mbtiTypes = [];
- try {
- $rows = Db::name('test_results')
- ->where('testType', 'mbti')
- ->field('userId, resultData, createdAt')
- ->order('createdAt', 'desc')
- ->select()
- ->toArray();
- $seenUserIds = [];
- foreach ($rows as $r) {
- $uid = (int) ($r['userId'] ?? 0);
- if ($uid <= 0 || isset($seenUserIds[$uid])) {
- continue;
- }
- $raw = $r['resultData'] ?? '';
- $dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
- if (!is_array($dec)) {
- $seenUserIds[$uid] = true;
- continue;
- }
- $type = '';
- if (isset($dec['mbtiType'])) {
- $type = $dec['mbtiType'];
- } elseif (isset($dec['mbti']['type'])) {
- $type = $dec['mbti']['type'];
- } elseif (isset($dec['type'])) {
- $type = $dec['type'];
- }
- $type = strtoupper(trim((string) $type));
- $seenUserIds[$uid] = true;
- if ($type === '') {
- continue;
- }
- $mbtiTypes[$type] = ($mbtiTypes[$type] ?? 0) + 1;
- }
- } catch (\Throwable $e) {
- // ignore
- }
- $mbtiDistribution = [];
- foreach ($mbtiTypes as $type => $count) {
- $mbtiDistribution[] = ['type' => $type, 'count' => $count];
- }
-
- return success([
- 'totalUsers' => $totalUsers,
- 'testedUsers' => $testedUsers,
- 'activeUsers' => $activeUsers,
- 'userCards' => $userCards,
- 'mbtiDistribution' => $mbtiDistribution
- ]);
- }
-
- /**
- * 测试用户列表:分页、关键词、池筛选、MBTI 筛选
- * GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=
- */
- public function index()
- {
- $user = $this->request->user ?? null;
- if (!$user || ($user['role'] ?? '') !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $page = (int) Request::param('page', 1);
- $pageSize = (int) Request::param('pageSize', 20);
- $pageSize = min(max($pageSize, 1), 100);
- $keyword = trim(Request::param('keyword', ''));
- $pool = Request::param('pool', 'all');
- $enterpriseId = Request::param('enterpriseId', '');
- $mbti = trim(Request::param('mbti', ''));
-
- $where = [];
- if ($keyword !== '') {
- $where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
- }
-
- $wechatIds = null;
- if ($pool === 'individual' || ($pool === 'enterprise' && $enterpriseId !== '')) {
- try {
- $trQuery = Db::name('test_results');
- if ($pool === 'individual') {
- $trQuery->where(function ($q) {
- $q->whereNull('enterpriseId')->whereOr('enterpriseId', '');
- });
- } else {
- $trQuery->where('enterpriseId', $enterpriseId);
- }
- $wechatIds = $trQuery->distinct(true)->column('userId');
- $wechatIds = array_values(array_unique(array_filter($wechatIds)));
- } catch (\Throwable $e) {
- $wechatIds = null;
- }
- }
- if ($mbti !== '') {
- $mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
- $mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
- if ($wechatIds !== null) {
- $wechatIds = array_values(array_intersect($wechatIds, $mbtiUserIds));
- } else {
- $wechatIds = $mbtiUserIds;
- }
- }
-
- // 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
- try {
- $dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
- $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
- } catch (\Throwable $e) {
- $dedupIds = Db::name('wechat_users')->column('id');
- $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
- }
- if (empty($dedupIds)) {
- return paginate_response([], 0, $page, $pageSize);
- }
-
- $baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
- if ($where) {
- $baseQuery->where($where);
- }
- if ($wechatIds !== null && !empty($wechatIds)) {
- $baseQuery->where('id', 'in', array_intersect($dedupIds, $wechatIds));
- } elseif ($wechatIds !== null && empty($wechatIds)) {
- return paginate_response([], 0, $page, $pageSize);
- }
-
- $total = $baseQuery->count();
- $list = (clone $baseQuery)
- ->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
- ->order('createdAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- $ids = array_column($list, 'id');
- $testCounts = [];
- $lastTestAt = [];
- $testTypes = [];
- $userEnterprise = [];
- $payStats = [];
- if (!empty($ids)) {
- $counts = Db::name('test_results')->where('userId', 'in', $ids)->group('userId')->column('COUNT(*) as cnt', 'userId');
- $testCounts = $counts ?: [];
- $lastRows = Db::name('test_results')
- ->where('userId', 'in', $ids)
- ->field('id, userId, testType, resultData, createdAt')
- ->order('createdAt', 'desc')
- ->select();
- foreach ($lastRows as $row) {
- $uid = $row['userId'];
- if (!isset($lastTestAt[$uid])) {
- $lastTestAt[$uid] = $row['createdAt'];
- }
- if (!isset($testTypes[$uid])) {
- $testTypes[$uid] = [];
- }
- $testTypes[$uid][] = [
- 'testType' => $row['testType'],
- 'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
- 'createdAt' => $row['createdAt'],
- ];
- }
- try {
- $trWithE = Db::name('test_results')
- ->where('userId', 'in', $ids)
- ->where('enterpriseId', '<>', null)
- ->where('enterpriseId', '<>', '')
- ->field('userId, enterpriseId')
- ->select();
- $eids = array_unique(array_filter(array_column($trWithE, 'enterpriseId')));
- $enterpriseNames = [];
- if (!empty($eids)) {
- $enterpriseNames = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
- }
- foreach ($trWithE as $r) {
- if (!isset($userEnterprise[$r['userId']])) {
- $userEnterprise[$r['userId']] = $enterpriseNames[$r['enterpriseId']] ?? ('企业' . $r['enterpriseId']);
- }
- }
- } catch (\Throwable $e) {
- // test_results 可能无 enterpriseId 列
- }
- foreach ($ids as $uid) {
- if (!isset($userEnterprise[$uid])) {
- $userEnterprise[$uid] = '个人用户(无企业)';
- }
- }
-
- // 从用户画像表汇总支付统计(付款次数与总金额)
- try {
- $profiles = Db::name('user_profile')
- ->where('userId', 'in', $ids)
- ->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
- ->group('userId')
- ->select()
- ->toArray();
- foreach ($profiles as $p) {
- $uid = (int) ($p['userId'] ?? 0);
- if ($uid <= 0) {
- continue;
- }
- $payStats[$uid] = [
- 'paidOrders' => (int) ($p['paidOrders'] ?? 0),
- 'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
- ];
- }
- } catch (\Throwable $e) {
- $payStats = [];
- }
- }
-
- foreach ($list as &$row) {
- $id = $row['id'];
- $testsForUser = $testTypes[$id] ?? [];
- $row['username'] = $row['nickname'] ?? ('用户' . $id);
- $row['testCount'] = (int) ($testCounts[$id] ?? 0);
- $row['lastTestAt'] = $lastTestAt[$id] ?? null;
- $row['tests'] = $testsForUser;
- $row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
- $row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
- $row['discType'] = $this->extractResultType($testsForUser, 'disc');
- $row['faceType'] = $this->extractResultType($testsForUser, 'face');
- $row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
- $row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
- $row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
- $row['enterprise'] = $userEnterprise[$id] ?? '个人用户(无企业)';
-
- $pay = $payStats[$id] ?? null;
- $totalPaidFen = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
- $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
- $row['totalPaidAmount'] = $totalPaidFen;
- $row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0;
- }
-
- return paginate_response($list, $total, $page, $pageSize);
- }
-
- /**
- * 测试用户详情
- * GET /api/v1/superadmin/app-users/:id
- */
- public function detail($id)
- {
- $user = $this->request->user ?? null;
- if (!$user || ($user['role'] ?? '') !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $row = Db::name('wechat_users')->where('id', $id)->find();
- if (!$row) {
- return error('用户不存在', 404);
- }
-
- $data = [
- 'id' => (int) $row['id'],
- 'username' => $row['nickname'] ?? ('用户' . $row['id']),
- 'nickname' => $row['nickname'] ?? '',
- 'avatar' => $row['avatar'] ?? '',
- 'phone' => $row['phone'] ?? '',
- 'email' => '',
- 'gender' => (int) ($row['gender'] ?? 0),
- 'country' => $row['country'] ?? '',
- 'province' => $row['province'] ?? '',
- 'city' => $row['city'] ?? '',
- 'status' => (int) ($row['status'] ?? 1),
- 'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
- 'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
- 'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
- ];
-
- $tests = Db::name('test_results')
- ->where('userId', $id)
- ->field('id, testType, resultData, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
- ->order('createdAt', 'desc')
- ->select()
- ->toArray();
- foreach ($tests as &$t) {
- $raw = $t['resultData'] ?? '';
- $t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
- }
-
- $data['testCount'] = count($tests);
- $data['testList'] = $tests;
- $data['mbtiType'] = $this->extractResultType($tests, 'mbti');
- $data['pdpType'] = $this->extractResultType($tests, 'pdp');
- $data['discType'] = $this->extractResultType($tests, 'disc');
- $data['faceType'] = $this->extractResultType($tests, 'face');
- $data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
- $data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
- $data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
-
- return success($data);
- }
-
- private function parseMbtiFromResult($result): string
- {
- if (!is_string($result)) return '';
- $dec = json_decode($result, true);
- if (is_array($dec)) {
- return (string) ($dec['type'] ?? $dec['result'] ?? $dec['mbtiType'] ?? '');
- }
- return trim($result);
- }
-
- private function extractResultType(array $tests, string $type): string
- {
- $targetType = strtolower($type);
- foreach ($tests as $t) {
- if (strtolower($t['testType'] ?? '') !== $targetType) {
- continue;
- }
- $result = $t['result'] ?? '';
- if (!is_string($result)) {
- continue;
- }
- $dec = json_decode($result, true);
- if (!is_array($dec)) {
- // 无法解析 JSON 时,直接返回原始字符串
- return $targetType === 'face' ? '人脸分析' : trim($result);
- }
-
- // 人脸分析:有记录就返回固定标签
- if ($targetType === 'face') {
- return '人脸分析';
- }
-
- // MBTI:直接读 mbtiType/type
- if ($targetType === 'mbti') {
- return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
- }
-
- // DISC:优先 description.type,然后 dominantType
- if ($targetType === 'disc') {
- $desc = $dec['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($dec['dominantType'])) {
- return (string) $dec['dominantType'];
- }
- return (string) ($dec['disc'] ?? '');
- }
-
- // PDP:优先 description.type,然后 dominantType
- if ($targetType === 'pdp') {
- $desc = $dec['description']['type'] ?? null;
- if (is_string($desc) && $desc !== '') {
- return $desc;
- }
- if (!empty($dec['dominantType'])) {
- return (string) $dec['dominantType'];
- }
- return (string) ($dec['pdp'] ?? '');
- }
-
- // 兜底:尝试常见字段
- return (string) ($dec['type'] ?? $dec['result'] ?? '');
- }
- return '';
- }
-
- /**
- * 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
- */
- private function extractFaceSubType(array $tests, string $subType): string
- {
- $target = strtolower($subType);
- foreach ($tests as $t) {
- if (strtolower($t['testType'] ?? '') !== 'face') {
- continue;
- }
- $result = $t['result'] ?? '';
- if (!is_string($result)) {
- continue;
- }
- $dec = json_decode($result, true);
- if (!is_array($dec)) {
- continue;
- }
-
- if ($target === 'mbti') {
- if (!empty($dec['mbti']['type'])) {
- return (string) $dec['mbti']['type'];
- }
- if (!empty($dec['mbtiType'])) {
- return (string) $dec['mbtiType'];
- }
- } elseif ($target === 'disc') {
- if (!empty($dec['disc']['primary'])) {
- return (string) $dec['disc']['primary'];
- }
- if (!empty($dec['disc'])) {
- return (string) $dec['disc'];
- }
- } elseif ($target === 'pdp') {
- if (!empty($dec['pdp']['primary'])) {
- return (string) $dec['pdp']['primary'];
- }
- if (!empty($dec['pdp'])) {
- return (string) $dec['pdp'];
- }
- }
- }
- return '';
- }
-
-}
+where('name', 'like', '%存客宝%')->order('id', 'asc')->find();
+ return $row ? (int) $row['id'] : null;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * 概览:用户统计、卡片、MBTI 分布
+ * GET /api/v1/superadmin/app-users/overview
+ */
+ public function overview()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || ($user['role'] ?? '') !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ // 用户数按 openid 去重
+ try {
+ $totalUsers = (int) Db::name('wechat_users')->count('openid', true);
+ } catch (\Throwable $e) {
+ $totalUsers = (int) Db::name('wechat_users')->count();
+ }
+ $last30d = time() - 30 * 86400;
+
+ // 全部池:去重后的测试用户 & 近 30 天活跃用户(按 userId 去重)
+ // 这里使用逻辑表名 test_results,底层会自动加前缀生成 mbti_test_results
+ $testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
+ $testedUsers = count(array_filter($testedUserIds));
+
+ $activeUserIds = Db::name('test_results')
+ ->where('createdAt', '>=', $last30d)
+ ->distinct(true)
+ ->column('userId');
+ $activeUsers = count(array_filter($activeUserIds));
+
+ $userCards = [
+ [
+ 'type' => 'all',
+ 'name' => '全部用户',
+ 'total' => $totalUsers,
+ 'active' => $activeUsers,
+ 'tested' => $testedUsers
+ ]
+ ];
+
+ $individualTotal = 0;
+ $individualActive = 0;
+ try {
+ $individualIds = Db::name('test_results')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
+ })
+ ->distinct(true)
+ ->column('userId');
+ $individualTotal = count(array_filter($individualIds));
+
+ $individualActiveIds = Db::name('test_results')
+ ->where('createdAt', '>=', $last30d)
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
+ })
+ ->distinct(true)
+ ->column('userId');
+ $individualActive = count(array_filter($individualActiveIds));
+ } catch (\Throwable $e) {
+ $individualTotal = 0;
+ $individualActive = 0;
+ }
+
+ $cunkbaoId = $this->resolveCunkbaoEnterpriseId();
+
+ $enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
+ foreach ($enterprises as $e) {
+ $eid = $e['id'];
+ try {
+ $ids = Db::name('test_results')
+ ->where('enterpriseId', $eid)
+ ->distinct(true)
+ ->column('userId');
+ $total = count(array_filter($ids));
+
+ $activeIds = Db::name('test_results')
+ ->where('enterpriseId', $eid)
+ ->where('createdAt', '>=', $last30d)
+ ->distinct(true)
+ ->column('userId');
+ $active = count(array_filter($activeIds));
+ } catch (\Throwable $ex) {
+ $total = 0;
+ $active = 0;
+ }
+ if ($cunkbaoId !== null && (int) $eid === $cunkbaoId) {
+ $total += $individualTotal;
+ $active += $individualActive;
+ }
+ $userCards[] = [
+ 'type' => 'enterprise',
+ 'enterpriseId' => $eid,
+ 'name' => $e['name'] ?? ('企业' . $eid),
+ 'total' => $total,
+ 'active' => $active,
+ 'tested' => $total
+ ];
+ }
+
+ if ($cunkbaoId === null && ($individualTotal > 0 || $individualActive > 0)) {
+ $userCards[] = [
+ 'type' => 'individual',
+ 'name' => '个人用户(无企业)',
+ 'total' => $individualTotal,
+ 'active' => $individualActive,
+ 'tested' => $individualTotal
+ ];
+ }
+
+ // MBTI 类型分布:按用户去重,每人只计其最新一次 MBTI 结果
+ $mbtiTypes = [];
+ try {
+ $rows = Db::name('test_results')
+ ->where('testType', 'mbti')
+ ->field('userId, resultData, createdAt')
+ ->order('createdAt', 'desc')
+ ->select()
+ ->toArray();
+ $seenUserIds = [];
+ foreach ($rows as $r) {
+ $uid = (int) ($r['userId'] ?? 0);
+ if ($uid <= 0 || isset($seenUserIds[$uid])) {
+ continue;
+ }
+ $raw = $r['resultData'] ?? '';
+ $dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
+ if (!is_array($dec)) {
+ $seenUserIds[$uid] = true;
+ continue;
+ }
+ $type = '';
+ if (isset($dec['mbtiType'])) {
+ $type = $dec['mbtiType'];
+ } elseif (isset($dec['mbti']['type'])) {
+ $type = $dec['mbti']['type'];
+ } elseif (isset($dec['type'])) {
+ $type = $dec['type'];
+ }
+ $type = strtoupper(trim((string) $type));
+ $seenUserIds[$uid] = true;
+ if ($type === '') {
+ continue;
+ }
+ $mbtiTypes[$type] = ($mbtiTypes[$type] ?? 0) + 1;
+ }
+ } catch (\Throwable $e) {
+ // ignore
+ }
+ $mbtiDistribution = [];
+ foreach ($mbtiTypes as $type => $count) {
+ $mbtiDistribution[] = ['type' => $type, 'count' => $count];
+ }
+
+ return success([
+ 'totalUsers' => $totalUsers,
+ 'testedUsers' => $testedUsers,
+ 'activeUsers' => $activeUsers,
+ 'userCards' => $userCards,
+ 'mbtiDistribution' => $mbtiDistribution
+ ]);
+ }
+
+ /**
+ * 测试用户列表:分页、关键词、池筛选、MBTI 筛选
+ * GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=
+ */
+ public function index()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || ($user['role'] ?? '') !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $page = (int) Request::param('page', 1);
+ $pageSize = (int) Request::param('pageSize', 20);
+ $pageSize = min(max($pageSize, 1), 100);
+ $keyword = trim(Request::param('keyword', ''));
+ $pool = Request::param('pool', 'all');
+ $enterpriseId = Request::param('enterpriseId', '');
+ $mbti = trim(Request::param('mbti', ''));
+
+ $cunkbaoEnterpriseId = $this->resolveCunkbaoEnterpriseId();
+ if ($pool === 'individual' && $cunkbaoEnterpriseId) {
+ $pool = 'enterprise';
+ $enterpriseId = (string) $cunkbaoEnterpriseId;
+ }
+
+ $where = [];
+ if ($keyword !== '') {
+ $where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
+ }
+
+ $wechatIds = null;
+ if ($pool === 'individual' || ($pool === 'enterprise' && $enterpriseId !== '')) {
+ try {
+ if ($pool === 'individual') {
+ $trQuery = Db::name('test_results')->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
+ });
+ $wechatIds = $trQuery->distinct(true)->column('userId');
+ } else {
+ $eid = (int) $enterpriseId;
+ if ($cunkbaoEnterpriseId !== null && $eid === $cunkbaoEnterpriseId) {
+ $idsEnt = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
+ $idsOrphan = Db::name('test_results')->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
+ })->distinct(true)->column('userId');
+ $wechatIds = array_values(array_unique(array_filter(array_merge($idsEnt, $idsOrphan))));
+ } else {
+ $wechatIds = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
+ }
+ }
+ $wechatIds = array_values(array_unique(array_filter($wechatIds)));
+ } catch (\Throwable $e) {
+ $wechatIds = null;
+ }
+ }
+ if ($mbti !== '') {
+ $mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
+ $mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
+ if ($wechatIds !== null) {
+ $wechatIds = array_values(array_intersect($wechatIds, $mbtiUserIds));
+ } else {
+ $wechatIds = $mbtiUserIds;
+ }
+ }
+
+ // 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
+ try {
+ $dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
+ $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
+ } catch (\Throwable $e) {
+ $dedupIds = Db::name('wechat_users')->column('id');
+ $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
+ }
+ if (empty($dedupIds)) {
+ return paginate_response([], 0, $page, $pageSize);
+ }
+
+ $baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
+ if ($where) {
+ $baseQuery->where($where);
+ }
+ if ($wechatIds !== null && !empty($wechatIds)) {
+ $baseQuery->where('id', 'in', array_intersect($dedupIds, $wechatIds));
+ } elseif ($wechatIds !== null && empty($wechatIds)) {
+ return paginate_response([], 0, $page, $pageSize);
+ }
+
+ $total = $baseQuery->count();
+ $list = (clone $baseQuery)
+ ->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
+ ->order('createdAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ $ids = array_column($list, 'id');
+ $testCounts = [];
+ $lastTestAt = [];
+ $testTypes = [];
+ $userEnterprise = [];
+ $payStats = [];
+ if (!empty($ids)) {
+ $counts = Db::name('test_results')->where('userId', 'in', $ids)->group('userId')->column('COUNT(*) as cnt', 'userId');
+ $testCounts = $counts ?: [];
+ $lastRows = Db::name('test_results')
+ ->where('userId', 'in', $ids)
+ ->field('id, userId, testType, resultData, createdAt')
+ ->order('createdAt', 'desc')
+ ->select();
+ foreach ($lastRows as $row) {
+ $uid = $row['userId'];
+ if (!isset($lastTestAt[$uid])) {
+ $lastTestAt[$uid] = $row['createdAt'];
+ }
+ if (!isset($testTypes[$uid])) {
+ $testTypes[$uid] = [];
+ }
+ $testTypes[$uid][] = [
+ 'testType' => $row['testType'],
+ 'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
+ 'createdAt' => $row['createdAt'],
+ ];
+ }
+ try {
+ $trWithE = Db::name('test_results')
+ ->where('userId', 'in', $ids)
+ ->where('enterpriseId', '<>', null)
+ ->where('enterpriseId', '<>', '')
+ ->field('userId, enterpriseId')
+ ->select();
+ $eids = array_unique(array_filter(array_column($trWithE, 'enterpriseId')));
+ $enterpriseNames = [];
+ if (!empty($eids)) {
+ $enterpriseNames = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
+ }
+ foreach ($trWithE as $r) {
+ if (!isset($userEnterprise[$r['userId']])) {
+ $userEnterprise[$r['userId']] = $enterpriseNames[$r['enterpriseId']] ?? ('企业' . $r['enterpriseId']);
+ }
+ }
+ } catch (\Throwable $e) {
+ // test_results 可能无 enterpriseId 列
+ }
+ $cunkbaoName = null;
+ if ($cunkbaoEnterpriseId) {
+ $cunkbaoName = (string) (Db::name('enterprises')->where('id', $cunkbaoEnterpriseId)->value('name') ?: '存客宝');
+ }
+ foreach ($ids as $uid) {
+ if (!isset($userEnterprise[$uid])) {
+ $tc = (int) ($testCounts[$uid] ?? 0);
+ if ($cunkbaoName !== null && $tc > 0) {
+ $userEnterprise[$uid] = $cunkbaoName;
+ } else {
+ $userEnterprise[$uid] = '个人用户(无企业)';
+ }
+ }
+ }
+
+ // 从用户画像表汇总支付统计(付款次数与总金额)
+ try {
+ $profiles = Db::name('user_profile')
+ ->where('userId', 'in', $ids)
+ ->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
+ ->group('userId')
+ ->select()
+ ->toArray();
+ foreach ($profiles as $p) {
+ $uid = (int) ($p['userId'] ?? 0);
+ if ($uid <= 0) {
+ continue;
+ }
+ $payStats[$uid] = [
+ 'paidOrders' => (int) ($p['paidOrders'] ?? 0),
+ 'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
+ ];
+ }
+ } catch (\Throwable $e) {
+ $payStats = [];
+ }
+ }
+
+ foreach ($list as &$row) {
+ $id = $row['id'];
+ $testsForUser = $testTypes[$id] ?? [];
+ $row['username'] = $row['nickname'] ?? ('用户' . $id);
+ $row['testCount'] = (int) ($testCounts[$id] ?? 0);
+ $row['lastTestAt'] = $lastTestAt[$id] ?? null;
+ $row['tests'] = $testsForUser;
+ $row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
+ $row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
+ $row['discType'] = $this->extractResultType($testsForUser, 'disc');
+ $row['faceType'] = $this->extractResultType($testsForUser, 'face');
+ $row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
+ $row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
+ $row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
+ $row['enterprise'] = $userEnterprise[$id] ?? '个人用户(无企业)';
+
+ $pay = $payStats[$id] ?? null;
+ $totalPaidFen = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
+ $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
+ $row['totalPaidAmount'] = $totalPaidFen;
+ $row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0;
+ }
+
+ return paginate_response($list, $total, $page, $pageSize);
+ }
+
+ /**
+ * 测试用户详情
+ * GET /api/v1/superadmin/app-users/:id
+ */
+ public function detail($id)
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || ($user['role'] ?? '') !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $row = Db::name('wechat_users')->where('id', $id)->find();
+ if (!$row) {
+ return error('用户不存在', 404);
+ }
+
+ $data = [
+ 'id' => (int) $row['id'],
+ 'username' => $row['nickname'] ?? ('用户' . $row['id']),
+ 'nickname' => $row['nickname'] ?? '',
+ 'avatar' => $row['avatar'] ?? '',
+ 'phone' => $row['phone'] ?? '',
+ 'email' => '',
+ 'gender' => (int) ($row['gender'] ?? 0),
+ 'country' => $row['country'] ?? '',
+ 'province' => $row['province'] ?? '',
+ 'city' => $row['city'] ?? '',
+ 'status' => (int) ($row['status'] ?? 1),
+ 'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
+ 'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
+ 'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
+ ];
+
+ $tests = Db::name('test_results')
+ ->where('userId', $id)
+ ->field('id, testType, resultData, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
+ ->order('createdAt', 'desc')
+ ->select()
+ ->toArray();
+ foreach ($tests as &$t) {
+ $raw = $t['resultData'] ?? '';
+ $t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
+ }
+
+ $data['testCount'] = count($tests);
+ $data['testList'] = $tests;
+ $data['mbtiType'] = $this->extractResultType($tests, 'mbti');
+ $data['pdpType'] = $this->extractResultType($tests, 'pdp');
+ $data['discType'] = $this->extractResultType($tests, 'disc');
+ $data['faceType'] = $this->extractResultType($tests, 'face');
+ $data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
+ $data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
+ $data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
+
+ return success($data);
+ }
+
+ private function parseMbtiFromResult($result): string
+ {
+ if (!is_string($result)) return '';
+ $dec = json_decode($result, true);
+ if (is_array($dec)) {
+ return (string) ($dec['type'] ?? $dec['result'] ?? $dec['mbtiType'] ?? '');
+ }
+ return trim($result);
+ }
+
+ private function extractResultType(array $tests, string $type): string
+ {
+ $targetType = strtolower($type);
+ foreach ($tests as $t) {
+ if (strtolower($t['testType'] ?? '') !== $targetType) {
+ continue;
+ }
+ $result = $t['result'] ?? '';
+ if (!is_string($result)) {
+ continue;
+ }
+ $dec = json_decode($result, true);
+ if (!is_array($dec)) {
+ // 无法解析 JSON 时,直接返回原始字符串
+ return $targetType === 'face' ? '人脸分析' : trim($result);
+ }
+
+ // 人脸分析:有记录就返回固定标签
+ if ($targetType === 'face') {
+ return '人脸分析';
+ }
+
+ // MBTI:直接读 mbtiType/type
+ if ($targetType === 'mbti') {
+ return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
+ }
+
+ // DISC:优先 description.type,然后 dominantType
+ if ($targetType === 'disc') {
+ $desc = $dec['description']['type'] ?? null;
+ if (is_string($desc) && $desc !== '') {
+ return $desc;
+ }
+ if (!empty($dec['dominantType'])) {
+ return (string) $dec['dominantType'];
+ }
+ return (string) ($dec['disc'] ?? '');
+ }
+
+ // PDP:优先 description.type,然后 dominantType
+ if ($targetType === 'pdp') {
+ $desc = $dec['description']['type'] ?? null;
+ if (is_string($desc) && $desc !== '') {
+ return $desc;
+ }
+ if (!empty($dec['dominantType'])) {
+ return (string) $dec['dominantType'];
+ }
+ return (string) ($dec['pdp'] ?? '');
+ }
+
+ // 兜底:尝试常见字段
+ return (string) ($dec['type'] ?? $dec['result'] ?? '');
+ }
+ return '';
+ }
+
+ /**
+ * 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
+ */
+ private function extractFaceSubType(array $tests, string $subType): string
+ {
+ $target = strtolower($subType);
+ foreach ($tests as $t) {
+ if (strtolower($t['testType'] ?? '') !== 'face') {
+ continue;
+ }
+ $result = $t['result'] ?? '';
+ if (!is_string($result)) {
+ continue;
+ }
+ $dec = json_decode($result, true);
+ if (!is_array($dec)) {
+ continue;
+ }
+
+ if ($target === 'mbti') {
+ if (!empty($dec['mbti']['type'])) {
+ return (string) $dec['mbti']['type'];
+ }
+ if (!empty($dec['mbtiType'])) {
+ return (string) $dec['mbtiType'];
+ }
+ } elseif ($target === 'disc') {
+ if (!empty($dec['disc']['primary'])) {
+ return (string) $dec['disc']['primary'];
+ }
+ if (!empty($dec['disc'])) {
+ return (string) $dec['disc'];
+ }
+ } elseif ($target === 'pdp') {
+ if (!empty($dec['pdp']['primary'])) {
+ return (string) $dec['pdp']['primary'];
+ }
+ if (!empty($dec['pdp'])) {
+ return (string) $dec['pdp'];
+ }
+ }
+ }
+ return '';
+ }
+
+}
diff --git a/api/app/controller/superadmin/DataMigration.php b/api/app/controller/superadmin/DataMigration.php
new file mode 100644
index 0000000..c2287a6
--- /dev/null
+++ b/api/app/controller/superadmin/DataMigration.php
@@ -0,0 +1,393 @@
+request->user ?? null;
+ if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
+ return error('仅超级管理员可操作', 403);
+ }
+
+ $body = Request::post();
+ if (!is_array($body)) {
+ $body = [];
+ }
+
+ $targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
+ if ($targetEnterpriseId <= 0) {
+ return error('targetEnterpriseId 无效', 400);
+ }
+
+ $ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
+ if (!$ent) {
+ return error('目标企业不存在', 404);
+ }
+
+ $dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
+ $confirm = !empty($body['confirm']);
+ $syncPersonalTestResults = array_key_exists('syncPersonalTestResults', $body) ? (bool) $body['syncPersonalTestResults'] : true;
+ $syncWechatUsers = array_key_exists('syncWechatUsers', $body) ? (bool) $body['syncWechatUsers'] : true;
+ $clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
+
+ $orderIdsFilter = $this->normalizeIdList($body['orderIds'] ?? null);
+ $userIdsFilter = $this->normalizeIdList($body['userIds'] ?? null);
+
+ $orderQuery = Db::name('orders')->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ });
+
+ if (!empty($orderIdsFilter)) {
+ $orderQuery->whereIn('id', $orderIdsFilter);
+ }
+ if (!empty($userIdsFilter)) {
+ $orderQuery->whereIn('userId', $userIdsFilter);
+ }
+
+ $orderRows = $orderQuery->field('id,userId,orderNo,enterpriseId,status,amount')->select()->toArray();
+ $affectedOrderIds = array_values(array_unique(array_filter(array_column($orderRows, 'id'))));
+ $userIdsFromOrders = array_values(array_unique(array_filter(array_column($orderRows, 'userId'))));
+
+ $testByOrderCount = 0;
+ if (!empty($affectedOrderIds)) {
+ $testByOrderCount = (int) Db::name('test_results')
+ ->whereIn('orderId', $affectedOrderIds)
+ ->count();
+ }
+
+ $personalTestExtraCount = 0;
+ if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
+ $personalTestExtraCount = (int) Db::name('test_results')
+ ->whereIn('userId', $userIdsFromOrders)
+ ->where('testScope', 'personal')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->count();
+ }
+
+ $wechatPatchCount = 0;
+ if ($syncWechatUsers && !empty($userIdsFromOrders)) {
+ $wechatPatchCount = (int) Db::name('wechat_users')
+ ->whereIn('id', $userIdsFromOrders)
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->count();
+ }
+
+ $profileCloneCount = 0;
+ if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
+ foreach ($userIdsFromOrders as $uid) {
+ $hasEnt = Db::name('user_profile')
+ ->where('userId', $uid)
+ ->where('userType', 'enterprise')
+ ->where('enterpriseId', $targetEnterpriseId)
+ ->find();
+ if (!$hasEnt) {
+ $profileCloneCount++;
+ }
+ }
+ }
+
+ $preview = [
+ 'targetEnterpriseId' => $targetEnterpriseId,
+ 'enterpriseName' => $ent['name'] ?? '',
+ 'ordersMatched' => count($orderRows),
+ 'orderIds' => $affectedOrderIds,
+ 'distinctUserIds' => $userIdsFromOrders,
+ 'testResultsByOrder' => $testByOrderCount,
+ 'testResultsPersonalExtra' => $personalTestExtraCount,
+ 'wechatUsersToPatch' => $wechatPatchCount,
+ 'userProfilesToClone' => $profileCloneCount,
+ 'dryRun' => $dryRun,
+ ];
+
+ if ($dryRun || !$confirm) {
+ $preview['hint'] = $dryRun
+ ? '当前为预览(dryRun=true)。若要执行写入,请传 dryRun=false 且 confirm=true。'
+ : '未执行写入:请同时传 dryRun=false 与 confirm=true。';
+ return success($preview);
+ }
+
+ $now = time();
+ Db::startTrans();
+ try {
+ if (!empty($affectedOrderIds)) {
+ Db::name('orders')
+ ->whereIn('id', $affectedOrderIds)
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ 'updatedAt' => $now,
+ ]);
+
+ Db::name('test_results')
+ ->whereIn('orderId', $affectedOrderIds)
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ 'testScope' => 'enterprise',
+ 'updatedAt' => $now,
+ ]);
+ }
+
+ if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
+ Db::name('test_results')
+ ->whereIn('userId', $userIdsFromOrders)
+ ->where('testScope', 'personal')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ 'testScope' => 'enterprise',
+ 'updatedAt' => $now,
+ ]);
+ }
+
+ if ($syncWechatUsers && !empty($userIdsFromOrders)) {
+ Db::name('wechat_users')
+ ->whereIn('id', $userIdsFromOrders)
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ ]);
+ }
+
+ if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
+ foreach ($userIdsFromOrders as $uid) {
+ $this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
+ }
+ }
+
+ Db::commit();
+ } catch (\Throwable $e) {
+ Db::rollback();
+ return error('迁移失败:' . $e->getMessage(), 500);
+ }
+
+ $preview['executed'] = true;
+ $preview['hint'] = '已写入。企业管理员刷新「订单运营 / 概览 / 用户运营」即可看到归属数据。超管仍可见全平台订单。';
+ return success($preview, '迁移完成');
+ }
+
+ /**
+ * @param mixed $raw
+ * @return int[]
+ */
+ private function normalizeIdList($raw): array
+ {
+ if (!is_array($raw) || $raw === []) {
+ return [];
+ }
+ $out = [];
+ foreach ($raw as $v) {
+ $n = (int) $v;
+ if ($n > 0) {
+ $out[] = $n;
+ }
+ }
+ return array_values(array_unique($out));
+ }
+
+ private function ensureEnterpriseProfileFromPersonal(int $userId, int $enterpriseId, int $now): void
+ {
+ if ($userId <= 0 || $enterpriseId <= 0) {
+ return;
+ }
+ $exists = Db::name('user_profile')
+ ->where('userId', $userId)
+ ->where('userType', 'enterprise')
+ ->where('enterpriseId', $enterpriseId)
+ ->find();
+ if ($exists) {
+ return;
+ }
+
+ $personal = Db::name('user_profile')
+ ->where('userId', $userId)
+ ->where('userType', 'personal')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->order('id', 'desc')
+ ->find();
+
+ $base = [
+ 'userId' => $userId,
+ 'userType' => 'enterprise',
+ 'enterpriseId' => $enterpriseId,
+ 'testsTotal' => 0,
+ 'testsMbti' => 0,
+ 'testsDisc' => 0,
+ 'testsPdp' => 0,
+ 'testsFace' => 0,
+ 'ordersTotal' => 0,
+ 'paidOrders' => 0,
+ 'totalPaidAmount' => 0,
+ 'lastTestResultId' => null,
+ 'lastTestType' => null,
+ 'lastTestAt' => null,
+ 'lastMbtiResultId' => null,
+ 'lastDiscResultId' => null,
+ 'lastPdpResultId' => null,
+ 'lastFaceResultId' => null,
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ];
+
+ if ($personal) {
+ $copyFields = [
+ 'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
+ 'ordersTotal', 'paidOrders', 'totalPaidAmount',
+ 'lastTestResultId', 'lastTestType', 'lastTestAt',
+ 'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId',
+ ];
+ foreach ($copyFields as $f) {
+ if (array_key_exists($f, $personal) && $personal[$f] !== null) {
+ $base[$f] = $personal[$f];
+ }
+ }
+ }
+
+ Db::name('user_profile')->insert($base);
+ }
+
+ /**
+ * 将全平台「无 enterpriseId」的 test_results 与 wechat_users 归属到存客宝(或指定企业)
+ * POST /api/v1/superadmin/data-migration/attach-orphans-to-cunkbao
+ *
+ * Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm、clonePersonalProfile (默认 true)
+ */
+ public function attachOrphansToCunkbao()
+ {
+ $actor = $this->request->user ?? null;
+ if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
+ return error('仅超级管理员可操作', 403);
+ }
+
+ $body = Request::post();
+ if (!is_array($body)) {
+ $body = [];
+ }
+
+ $dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
+ $confirm = !empty($body['confirm']);
+ $clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
+
+ $targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
+ if ($targetEnterpriseId <= 0) {
+ $row = Db::name('enterprises')->where('name', 'like', '%存客宝%')->order('id', 'asc')->find();
+ if (!$row) {
+ return error('未找到名称包含「存客宝」的企业,请先在企业管理中创建或传入 targetEnterpriseId', 404);
+ }
+ $targetEnterpriseId = (int) $row['id'];
+ }
+
+ $ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
+ if (!$ent) {
+ return error('目标企业不存在', 404);
+ }
+
+ $testAffected = (int) Db::name('test_results')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
+ })
+ ->count();
+
+ $wechatAffected = (int) Db::name('wechat_users')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->count();
+
+ $userIdsFromTests = Db::name('test_results')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
+ })
+ ->distinct(true)
+ ->column('userId');
+ $userIdsFromWechat = Db::name('wechat_users')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->column('id');
+ $distinctUserIds = array_values(array_unique(array_filter(array_merge($userIdsFromTests, $userIdsFromWechat))));
+
+ $preview = [
+ 'targetEnterpriseId' => $targetEnterpriseId,
+ 'enterpriseName' => $ent['name'] ?? '',
+ 'testResultsRows' => $testAffected,
+ 'wechatUsersRows' => $wechatAffected,
+ 'distinctUserIds' => $distinctUserIds,
+ 'dryRun' => $dryRun,
+ ];
+
+ if ($dryRun || !$confirm) {
+ $preview['hint'] = $dryRun
+ ? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
+ : '未写入:请同时传 dryRun=false 与 confirm=true。';
+ return success($preview);
+ }
+
+ $now = time();
+ Db::startTrans();
+ try {
+ Db::name('test_results')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
+ })
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ 'testScope' => 'enterprise',
+ 'updatedAt' => $now,
+ ]);
+
+ Db::name('wechat_users')
+ ->where(function ($q) {
+ $q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
+ })
+ ->update([
+ 'enterpriseId' => $targetEnterpriseId,
+ ]);
+
+ if ($clonePersonalProfile && !empty($distinctUserIds)) {
+ foreach ($distinctUserIds as $uid) {
+ $this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
+ }
+ }
+
+ Db::commit();
+ } catch (\Throwable $e) {
+ Db::rollback();
+ return error('归并失败:' . $e->getMessage(), 500);
+ }
+
+ $preview['executed'] = true;
+ $preview['hint'] = '已写入。无企业归属的测试与用户已归属到目标企业。';
+ return success($preview, '归并完成');
+ }
+}
diff --git a/api/app/controller/superadmin/Enterprise.php b/api/app/controller/superadmin/Enterprise.php
index 0b60942..1dd8248 100644
--- a/api/app/controller/superadmin/Enterprise.php
+++ b/api/app/controller/superadmin/Enterprise.php
@@ -1,437 +1,551 @@
-request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $page = Request::param('page', 1);
- $pageSize = Request::param('pageSize', 20);
- $keyword = Request::param('keyword', '');
- $status = Request::param('status', '');
-
- $where = [];
-
- // 搜索条件
- if ($keyword) {
- $where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
- }
-
- // 状态筛选
- if ($status !== '') {
- $where['status'] = $status;
- }
-
- // 查询企业列表
- $list = EnterpriseModel::where($where)
- ->order('createdAt', 'desc')
- ->page($page, $pageSize)
- ->select()
- ->toArray();
-
- // 统计每个企业的用户数和测试用量
- foreach ($list as &$item) {
- // 统计用户数(只统计未删除的用户)
- $item['userCount'] = Db::name('users')
- ->where('enterpriseId', $item['id'])
- ->where('deletedAt', null)
- ->count();
-
- // 统计测试用量(测试结果数)- 通过企业下的用户ID统计(只统计未删除的用户)
- $userIds = Db::name('users')
- ->where('enterpriseId', $item['id'])
- ->where('deletedAt', null)
- ->column('id');
-
- if (!empty($userIds)) {
- $item['testUsage'] = Db::name('test_results')
- ->where('userId', 'in', $userIds)
- ->count();
- } else {
- $item['testUsage'] = 0;
- }
- }
-
- $total = EnterpriseModel::where($where)->count();
-
- // 统计活跃企业数(status为operating)
- $activeCount = EnterpriseModel::where('status', 'operating')->count();
-
- return success([
- 'list' => $list,
- 'total' => $total,
- 'page' => $page,
- 'pageSize' => $pageSize,
- 'activeCount' => $activeCount
- ]);
- }
-
- /**
- * 获取企业详情
- * @param int $id
- * @return \think\response\Json
- */
- public function detail($id = null)
- {
- // 验证是否为超级管理员
- $user = $this->request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- // 如果路由参数没有传递,尝试从请求参数获取
- if (empty($id)) {
- $id = Request::param('id');
- }
-
- if (empty($id)) {
- return error('企业ID不能为空', 400);
- }
-
- $enterprise = EnterpriseModel::find($id);
-
- if (!$enterprise) {
- return error('企业不存在', 404);
- }
-
- $data = $enterprise->toArray();
-
- // 获取企业下的所有用户ID(只统计未删除的用户)
- $userIds = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('deletedAt', null)
- ->column('id');
-
- // 统计用户数
- $data['userCount'] = count($userIds);
-
- // 获取管理员账号列表(企业管理员角色,只获取未删除的)
- $adminAccounts = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('role', 'enterprise_admin')
- ->where('deletedAt', null)
- ->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
- ->select()
- ->toArray();
- $data['adminAccounts'] = $adminAccounts;
-
- // 获取用户列表(排除管理员,只获取未删除的)
- $users = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('role', '<>', 'enterprise_admin')
- ->where('deletedAt', null)
- ->field('id,username,email,phone,mbtiType,status,createdAt')
- ->limit(50) // 限制返回数量
- ->select()
- ->toArray();
- $data['users'] = $users;
-
- // 获取测试结果列表
- $testResults = [];
- if (!empty($userIds)) {
- $testResults = Db::name('test_results')
- ->alias('tr')
- ->leftJoin('users u', 'tr.userId = u.id')
- ->where('tr.userId', 'in', $userIds)
- ->field('tr.id,tr.testType,tr.createdAt,u.username')
- ->order('tr.createdAt', 'desc')
- ->limit(50) // 限制返回数量
- ->select()
- ->toArray();
- }
- $data['testResults'] = $testResults;
-
- // 统计测试用量
- if (!empty($userIds)) {
- $data['testUsage'] = Db::name('test_results')
- ->where('userId', 'in', $userIds)
- ->count();
- } else {
- $data['testUsage'] = 0;
- }
-
- return success($data);
- }
-
- /**
- * 创建企业
- * @return \think\response\Json
- */
- public function create()
- {
- // 验证是否为超级管理员
- $user = $this->request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $data = Request::post();
-
- // 验证必填字段
- if (empty($data['name'])) {
- return error('企业名称不能为空', 400);
- }
-
- // 验证管理员账号信息
- if (empty($data['adminUsername'])) {
- return error('管理员用户名不能为空', 400);
- }
-
- if (empty($data['adminPassword'])) {
- return error('管理员密码不能为空', 400);
- }
-
- if (strlen($data['adminPassword']) < 6) {
- return error('密码长度至少6位', 400);
- }
-
- // 检查企业代码是否重复(如果提供了代码)
- if (!empty($data['code'])) {
- if (EnterpriseModel::where('code', $data['code'])->find()) {
- return error('企业代码已存在', 400);
- }
- }
-
- // 检查管理员用户名是否已存在
- if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
- return error('管理员用户名已存在', 400);
- }
-
- // 状态映射(前端使用operating/trial/disabled)
- $status = $data['status'] ?? 'operating';
- if (!in_array($status, ['operating', 'trial', 'disabled'])) {
- $status = 'operating';
- }
-
- // 验证试用到期时间
- if ($status === 'trial') {
- if (empty($data['trialExpireAt'])) {
- return error('选择试用状态时,必须设置试用到期时间', 400);
- }
- // 确保到期时间大于当前时间
- if ($data['trialExpireAt'] <= time()) {
- return error('试用到期时间必须大于当前时间', 400);
- }
- }
-
- // 开启事务
- Db::startTrans();
- try {
- // 创建企业
- $enterprise = new EnterpriseModel();
- $enterprise->name = $data['name'];
- $enterprise->code = $data['code'] ?? null;
- $enterprise->contactName = $data['contactName'] ?? null;
- $enterprise->contactPhone = $data['contactPhone'] ?? null;
- $enterprise->contactEmail = $data['contactEmail'] ?? null;
- $enterprise->balance = $data['balance'] ?? 0.00;
- $enterprise->status = $status;
- $enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
- $enterprise->save();
-
- $enterpriseId = $enterprise->id;
-
- // 创建企业管理员账号
- $adminUser = [
- 'username' => $data['adminUsername'],
- 'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
- 'email' => $data['contactEmail'] ?? null,
- 'phone' => $data['contactPhone'] ?? null,
- 'role' => 'enterprise_admin',
- 'enterpriseId' => $enterpriseId,
- 'status' => 1,
- 'createdAt' => time(),
- 'updatedAt' => time()
- ];
-
- Db::name('users')->insert($adminUser);
-
- // 提交事务
- Db::commit();
-
- $enterpriseData = $enterprise->toArray();
- $enterpriseData['userCount'] = 1; // 刚创建的企业管理员
- $enterpriseData['testUsage'] = 0;
-
- return success($enterpriseData, '企业创建成功,管理员账号已创建');
- } catch (\Exception $e) {
- // 回滚事务
- Db::rollback();
- return error('创建失败:' . $e->getMessage(), 500);
- }
- }
-
- /**
- * 更新企业
- * @param int $id
- * @return \think\response\Json
- */
- public function update($id)
- {
- // 验证是否为超级管理员
- $user = $this->request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $enterprise = EnterpriseModel::find($id);
-
- if (!$enterprise) {
- return error('企业不存在', 404);
- }
-
- $data = Request::put();
- $oldBalance = (float) ($enterprise->balance ?? 0);
-
- // 如果更新企业代码,检查是否重复
- if (isset($data['code']) && $data['code'] != $enterprise->code) {
- if (EnterpriseModel::where('code', $data['code'])->find()) {
- return error('企业代码已存在', 400);
- }
- }
-
- // 状态验证
- if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
- return error('状态值无效', 400);
- }
-
- // 验证试用到期时间
- $status = $data['status'] ?? $enterprise->status;
- if ($status === 'trial') {
- if (empty($data['trialExpireAt'])) {
- return error('选择试用状态时,必须设置试用到期时间', 400);
- }
- // 确保到期时间大于当前时间
- if ($data['trialExpireAt'] <= time()) {
- return error('试用到期时间必须大于当前时间', 400);
- }
- $enterprise->trialExpireAt = $data['trialExpireAt'];
- } else {
- // 如果不是试用状态,清空到期时间
- $enterprise->trialExpireAt = null;
- }
-
- $enterprise->save($data);
-
- $newBalance = (float) ($enterprise->balance ?? 0);
- if ($newBalance > $oldBalance) {
- try {
- \app\controller\api\Distribution::unfreezeCommissions((int) $id);
- } catch (\Throwable $e) {
- // 余额已更新成功,解冻失败不阻断主流程
- }
- }
-
- $enterpriseData = $enterprise->toArray();
-
- // 统计用户数和测试用量(只统计未删除的用户)
- $enterpriseData['userCount'] = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('deletedAt', null)
- ->count();
-
- $userIds = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('deletedAt', null)
- ->column('id');
-
- if (!empty($userIds)) {
- $enterpriseData['testUsage'] = Db::name('test_results')
- ->where('userId', 'in', $userIds)
- ->count();
- } else {
- $enterpriseData['testUsage'] = 0;
- }
-
- return success($enterpriseData, '更新成功');
- }
-
- /**
- * 删除企业(软删除)
- * @param int $id
- * @return \think\response\Json
- */
- public function delete($id)
- {
- // 验证是否为超级管理员
- $user = $this->request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $enterprise = EnterpriseModel::find($id);
-
- if (!$enterprise) {
- return error('企业不存在', 404);
- }
-
- // 检查是否已删除
- if ($enterprise->deletedAt) {
- return error('企业已被删除', 400);
- }
-
- // 检查是否有用户关联(只检查未删除的用户)
- $userCount = Db::name('users')
- ->where('enterpriseId', $id)
- ->where('deletedAt', null)
- ->count();
- if ($userCount > 0) {
- return error('该企业下还有用户,无法删除', 400);
- }
-
- // 软删除(设置 deletedAt 时间戳)
- $enterprise->delete();
-
- return success(null, '删除成功');
- }
-
- /**
- * 启用/禁用企业
- * @param int $id
- * @return \think\response\Json
- */
- public function toggleStatus($id)
- {
- // 验证是否为超级管理员
- $user = $this->request->user ?? null;
- if (!$user || $user['role'] !== 'superadmin') {
- return error('无权限访问', 403);
- }
-
- $enterprise = EnterpriseModel::find($id);
-
- if (!$enterprise) {
- return error('企业不存在', 404);
- }
-
- // 切换状态:operating <-> disabled
- if ($enterprise->status === 'operating') {
- $enterprise->status = 'disabled';
- } else {
- $enterprise->status = 'operating';
- }
-
- $enterprise->save();
-
- return success($enterprise, '操作成功');
- }
-}
-
+request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $page = Request::param('page', 1);
+ $pageSize = Request::param('pageSize', 20);
+ $keyword = Request::param('keyword', '');
+ $status = Request::param('status', '');
+
+ $where = [];
+
+ // 搜索条件
+ if ($keyword) {
+ $where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
+ }
+
+ // 状态筛选
+ if ($status !== '') {
+ $where['status'] = $status;
+ }
+
+ // 查询企业列表
+ $list = EnterpriseModel::where($where)
+ ->order('createdAt', 'desc')
+ ->page($page, $pageSize)
+ ->select()
+ ->toArray();
+
+ // 统计每个企业的用户数和测试用量
+ foreach ($list as &$item) {
+ // 统计用户数(只统计未删除的用户)
+ $item['userCount'] = Db::name('users')
+ ->where('enterpriseId', $item['id'])
+ ->where('deletedAt', null)
+ ->count();
+
+ // 统计测试用量(测试结果数)- 通过企业下的用户ID统计(只统计未删除的用户)
+ $userIds = Db::name('users')
+ ->where('enterpriseId', $item['id'])
+ ->where('deletedAt', null)
+ ->column('id');
+
+ if (!empty($userIds)) {
+ $item['testUsage'] = Db::name('test_results')
+ ->where('userId', 'in', $userIds)
+ ->count();
+ } else {
+ $item['testUsage'] = 0;
+ }
+ }
+
+ $total = EnterpriseModel::where($where)->count();
+
+ // 统计活跃企业数(status为operating)
+ $activeCount = EnterpriseModel::where('status', 'operating')->count();
+
+ return success([
+ 'list' => $list,
+ 'total' => $total,
+ 'page' => $page,
+ 'pageSize' => $pageSize,
+ 'activeCount' => $activeCount
+ ]);
+ }
+
+ /**
+ * 获取企业详情
+ * @param int $id
+ * @return \think\response\Json
+ */
+ public function detail($id = null)
+ {
+ // 验证是否为超级管理员
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ // 如果路由参数没有传递,尝试从请求参数获取
+ if (empty($id)) {
+ $id = Request::param('id');
+ }
+
+ if (empty($id)) {
+ return error('企业ID不能为空', 400);
+ }
+
+ $enterprise = EnterpriseModel::find($id);
+
+ if (!$enterprise) {
+ return error('企业不存在', 404);
+ }
+
+ $data = $enterprise->toArray();
+
+ // 获取企业下的所有用户ID(只统计未删除的用户)
+ $userIds = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('deletedAt', null)
+ ->column('id');
+
+ // 统计用户数
+ $data['userCount'] = count($userIds);
+
+ // 获取管理员账号列表(企业管理员角色,只获取未删除的)
+ $adminAccounts = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('role', 'enterprise_admin')
+ ->where('deletedAt', null)
+ ->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
+ ->select()
+ ->toArray();
+ $data['adminAccounts'] = $adminAccounts;
+
+ // 获取用户列表(排除管理员,只获取未删除的)
+ $users = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('role', '<>', 'enterprise_admin')
+ ->where('deletedAt', null)
+ ->field('id,username,email,phone,mbtiType,status,createdAt')
+ ->limit(50) // 限制返回数量
+ ->select()
+ ->toArray();
+ $data['users'] = $users;
+
+ // 获取测试结果列表
+ $testResults = [];
+ if (!empty($userIds)) {
+ $testResults = Db::name('test_results')
+ ->alias('tr')
+ ->leftJoin('users u', 'tr.userId = u.id')
+ ->where('tr.userId', 'in', $userIds)
+ ->field('tr.id,tr.testType,tr.createdAt,u.username')
+ ->order('tr.createdAt', 'desc')
+ ->limit(50) // 限制返回数量
+ ->select()
+ ->toArray();
+ }
+ $data['testResults'] = $testResults;
+
+ // 统计测试用量
+ if (!empty($userIds)) {
+ $data['testUsage'] = Db::name('test_results')
+ ->where('userId', 'in', $userIds)
+ ->count();
+ } else {
+ $data['testUsage'] = 0;
+ }
+
+ // —— 小程序侧用户(wechat_users.enterpriseId)——
+ $wechatIds = [];
+ try {
+ $wechatIds = Db::name('wechat_users')->where('enterpriseId', $id)->column('id');
+ $wechatIds = array_values(array_filter($wechatIds));
+ } catch (\Throwable $e) {
+ $wechatIds = [];
+ }
+ $data['wechatUserCount'] = count($wechatIds);
+ $data['wechatUsers'] = [];
+ if (!empty($wechatIds)) {
+ try {
+ $data['wechatUsers'] = Db::name('wechat_users')
+ ->where('id', 'in', $wechatIds)
+ ->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
+ ->order('createdAt', 'desc')
+ ->limit(120)
+ ->select()
+ ->toArray();
+ } catch (\Throwable $e) {
+ $data['wechatUsers'] = [];
+ }
+ }
+
+ // 该企业下、带 enterpriseId 的小程序测试记录
+ $data['miniprogramTestResults'] = [];
+ try {
+ $data['miniprogramTestResults'] = Db::name('test_results')
+ ->alias('tr')
+ ->leftJoin('wechat_users w', 'tr.userId = w.id')
+ ->where('tr.enterpriseId', $id)
+ ->field('tr.id,tr.testType,tr.createdAt,tr.userId,w.nickname as wechatNickname')
+ ->order('tr.createdAt', 'desc')
+ ->limit(60)
+ ->select()
+ ->toArray();
+ } catch (\Throwable $e) {
+ $data['miniprogramTestResults'] = [];
+ }
+
+ // 订单与消耗(金额分)
+ $paidStatuses = ['paid', 'completed'];
+ try {
+ $data['orderStats'] = [
+ 'totalCount' => (int) Db::name('orders')->where('enterpriseId', $id)->count(),
+ 'paidCount' => (int) Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->count(),
+ 'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
+ ];
+ $data['recentOrders'] = Db::name('orders')
+ ->where('enterpriseId', $id)
+ ->order('createdAt', 'desc')
+ ->limit(25)
+ ->field('id,orderNo,status,amount,productType,userId,createdAt')
+ ->select()
+ ->toArray();
+ } catch (\Throwable $e) {
+ $data['orderStats'] = [
+ 'totalCount' => 0,
+ 'paidCount' => 0,
+ 'paidAmountFen' => 0,
+ ];
+ $data['recentOrders'] = [];
+ }
+
+ // 埋点:近 30 天,归属该企业的小程序用户
+ $data['analyticsStats'] = [
+ 'eventTotal' => 0,
+ 'pageViewCount' => 0,
+ 'byEvent' => [],
+ 'hint' => null,
+ 'windowDays' => 30,
+ ];
+ if (empty($wechatIds)) {
+ $data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点';
+ } else {
+ try {
+ $since = date('Y-m-d H:i:s', time() - 30 * 86400);
+ $data['analyticsStats']['eventTotal'] = (int) Db::name('analytics_events')
+ ->where('userId', 'in', $wechatIds)
+ ->where('createdAt', '>=', $since)
+ ->count();
+ $data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events')
+ ->where('userId', 'in', $wechatIds)
+ ->where('createdAt', '>=', $since)
+ ->where('eventName', 'page_view')
+ ->count();
+ $byEvent = Db::name('analytics_events')
+ ->where('userId', 'in', $wechatIds)
+ ->where('createdAt', '>=', $since)
+ ->field('eventName, COUNT(*) AS cnt')
+ ->group('eventName')
+ ->order('cnt', 'desc')
+ ->limit(20)
+ ->select()
+ ->toArray();
+ $data['analyticsStats']['byEvent'] = $byEvent ?: [];
+ } catch (\Throwable $e) {
+ $data['analyticsStats']['hint'] = '埋点表未就绪或查询失败(请确认已建 analytics_events 表)';
+ }
+ }
+
+ // 全局通知策略(超管在系统设置中配置,影响余额类提醒等)
+ $data['notificationPolicy'] = null;
+ try {
+ $nc = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find();
+ if ($nc) {
+ $val = $nc->getAttr('value');
+ $data['notificationPolicy'] = is_array($val) ? $val : null;
+ }
+ } catch (\Throwable $e) {
+ $data['notificationPolicy'] = null;
+ }
+
+ return success($data);
+ }
+
+ /**
+ * 创建企业
+ * @return \think\response\Json
+ */
+ public function create()
+ {
+ // 验证是否为超级管理员
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $data = Request::post();
+
+ // 验证必填字段
+ if (empty($data['name'])) {
+ return error('企业名称不能为空', 400);
+ }
+
+ // 验证管理员账号信息
+ if (empty($data['adminUsername'])) {
+ return error('管理员用户名不能为空', 400);
+ }
+
+ if (empty($data['adminPassword'])) {
+ return error('管理员密码不能为空', 400);
+ }
+
+ if (strlen($data['adminPassword']) < 6) {
+ return error('密码长度至少6位', 400);
+ }
+
+ // 检查企业代码是否重复(如果提供了代码)
+ if (!empty($data['code'])) {
+ if (EnterpriseModel::where('code', $data['code'])->find()) {
+ return error('企业代码已存在', 400);
+ }
+ }
+
+ // 检查管理员用户名是否已存在
+ if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
+ return error('管理员用户名已存在', 400);
+ }
+
+ // 状态映射(前端使用operating/trial/disabled)
+ $status = $data['status'] ?? 'operating';
+ if (!in_array($status, ['operating', 'trial', 'disabled'])) {
+ $status = 'operating';
+ }
+
+ // 验证试用到期时间
+ if ($status === 'trial') {
+ if (empty($data['trialExpireAt'])) {
+ return error('选择试用状态时,必须设置试用到期时间', 400);
+ }
+ // 确保到期时间大于当前时间
+ if ($data['trialExpireAt'] <= time()) {
+ return error('试用到期时间必须大于当前时间', 400);
+ }
+ }
+
+ // 开启事务
+ Db::startTrans();
+ try {
+ // 创建企业
+ $enterprise = new EnterpriseModel();
+ $enterprise->name = $data['name'];
+ $enterprise->code = $data['code'] ?? null;
+ $enterprise->contactName = $data['contactName'] ?? null;
+ $enterprise->contactPhone = $data['contactPhone'] ?? null;
+ $enterprise->contactEmail = $data['contactEmail'] ?? null;
+ $enterprise->balance = $data['balance'] ?? 0.00;
+ $enterprise->status = $status;
+ $enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
+ $enterprise->save();
+
+ $enterpriseId = $enterprise->id;
+
+ // 创建企业管理员账号
+ $adminUser = [
+ 'username' => $data['adminUsername'],
+ 'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
+ 'email' => $data['contactEmail'] ?? null,
+ 'phone' => $data['contactPhone'] ?? null,
+ 'role' => 'enterprise_admin',
+ 'enterpriseId' => $enterpriseId,
+ 'status' => 1,
+ 'createdAt' => time(),
+ 'updatedAt' => time()
+ ];
+
+ Db::name('users')->insert($adminUser);
+
+ // 提交事务
+ Db::commit();
+
+ $enterpriseData = $enterprise->toArray();
+ $enterpriseData['userCount'] = 1; // 刚创建的企业管理员
+ $enterpriseData['testUsage'] = 0;
+
+ return success($enterpriseData, '企业创建成功,管理员账号已创建');
+ } catch (\Exception $e) {
+ // 回滚事务
+ Db::rollback();
+ return error('创建失败:' . $e->getMessage(), 500);
+ }
+ }
+
+ /**
+ * 更新企业
+ * @param int $id
+ * @return \think\response\Json
+ */
+ public function update($id)
+ {
+ // 验证是否为超级管理员
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $enterprise = EnterpriseModel::find($id);
+
+ if (!$enterprise) {
+ return error('企业不存在', 404);
+ }
+
+ $data = Request::put();
+ $oldBalance = (float) ($enterprise->balance ?? 0);
+
+ // 如果更新企业代码,检查是否重复
+ if (isset($data['code']) && $data['code'] != $enterprise->code) {
+ if (EnterpriseModel::where('code', $data['code'])->find()) {
+ return error('企业代码已存在', 400);
+ }
+ }
+
+ // 状态验证
+ if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
+ return error('状态值无效', 400);
+ }
+
+ // 验证试用到期时间
+ $status = $data['status'] ?? $enterprise->status;
+ if ($status === 'trial') {
+ if (empty($data['trialExpireAt'])) {
+ return error('选择试用状态时,必须设置试用到期时间', 400);
+ }
+ // 确保到期时间大于当前时间
+ if ($data['trialExpireAt'] <= time()) {
+ return error('试用到期时间必须大于当前时间', 400);
+ }
+ $enterprise->trialExpireAt = $data['trialExpireAt'];
+ } else {
+ // 如果不是试用状态,清空到期时间
+ $enterprise->trialExpireAt = null;
+ }
+
+ $enterprise->save($data);
+
+ $newBalance = (float) ($enterprise->balance ?? 0);
+ if ($newBalance > $oldBalance) {
+ try {
+ \app\controller\api\Distribution::unfreezeCommissions((int) $id);
+ } catch (\Throwable $e) {
+ // 余额已更新成功,解冻失败不阻断主流程
+ }
+ }
+
+ $enterpriseData = $enterprise->toArray();
+
+ // 统计用户数和测试用量(只统计未删除的用户)
+ $enterpriseData['userCount'] = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('deletedAt', null)
+ ->count();
+
+ $userIds = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('deletedAt', null)
+ ->column('id');
+
+ if (!empty($userIds)) {
+ $enterpriseData['testUsage'] = Db::name('test_results')
+ ->where('userId', 'in', $userIds)
+ ->count();
+ } else {
+ $enterpriseData['testUsage'] = 0;
+ }
+
+ return success($enterpriseData, '更新成功');
+ }
+
+ /**
+ * 删除企业(软删除)
+ * @param int $id
+ * @return \think\response\Json
+ */
+ public function delete($id)
+ {
+ // 验证是否为超级管理员
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $enterprise = EnterpriseModel::find($id);
+
+ if (!$enterprise) {
+ return error('企业不存在', 404);
+ }
+
+ // 检查是否已删除
+ if ($enterprise->deletedAt) {
+ return error('企业已被删除', 400);
+ }
+
+ // 检查是否有用户关联(只检查未删除的用户)
+ $userCount = Db::name('users')
+ ->where('enterpriseId', $id)
+ ->where('deletedAt', null)
+ ->count();
+ if ($userCount > 0) {
+ return error('该企业下还有用户,无法删除', 400);
+ }
+
+ // 软删除(设置 deletedAt 时间戳)
+ $enterprise->delete();
+
+ return success(null, '删除成功');
+ }
+
+ /**
+ * 启用/禁用企业
+ * @param int $id
+ * @return \think\response\Json
+ */
+ public function toggleStatus($id)
+ {
+ // 验证是否为超级管理员
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+
+ $enterprise = EnterpriseModel::find($id);
+
+ if (!$enterprise) {
+ return error('企业不存在', 404);
+ }
+
+ // 切换状态:operating <-> disabled
+ if ($enterprise->status === 'operating') {
+ $enterprise->status = 'disabled';
+ } else {
+ $enterprise->status = 'operating';
+ }
+
+ $enterprise->save();
+
+ return success($enterprise, '操作成功');
+ }
+}
+
diff --git a/api/app/controller/superadmin/Settings.php b/api/app/controller/superadmin/Settings.php
index 40f00b1..8c6759e 100644
--- a/api/app/controller/superadmin/Settings.php
+++ b/api/app/controller/superadmin/Settings.php
@@ -2,6 +2,7 @@
namespace app\controller\superadmin;
use app\BaseController;
+use app\common\service\FeishuLeadWebhookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use app\model\Enterprise as EnterpriseModel;
@@ -276,6 +277,69 @@ class Settings extends BaseController
}
}
+ /**
+ * 飞书获客 Webhook(与 admin 共用配置)
+ */
+ public function getFeishuLeadConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+ $cfg = FeishuLeadWebhookService::getConfig();
+ return success([
+ 'enabled' => !empty($cfg['enabled']),
+ 'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
+ 'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
+ ]);
+ }
+
+ public function updateFeishuLeadConfig()
+ {
+ $user = $this->request->user ?? null;
+ if (!$user || $user['role'] !== 'superadmin') {
+ return error('无权限访问', 403);
+ }
+ $raw = $this->request->getContent();
+ $input = $raw ? json_decode($raw, true) : [];
+ if (!is_array($input)) {
+ $input = [];
+ }
+ $enabled = !empty($input['enabled']);
+ $webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
+ $contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
+ if ($contactPerson === '') {
+ $contactPerson = '运营';
+ }
+ if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
+ return error('Webhook 须以 http(s) 开头', 400);
+ }
+ $json = json_encode([
+ 'enabled' => $enabled,
+ 'webhookUrl' => $webhookUrl,
+ 'contactPerson' => $contactPerson,
+ ], JSON_UNESCAPED_UNICODE);
+ $now = time();
+ $key = FeishuLeadWebhookService::CONFIG_KEY;
+ $exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
+ if ($exists) {
+ Db::name('system_config')
+ ->where('key', $key)
+ ->where('enterprise_id', 0)
+ ->update(['value' => $json, 'updatedAt' => $now]);
+ } else {
+ Db::name('system_config')->insert([
+ 'key' => $key,
+ 'enterprise_id' => 0,
+ 'value' => $json,
+ 'description' => '飞书获客 Webhook',
+ 'createdAt' => $now,
+ 'updatedAt' => $now,
+ ]);
+ }
+ return success(null, '已保存');
+ }
+
/**
* 更新超管账户信息
* @return \think\response\Json
diff --git a/api/database/migrations/add_feishu_lead_webhook.sql b/api/database/migrations/add_feishu_lead_webhook.sql
new file mode 100644
index 0000000..1549218
--- /dev/null
+++ b/api/database/migrations/add_feishu_lead_webhook.sql
@@ -0,0 +1,11 @@
+-- 飞书获客 Webhook 去重表(前缀 mbti_ 与 .env DATABASE_PREFIX 一致)
+CREATE TABLE IF NOT EXISTS `mbti_feishu_lead_dedup` (
+ `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `dedupKey` varchar(160) NOT NULL COMMENT '如 order_paid:123、phone_bind:456',
+ `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_dedup_key` (`dedupKey`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='飞书获客推送幂等';
+
+-- 可选:加速按用户查最近行为(若 idx_user_id 已存在会报错,忽略即可)
+-- ALTER TABLE `mbti_analytics_events` ADD INDEX `idx_user_id` (`userId`);
diff --git a/api/database/migrations/add_mp_analytics_events.sql b/api/database/migrations/add_mp_analytics_events.sql
new file mode 100644
index 0000000..2ce15eb
--- /dev/null
+++ b/api/database/migrations/add_mp_analytics_events.sql
@@ -0,0 +1,15 @@
+-- 小程序埋点表(与 mbti_ 表前缀一致;若 .env 中 DATABASE_PREFIX 不同,请同步改表名)
+-- ThinkPHP:Db::name('analytics_events') + 前缀 mbti_ => mbti_analytics_events
+CREATE TABLE IF NOT EXISTS `mbti_analytics_events` (
+ `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ `userId` int(10) unsigned DEFAULT NULL COMMENT 'wechat_users.id',
+ `openid` varchar(64) DEFAULT NULL,
+ `eventName` varchar(128) NOT NULL DEFAULT '',
+ `pagePath` varchar(255) DEFAULT NULL,
+ `propsJson` text COMMENT 'JSON 字符串',
+ `clientTs` bigint(20) DEFAULT NULL COMMENT '客户端毫秒时间戳',
+ `createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_event_created` (`eventName`,`createdAt`),
+ KEY `idx_created` (`createdAt`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='小程序行为埋点';
diff --git a/api/public/router.php b/api/public/router.php
new file mode 100644
index 0000000..8de2de6
--- /dev/null
+++ b/api/public/router.php
@@ -0,0 +1,7 @@
+middleware('cors');
// 前端需要认证的API路由
@@ -166,6 +168,14 @@ Route::group('api/v1/superadmin', function () {
// 超级管理员认证
Route::get('auth/me', 'superadmin.Auth/me');
Route::post('auth/logout', 'superadmin.Auth/logout');
+
+ // 全平台订单列表(与 admin.Order 共用逻辑,超管不限企业)
+ Route::get('orders', 'admin.Order/index');
+
+ // 将无 enterpriseId 的订单/测试等归并到指定企业(仅超管;默认预览)
+ Route::post('data-migration/attach-orphan-orders', 'superadmin.DataMigration/attachOrphanOrders');
+ // 将全平台无归属测试记录/小程序用户并入「存客宝」或指定企业(仅超管)
+ Route::post('data-migration/attach-orphans-to-cunkbao', 'superadmin.DataMigration/attachOrphansToCunkbao');
// 企业管理(超管专用)
// 注意:带参数的路由要放在不带参数的路由之前,避免路由匹配冲突
@@ -253,6 +263,10 @@ Route::group('api/v1/superadmin', function () {
Route::post('distribution/withdrawals/:id/reject', 'superadmin.Distribution/rejectWithdrawal');
Route::get('distribution/settings', 'superadmin.Distribution/settings');
Route::put('distribution/settings', 'superadmin.Distribution/updateSettings');
+
+ // 小程序埋点统计(仅超管)
+ Route::get('analytics/summary', 'superadmin.Analytics/summary');
+ Route::get('analytics/events', 'superadmin.Analytics/events');
})->middleware(['cors', 'auth', 'superadmin']);
// ==================== 兼容旧版路由(保留,逐步废弃)====================
diff --git a/douyin-miniprogram/app.js b/douyin-miniprogram/app.js
index e0bdf9e..ba24a4d 100644
--- a/douyin-miniprogram/app.js
+++ b/douyin-miniprogram/app.js
@@ -28,10 +28,27 @@ App({
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle
+ if (typeof cfg.reviewMode === 'boolean') {
+ this.globalData.reviewMode = cfg.reviewMode
+ }
}
}).catch(() => {})
},
+ onShow() {
+ try {
+ const { reportPageView } = require('./utils/analytics.js')
+ reportPageView()
+ } catch (e) {}
+ },
+
+ onHide() {
+ try {
+ const { flush } = require('./utils/analytics.js')
+ flush()
+ } catch (e) {}
+ },
+
loadStoredData() {
const token = tt.getStorageSync('token')
if (token) {
diff --git a/douyin-miniprogram/app.json b/douyin-miniprogram/app.json
index f4cfc06..36e907c 100644
--- a/douyin-miniprogram/app.json
+++ b/douyin-miniprogram/app.json
@@ -39,7 +39,7 @@
},
{
"pagePath": "pages/index/camera",
- "text": "查看报告",
+ "text": "拍摄",
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},
diff --git a/douyin-miniprogram/custom-tab-bar/index.js b/douyin-miniprogram/custom-tab-bar/index.js
index 2663637..1e49d17 100644
--- a/douyin-miniprogram/custom-tab-bar/index.js
+++ b/douyin-miniprogram/custom-tab-bar/index.js
@@ -2,33 +2,23 @@
Component({
data: {
selected: 0,
- reviewMode: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
- { pagePath: '/pages/index/camera', text: '查看报告', textKey: 'camera', icon: 'camera' },
+ { pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
]
},
lifetimes: {
attached() {
this.updateSelected()
- this.checkReviewMode()
}
},
pageLifetimes: {
show() {
this.updateSelected()
- this.checkReviewMode()
}
},
methods: {
- checkReviewMode() {
- try {
- const app = getApp()
- const rm = !!(app && app.globalData && app.globalData.reviewMode)
- this.setData({ reviewMode: rm })
- } catch (e) {}
- },
updateSelected() {
try {
const pages = getCurrentPages()
@@ -66,13 +56,6 @@ Component({
} catch (e) {}
}
- // 审核模式:中间按钮跳转到测试选择页而非相机
- if (index === 1 && this.data.reviewMode) {
- tt.navigateTo({ url: '/pages/test-select/index' })
- this.setData({ selected: index })
- return
- }
-
tt.switchTab({ url })
this.setData({ selected: index })
}
diff --git a/douyin-miniprogram/custom-tab-bar/index.ttml b/douyin-miniprogram/custom-tab-bar/index.ttml
index 1bf2325..c07927a 100644
--- a/douyin-miniprogram/custom-tab-bar/index.ttml
+++ b/douyin-miniprogram/custom-tab-bar/index.ttml
@@ -1,4 +1,4 @@
-
+
@@ -11,30 +11,21 @@
首页
-
-
-
- 测试
-
-
-
-
-
+
+
+
+
+
+
+ 拍摄
-
+
{
- if (cfg && cfg.textConfig) {
- app.globalData.textConfig = cfg.textConfig
- this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析' })
+ }
+ app.getRuntimeConfig().then((cfg) => {
+ if (cfg) {
+ if (typeof cfg.reviewMode === 'boolean') {
+ app.globalData.reviewMode = cfg.reviewMode
}
- }).catch(() => {})
+ if (cfg.textConfig) {
+ app.globalData.textConfig = cfg.textConfig
+ this.setData({
+ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析',
+ reviewMode: !!app.globalData.reviewMode
+ })
+ return
+ }
+ }
+ this.setData({ reviewMode: !!app.globalData.reviewMode })
+ }).catch(() => {
+ this.setData({ reviewMode: !!app.globalData.reviewMode })
+ })
+ },
+
+ onReady() {
+ if (!app.globalData.reviewMode) {
+ this.initCameraContext()
+ }
+ },
+
+ goToQuestionnaire() {
+ tt.navigateTo({ url: '/pages/test-select/index' })
+ },
+
+ initCameraContext() {
+ try {
+ if (typeof tt.createCameraContext === 'function') {
+ this.cameraContext = tt.createCameraContext()
+ }
+ } catch (e) {
+ console.error('initCameraContext', e)
+ this.cameraContext = null
}
},
onShow() {
- // 审核模式下重定向到测试选择页
- if (app.globalData.reviewMode) {
- tt.navigateTo({ url: '/pages/test-select/index' })
+ const rm = !!app.globalData.reviewMode
+ this.setData({ reviewMode: rm })
+
+ if (typeof this.getTabBar === 'function' && this.getTabBar()) {
+ this.getTabBar().setData({ selected: 1 })
+ }
+
+ if (rm) {
return
}
if (!ensureProfileCompleteAndRedirect()) return
- if (typeof this.getTabBar === 'function' && this.getTabBar()) {
- this.getTabBar().setData({ selected: 1 })
+ if (this.data.photos.length < 3) {
+ this.initCameraContext()
}
this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig
@@ -77,6 +114,14 @@ Page({
return
}
+ if (!this.cameraContext) {
+ this.initCameraContext()
+ }
+ if (!this.cameraContext || typeof this.cameraContext.takePhoto !== 'function') {
+ tt.showToast({ title: '相机未就绪,请稍候再试', icon: 'none' })
+ return
+ }
+
this.cameraContext.takePhoto({
quality: 'high',
success: (res) => {
@@ -95,7 +140,17 @@ Page({
}
},
fail: (err) => {
- tt.showToast({ title: '拍照失败', icon: 'none' })
+ const msg = (err && (err.errMsg || err.message)) ? String(err.errMsg || err.message) : ''
+ if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
+ tt.showModal({
+ title: '需要相机权限',
+ content: '请在设置中允许使用摄像头',
+ confirmText: '去设置',
+ success: (r) => { if (r.confirm) tt.openSetting() }
+ })
+ } else {
+ tt.showToast({ title: '拍照失败,请重试', icon: 'none' })
+ }
console.error('拍照失败:', err)
}
})
@@ -116,6 +171,7 @@ Page({
guideText: '请正对镜头'
})
tt.showToast({ title: '已清空,请重新拍摄', icon: 'success' })
+ setTimeout(() => this.initCameraContext(), 200)
}
}
})
diff --git a/douyin-miniprogram/pages/index/camera.json b/douyin-miniprogram/pages/index/camera.json
index 88e3bdf..bdbb7e4 100644
--- a/douyin-miniprogram/pages/index/camera.json
+++ b/douyin-miniprogram/pages/index/camera.json
@@ -1,6 +1,6 @@
-{
- "navigationBarTitleText": "拍照",
- "navigationBarBackgroundColor": "#FFFFFF",
- "navigationBarTextStyle": "black",
- "usingComponents": {}
-}
+{
+ "navigationBarTitleText": "拍摄",
+ "navigationBarBackgroundColor": "#FFFFFF",
+ "navigationBarTextStyle": "black",
+ "usingComponents": {}
+}
diff --git a/douyin-miniprogram/pages/index/camera.ttml b/douyin-miniprogram/pages/index/camera.ttml
index edc0c2d..1780ae0 100644
--- a/douyin-miniprogram/pages/index/camera.ttml
+++ b/douyin-miniprogram/pages/index/camera.ttml
@@ -1,69 +1,77 @@
-
-
-
-
- 步骤 {{photoIndex + 1}}/3
- {{photos.length}}/3 张照片已完成
-
-
-
-
-
-
-
-
- {{photoIndex + 1}}
-
- {{guideText}}
- 请保持自然表情,确保光线充足
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}
-
-
-
-
-
-
-
- 拍摄{{guideText}}照片
-
-
- 从相册选择
-
-
-
-
-
- 重新拍摄
-
-
- 立即{{aiAnalysisText || '分析'}}
-
-
-
-
-
-
- 为保障服务与联系,请先授权手机号。
-
-
-
+
+
+
+
+ 问卷审核模式
+ 当前未开放实时拍摄面相,请先做 MBTI / DISC / PDP 等问卷测试;后台关闭「审核模式」后即可使用拍摄报告。
+ 去做性格测试
+
+
+
+
+
+ 步骤 {{photoIndex + 1}}/3
+ {{photos.length}}/3 张照片已完成
+
+
+
+
+
+
+
+
+ {{photoIndex + 1}}
+
+ {{guideText}}
+ 请保持自然表情,确保光线充足
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}
+
+
+
+
+
+
+
+ 拍摄{{guideText}}照片
+
+
+ 从相册选择
+
+
+
+
+
+ 重新拍摄
+
+
+ 立即{{aiAnalysisText || '分析'}}
+
+
+
+
+
+ 为保障服务与联系,请先授权手机号。
+
+
+
+
diff --git a/douyin-miniprogram/pages/index/camera.ttss b/douyin-miniprogram/pages/index/camera.ttss
index 5430ea3..5d91e77 100644
--- a/douyin-miniprogram/pages/index/camera.ttss
+++ b/douyin-miniprogram/pages/index/camera.ttss
@@ -1,312 +1,366 @@
-/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar(含中间浮起圆钮)预留空间 */
-.container {
- width: 100%;
- height: 100vh;
- height: 100dvh;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background-color: #fff;
- /* 预留底部空间,避免与自定义 tabBar(约 100rpx 高 + 中间圆钮上浮约 56rpx)重叠 */
- padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
-}
-
-.progress-section {
- flex-shrink: 0;
- margin: 16rpx 24rpx 0;
- padding: 20rpx 32rpx 16rpx;
- border-radius: 10rpx;
- background: linear-gradient(to right, #fff5f5, #ffe5e8);
-}
-
-.progress-info {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10rpx;
-}
-
-.step-text {
- font-size: 26rpx;
- font-weight: 600;
- color: #e63946;
-}
-
-.photo-count {
- font-size: 22rpx;
- color: #999;
-}
-
-.progress-bars {
- display: flex;
- gap: 12rpx;
- margin-bottom: 12rpx;
-}
-
-.progress-bar {
- flex: 1;
- height: 6rpx;
- border-radius: 6rpx;
-}
-
-.progress-bar.completed {
- background-color: #52c41a;
-}
-
-.progress-bar.pending {
- background-color: #e5e5e5;
-}
-
-.instruction-card {
- background: rgba(255, 255, 255, 0.9);
- border-radius: 12rpx;
- padding: 14rpx 18rpx;
- border: 1rpx solid rgba(230, 57, 70, 0.1);
-}
-
-.instruction-content {
- display: flex;
- align-items: center;
- gap: 12rpx;
-}
-
-.step-number {
- width: 40rpx;
- height: 40rpx;
- flex-shrink: 0;
- border-radius: 50%;
- background-color: #e63946;
- color: #fff;
- font-size: 24rpx;
- font-weight: bold;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.instruction-text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2rpx;
-}
-
-.angle-text {
- font-size: 26rpx;
- font-weight: 600;
- color: #c41d2a;
-}
-
-.tip-text {
- font-size: 22rpx;
- color: #666;
- line-height: 1.3;
-}
-
-.camera-container {
- flex: 1;
- min-height: 0;
- padding: 16rpx 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.camera-preview {
- width: 100%;
- max-width: 100%;
- max-height: 100%;
- aspect-ratio: 1;
- border-radius: 32rpx;
- overflow: hidden;
- border: 6rpx solid #e5e5e5;
- background-color: #000;
- position: relative;
-}
-
-.camera {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
-}
-
-.photos-preview {
- width: 100%;
- height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- gap: 12rpx;
-}
-
-.photo-item {
- position: relative;
- flex: 1;
- min-width: 0;
- height: 100%;
- max-height: 100%;
- border-radius: 16rpx;
- overflow: hidden;
- border: 4rpx solid #e5e5e5;
-}
-
-.photo-image {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.photo-label {
- position: absolute;
- top: 8rpx;
- left: 8rpx;
- background: rgba(230, 57, 70, 0.9);
- color: #fff;
- font-size: 20rpx;
- font-weight: 600;
- padding: 4rpx 12rpx;
- border-radius: 12rpx;
-}
-
-.button-container {
- flex-shrink: 0;
- padding: 16rpx 24rpx 0;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.capture-actions {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 20rpx;
-}
-
-.capture-actions .capture-button {
- width: 100%;
-}
-
-.album-button {
- width: 100%;
- padding: 20rpx;
- border-radius: 50rpx;
- text-align: center;
- background: #fff;
- border: 2rpx solid #e63946;
- box-sizing: border-box;
-}
-
-.album-button:active {
- background: #fff5f5;
-}
-
-.album-button-text {
- font-size: 28rpx;
- font-weight: 600;
- color: #e63946;
- letter-spacing: 1rpx;
-}
-
-.phone-auth-section {
- padding: 24rpx 24rpx 0;
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.phone-auth-tip {
- font-size: 26rpx;
- color: #4b5563;
-}
-
-.phone-auth-btn {
- height: 80rpx;
- line-height: 80rpx;
- border-radius: 40rpx;
- background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
- color: #ffffff;
- font-size: 28rpx;
-}
-
-.phone-auth-btn::after {
- border: none;
-}
-
-.capture-button {
- width: 100%;
- max-width: 100%;
- padding: 22rpx;
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- border-radius: 50rpx;
- text-align: center;
- box-sizing: border-box;
- box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
-}
-
-.button-text {
- font-size: 30rpx;
- font-weight: 700;
- color: #fff;
- letter-spacing: 1rpx;
-}
-
-.action-buttons {
- display: flex;
- gap: 20rpx;
- width: 100%;
-}
-
-.action-button {
- flex: 1;
- padding: 28rpx 24rpx;
- border-radius: 50rpx;
- text-align: center;
- box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
- transition: all 0.3s ease;
-}
-
-.action-button.secondary {
- background: #fff;
- border: 2rpx solid #e63946;
-}
-
-.action-button.secondary:active {
- background: #fff5f5;
- transform: scale(0.98);
-}
-
-.action-button.primary {
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
-}
-
-.action-button.primary:active {
- transform: scale(0.98);
- box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
-}
-
-.action-button-text {
- font-size: 28rpx;
- font-weight: 700;
- letter-spacing: 1rpx;
-}
-
-.action-button.secondary .action-button-text {
- color: #e63946;
-}
-
-.action-button.primary .action-button-text {
- color: #fff;
-}
+/* pages/index/camera.ttss */
+.camera-page-root {
+ min-height: 100vh;
+ min-height: 100dvh;
+ position: relative;
+}
+
+.container {
+ width: 100%;
+ height: 100vh;
+ height: 100dvh;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #fff;
+ /* 预留底部空间,避免与自定义 tabBar(约 100rpx 高 + 中间圆钮上浮约 56rpx)重叠 */
+ padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
+}
+
+.progress-section {
+ flex-shrink: 0;
+ margin: 16rpx 24rpx 0;
+ padding: 20rpx 32rpx 16rpx;
+ border-radius: 10rpx;
+ background: linear-gradient(to right, #fff5f5, #ffe5e8);
+}
+
+.progress-info {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10rpx;
+}
+
+.step-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #e63946;
+}
+
+.photo-count {
+ font-size: 22rpx;
+ color: #999;
+}
+
+.progress-bars {
+ display: flex;
+ gap: 12rpx;
+ margin-bottom: 12rpx;
+}
+
+.progress-bar {
+ flex: 1;
+ height: 6rpx;
+ border-radius: 6rpx;
+}
+
+.progress-bar.completed {
+ background-color: #52c41a;
+}
+
+.progress-bar.pending {
+ background-color: #e5e5e5;
+}
+
+.instruction-card {
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 12rpx;
+ padding: 14rpx 18rpx;
+ border: 1rpx solid rgba(230, 57, 70, 0.1);
+}
+
+.instruction-content {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.step-number {
+ width: 40rpx;
+ height: 40rpx;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background-color: #e63946;
+ color: #fff;
+ font-size: 24rpx;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.instruction-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2rpx;
+}
+
+.angle-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #c41d2a;
+}
+
+.tip-text {
+ font-size: 22rpx;
+ color: #666;
+ line-height: 1.3;
+}
+
+.camera-container {
+ flex: 1;
+ min-height: 0;
+ padding: 16rpx 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.camera-preview {
+ width: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: 1;
+ border-radius: 32rpx;
+ overflow: hidden;
+ border: 6rpx solid #e5e5e5;
+ background-color: #000;
+ position: relative;
+}
+
+.camera {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.photos-preview {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 12rpx;
+}
+
+.photo-item {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ max-height: 100%;
+ border-radius: 16rpx;
+ overflow: hidden;
+ border: 4rpx solid #e5e5e5;
+}
+
+.photo-image {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.photo-label {
+ position: absolute;
+ top: 8rpx;
+ left: 8rpx;
+ background: rgba(230, 57, 70, 0.9);
+ color: #fff;
+ font-size: 20rpx;
+ font-weight: 600;
+ padding: 4rpx 12rpx;
+ border-radius: 12rpx;
+}
+
+.button-container {
+ flex-shrink: 0;
+ padding: 16rpx 24rpx 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.capture-actions {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20rpx;
+}
+
+.capture-actions .capture-button {
+ width: 100%;
+}
+
+.album-button {
+ width: 100%;
+ padding: 20rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ background: #fff;
+ border: 2rpx solid #e63946;
+ box-sizing: border-box;
+}
+
+.album-button:active {
+ background: #fff5f5;
+}
+
+.album-button-text {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: #e63946;
+ letter-spacing: 1rpx;
+}
+
+.phone-auth-section {
+ padding: 24rpx 24rpx 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.phone-auth-tip {
+ font-size: 26rpx;
+ color: #4b5563;
+}
+
+.phone-auth-btn {
+ height: 80rpx;
+ line-height: 80rpx;
+ border-radius: 40rpx;
+ background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
+ color: #ffffff;
+ font-size: 28rpx;
+}
+
+.phone-auth-btn::after {
+ border: none;
+}
+
+.capture-button {
+ width: 100%;
+ max-width: 100%;
+ padding: 22rpx;
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ border-radius: 50rpx;
+ text-align: center;
+ box-sizing: border-box;
+ box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
+}
+
+.button-text {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 1rpx;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 20rpx;
+ width: 100%;
+}
+
+.action-button {
+ flex: 1;
+ padding: 28rpx 24rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+}
+
+.action-button.secondary {
+ background: #fff;
+ border: 2rpx solid #e63946;
+}
+
+.action-button.secondary:active {
+ background: #fff5f5;
+ transform: scale(0.98);
+}
+
+.action-button.primary {
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
+}
+
+.action-button.primary:active {
+ transform: scale(0.98);
+ box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
+}
+
+.action-button-text {
+ font-size: 28rpx;
+ font-weight: 700;
+ letter-spacing: 1rpx;
+}
+
+.action-button.secondary .action-button-text {
+ color: #e63946;
+}
+
+.action-button.primary .action-button-text {
+ color: #fff;
+}
+
+.review-mode-panel {
+ justify-content: center;
+ align-items: center;
+ padding: 48rpx 40rpx;
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
+}
+
+.review-mode-card {
+ width: 100%;
+ max-width: 620rpx;
+ padding: 48rpx 40rpx;
+ background: #fff;
+ border-radius: 24rpx;
+ box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.06);
+ border: 1rpx solid rgba(0, 0, 0, 0.04);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24rpx;
+}
+
+.review-mode-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.review-mode-desc {
+ font-size: 28rpx;
+ color: #64748b;
+ line-height: 1.65;
+ text-align: center;
+}
+
+.review-mode-btn {
+ margin-top: 16rpx;
+ padding: 24rpx 56rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ color: #fff;
+ font-size: 30rpx;
+ font-weight: 600;
+}
+
+.review-mode-btn:active {
+ opacity: 0.9;
+}
diff --git a/douyin-miniprogram/pages/index/index.js b/douyin-miniprogram/pages/index/index.js
index 543e4e9..e54d6af 100644
--- a/douyin-miniprogram/pages/index/index.js
+++ b/douyin-miniprogram/pages/index/index.js
@@ -29,7 +29,7 @@ Page({
navbarHeight: navbarHeightRpx,
showEnterpriseEntry: userInfo.hasEnterprise === true,
siteTitle: gd.reviewMode ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
- startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
+ startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: gd.reviewMode ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: !!gd.reviewMode
})
@@ -45,7 +45,7 @@ Page({
if (cfg.textConfig) {
getApp().globalData.textConfig = cfg.textConfig
this.setData({
- startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '开始面相测试'),
+ startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '拍摄'),
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
})
}
@@ -100,7 +100,7 @@ Page({
const rm = !!gd.reviewMode
this.setData({
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
- startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
+ startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm
})
@@ -124,14 +124,10 @@ Page({
}
},
- // 开始测试(审核模式跳问卷,正常模式跳拍照)
+ // 与底栏中间一致:始终进入拍摄 Tab(审核态在 camera 页内展示问卷引导)
startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {}
- if (this.data.reviewMode) {
- tt.navigateTo({ url: '/pages/test-select/index' })
- } else {
- tt.switchTab({ url: '/pages/index/camera' })
- }
+ tt.switchTab({ url: '/pages/index/camera' })
},
// 上传照片(个人版入口:强制本次链路为个人定价)
diff --git a/douyin-miniprogram/pages/index/upload.ttml b/douyin-miniprogram/pages/index/upload.ttml
index b4c0cde..e852dc5 100644
--- a/douyin-miniprogram/pages/index/upload.ttml
+++ b/douyin-miniprogram/pages/index/upload.ttml
@@ -1,113 +1,113 @@
-
-
-
-
-
-
- 多角度拍摄更精准
-
- 为了更准确地分析您的性格特征,
- 请上传以下三个维度的照片。
-
-
-
-
-
-
-
-
- 1
-
-
- 正面
- 目视前方,五官端正,不遮挡额头
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
- 2
-
-
- 左侧 45度
- 展示左侧面颊,用于分析面部轮廓
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
- 3
-
-
- 右侧 45度
- 展示右侧面颊,完整捕捉面部信息
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
-
-
- 温馨提示
-
-
- · 光线充足,背景整洁,避免过曝或过暗
- · 保持表情自然,无需刻意微笑
- · 如有佩戴眼镜,请确保镜片不反光
-
-
-
-
-
- 为保障服务与联系,请先授权手机号。
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ 多角度拍摄更精准
+
+ 为了更准确地分析您的性格特征,
+ 请上传以下三个维度的照片。
+
+
+
+
+
+
+
+
+ 1
+
+
+ 正面
+ 目视前方,五官端正,不遮挡额头
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+ 2
+
+
+ 左侧 45度
+ 展示左侧面颊,用于分析面部轮廓
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+ 3
+
+
+ 右侧 45度
+ 展示右侧面颊,完整捕捉面部信息
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+
+
+ 温馨提示
+
+
+ · 光线充足,背景整洁,避免过曝或过暗
+ · 保持表情自然,无需刻意微笑
+ · 如有佩戴眼镜,请确保镜片不反光
+
+
+
+
+
+ 为保障服务与联系,请先授权手机号。
+
+
+
+
+
+
+
+
+
+
diff --git a/douyin-miniprogram/pages/phone-auth/index.ttml b/douyin-miniprogram/pages/phone-auth/index.ttml
index 3b5d05e..b158667 100644
--- a/douyin-miniprogram/pages/phone-auth/index.ttml
+++ b/douyin-miniprogram/pages/phone-auth/index.ttml
@@ -1,5 +1,5 @@
-
- 为保障服务与联系需要,请授权您的手机号。
- 授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。
-
-
+
+ 为保障服务与联系需要,请授权您的手机号。
+ 授权后可在「拍摄」「支付」「企业咨询」等场景使用,仅需授权一次。
+
+
diff --git a/douyin-miniprogram/pages/profile/index.js b/douyin-miniprogram/pages/profile/index.js
index 1aa4e3f..a51a2b4 100644
--- a/douyin-miniprogram/pages/profile/index.js
+++ b/douyin-miniprogram/pages/profile/index.js
@@ -258,10 +258,16 @@ Page({
},
goToIndex() { tt.switchTab({ url: '/pages/index/index' }) },
- goToCamera() { tt.switchTab({ url: '/pages/index/camera' }) },
- goToHistory() { tt.navigateTo({ url: '/pages/history/index' }) },
+ goToHistory() {
+ try { require('../../utils/analytics').track('tap_test_history', {}) } catch (e) {}
+ tt.navigateTo({ url: '/pages/history/index' })
+ },
goToUserProfile() { tt.navigateTo({ url: '/pages/user-profile/index' }) },
- goToPurchase() { tt.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
+ goToDeepService() {
+ try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
+ tt.navigateTo({ url: '/pages/purchase/index' })
+ },
+ goToPurchase() { tt.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { tt.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { tt.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToEnterprise() { tt.navigateTo({ url: '/pages/enterprise/index' }) },
diff --git a/douyin-miniprogram/pages/profile/index.ttml b/douyin-miniprogram/pages/profile/index.ttml
index 0e38c45..abc0751 100644
--- a/douyin-miniprogram/pages/profile/index.ttml
+++ b/douyin-miniprogram/pages/profile/index.ttml
@@ -68,58 +68,84 @@
›
-
-
-
-
-
- 深度服务
-
-
-
-
-
diff --git a/douyin-miniprogram/pages/profile/index.ttss b/douyin-miniprogram/pages/profile/index.ttss
index 33d810b..7de16ad 100644
--- a/douyin-miniprogram/pages/profile/index.ttss
+++ b/douyin-miniprogram/pages/profile/index.ttss
@@ -1,534 +1,614 @@
-/* pages/profile/index.wxss */
-page {
- background-color: #F9FAFB;
-}
-
-.page {
- min-height: 100vh;
- background-color: #F9FAFB;
- padding-bottom: 140rpx;
-}
-
-/* ===== 顶部栏 ===== */
-.topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16rpx 40rpx 24rpx;
- background: #F9FAFB;
-}
-
-.topbar-title {
- font-size: 48rpx;
- font-weight: 700;
- color: #111827;
- letter-spacing: -0.5rpx;
-}
-
-.topbar-actions {
- display: flex;
- gap: 12rpx;
- align-items: center;
-}
-
-.settings-btn {
- width: 72rpx;
- height: 72rpx;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.settings-icon {
- font-size: 44rpx;
- color: #4B5563;
-}
-
-.icon-btn {
- width: 72rpx;
- height: 72rpx;
- border-radius: 50%;
- background: transparent;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: none;
-}
-
-.icon-btn::after {
- border: none;
-}
-
-.icon-text {
- font-size: 36rpx;
-}
-
-/* ===== 用户卡片 ===== */
-.user-card {
- margin: 0 32rpx 28rpx;
- background: #FFFFFF;
- border-radius: 28rpx;
- padding: 36rpx 32rpx;
- display: flex;
- align-items: center;
- gap: 20rpx;
- box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05);
- position: relative;
-}
-
-.user-card:active {
- opacity: 0.9;
-}
-
-/* 头像区域(已登录,仅展示) */
-.avatar-wrap {
- position: relative;
- width: 144rpx;
- height: 144rpx;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.avatar-ring {
- width: 144rpx;
- height: 144rpx;
- border-radius: 50%;
- padding: 4rpx;
- background: linear-gradient(135deg, #FFDEE9 0%, #B5FFFC 100%);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- box-shadow: inset 0 2rpx 10rpx rgba(0,0,0,0.05);
- box-sizing: border-box;
-}
-
-.avatar-img {
- width: 128rpx;
- height: 128rpx;
- border-radius: 50%;
- border: 4rpx solid #FFFFFF;
- display: block;
-}
-
-.avatar-letter-wrap {
- width: 128rpx;
- height: 128rpx;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 4rpx solid #FFFFFF;
-}
-
-.avatar-letter {
- font-size: 64rpx;
- font-weight: 700;
- color: #FFFFFF;
-}
-
-.online-dot {
- position: absolute;
- bottom: 8rpx;
- right: 8rpx;
- width: 28rpx;
- height: 28rpx;
- background: #22C55E;
- border-radius: 50%;
- border: 4rpx solid #FFFFFF;
- z-index: 10;
- pointer-events: none;
-}
-
-/* 用户信息 */
-.user-meta {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 8rpx;
- padding-left: 12rpx;
-}
-
-.user-name {
- font-size: 36rpx;
- font-weight: 700;
- color: #111827;
- display: block;
-}
-
-.user-sub {
- font-size: 24rpx;
- color: #9CA3AF;
-}
-
-.nickname-text {
- font-size: 36rpx;
- font-weight: 700;
- color: #111827;
- line-height: 1.3;
- display: block;
-}
-
-/* 类型标签 */
-.tags-scroll {
- width: 100%;
- white-space: nowrap;
-}
-
-.tags-row {
- display: flex;
- gap: 10rpx;
- flex-wrap: nowrap;
-}
-
-.tag {
- display: inline-flex;
- align-items: center;
- padding: 6rpx 16rpx;
- border-radius: 10rpx;
- flex-shrink: 0;
-}
-
-.tag-text {
- font-size: 20rpx;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.tag-purple {
- background: #EDE9FE;
-}
-.tag-purple .tag-text {
- color: #7C3AED;
-}
-
-.tag-blue {
- background: #DBEAFE;
-}
-.tag-blue .tag-text {
- color: #2563EB;
-}
-
-.tag-orange {
- background: #FEF3C7;
-}
-.tag-orange .tag-text {
- color: #D97706;
-}
-
-.tag-gray {
- background: #F3F4F6;
-}
-.tag-gray .tag-text {
- color: #9CA3AF;
-}
-
-.chevron {
- font-size: 48rpx;
- color: #D1D5DB;
- flex-shrink: 0;
-}
-
-/* ===== Section ===== */
-.section {
- margin-bottom: 28rpx;
-}
-
-.px-section {
- padding: 0 32rpx;
-}
-
-.section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 32rpx;
- margin-bottom: 20rpx;
-}
-
-.section-title {
- font-size: 32rpx;
- font-weight: 700;
- color: #1F2937;
- display: block;
- margin-bottom: 20rpx;
-}
-
-.section-header .section-title {
- margin-bottom: 0;
-}
-
-.section-link {
- display: flex;
- align-items: center;
-}
-
-.section-link-text {
- font-size: 24rpx;
- color: #F43F5E;
- font-weight: 500;
-}
-
-/* ===== 测试结果卡片(横向滚动) ===== */
-.cards-scroll {
- width: 100%;
- white-space: nowrap;
-}
-
-.cards-row {
- display: flex;
- gap: 24rpx;
- padding: 8rpx 32rpx 16rpx;
- width: max-content;
-}
-
-.result-card {
- width: 280rpx;
- height: 240rpx;
- border-radius: 24rpx;
- padding: 32rpx 28rpx 24rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- position: relative;
- overflow: hidden;
- box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
- border: 1rpx solid rgba(0,0,0,0.04);
- background: #FFFFFF;
- flex-shrink: 0;
-}
-
-.card-deco {
- position: absolute;
- top: -24rpx;
- right: -24rpx;
- width: 120rpx;
- height: 120rpx;
- border-radius: 50%;
- opacity: 0.3;
-}
-
-.card-purple .card-deco { background: #DDD6FE; }
-.card-blue .card-deco { background: #BFDBFE; }
-.card-orange .card-deco { background: #FDE68A; }
-.card-rose .card-deco { background: #FECDD3; }
-
-.card-icon-wrap {
- width: 64rpx;
- height: 64rpx;
- border-radius: 16rpx;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.card-icon-purple { background: #EDE9FE; }
-.card-icon-blue { background: #DBEAFE; }
-.card-icon-orange { background: #FEF3C7; }
-.card-icon-rose { background: #FFE4E6; }
-
-.card-icon {
- font-size: 32rpx;
-}
-
-.card-label {
- font-size: 22rpx;
- color: #9CA3AF;
- font-weight: 500;
-}
-
-.card-value {
- font-size: 44rpx;
- font-weight: 800;
- letter-spacing: -1rpx;
-}
-
-.card-purple .card-value { color: #7C3AED; }
-.card-blue .card-value { color: #2563EB; }
-.card-orange .card-value { color: #D97706; }
-.card-rose .card-value { color: #E11D48; }
-
-.card-time {
- font-size: 20rpx;
- color: #D1D5DB;
-}
-
-.card-time {
- font-size: 20rpx;
- color: #D1D5DB;
-}
-
-/* ===== 推广中心 ===== */
-.promo-card {
- background: #FFFFFF;
- border-radius: 32rpx;
- padding: 40rpx;
- box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05);
- border: 1rpx solid rgba(0,0,0,0.04);
-}
-
-.promo-header {
- display: flex;
- align-items: center;
- margin-bottom: 40rpx;
-}
-
-.promo-title-wrap {
- display: flex;
- align-items: center;
- gap: 12rpx;
-}
-
-.promo-icon {
- font-size: 32rpx;
-}
-
-.promo-title {
- font-size: 32rpx;
- font-weight: 700;
- color: #1F2937;
-}
-
-.promo-stats {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.promo-stat-item {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8rpx;
-}
-
-.promo-stat-label {
- font-size: 24rpx;
- color: #9CA3AF;
-}
-
-.promo-stat-value {
- font-size: 36rpx;
- font-weight: 700;
- color: #111827;
- font-family: "DIN Alternate", "Courier New", Courier, monospace;
-}
-
-.promo-stat-divider {
- width: 1rpx;
- height: 60rpx;
- background: #F3F4F6;
-}
-
-.promo-highlight {
- color: #F43F5E;
-}
-
-/* ===== 服务菜单 ===== */
-.menu-card {
- background: #FFFFFF;
- border-radius: 24rpx;
- overflow: hidden;
- box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
- border: 1rpx solid rgba(0,0,0,0.04);
-}
-
-.menu-item {
- display: flex;
- align-items: center;
- padding: 32rpx 36rpx;
- gap: 28rpx;
- position: relative;
-}
-
-.menu-item:active {
- background: #F9FAFB;
-}
-
-.menu-divider {
- height: 1rpx;
- background: #F3F4F6;
- margin: 0 36rpx;
-}
-
-.menu-icon-wrap {
- width: 80rpx;
- height: 80rpx;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-}
-
-.menu-icon-red { background: #FEF2F2; }
-.menu-icon-indigo { background: #EEF2FF; }
-.menu-icon-amber { background: #FFFBEB; }
-.menu-icon-purple { background: #EDE9FE; }
-
-.menu-icon {
- font-size: 36rpx;
-}
-
-.menu-content {
- flex: 1;
- min-width: 0;
-}
-
-.menu-title {
- font-size: 30rpx;
- font-weight: 600;
- color: #111827;
- display: block;
- margin-bottom: 6rpx;
-}
-
-.menu-sub {
- font-size: 24rpx;
- color: #9CA3AF;
- display: block;
-}
-
-.menu-chevron {
- font-size: 48rpx;
- color: #D1D5DB;
- flex-shrink: 0;
-}
-
-/* ===== 退出登录按钮 ===== */
-.logout-btn {
- width: 100%;
- padding: 32rpx;
- background: #FFF1F2;
- border: 1rpx solid #FFE4E6;
- border-radius: 24rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 16rpx;
- box-sizing: border-box;
-}
-
-.logout-btn:active {
- opacity: 0.8;
-}
-
-.logout-icon {
- font-size: 32rpx;
-}
-
-.logout-text {
- font-size: 30rpx;
- font-weight: 600;
- color: #EF4444;
-}
-
-.bottom-safe {
- height: 40rpx;
-}
+/* pages/profile/index.wxss */
+page {
+ background-color: #F9FAFB;
+}
+
+.page {
+ min-height: 100vh;
+ background-color: #F9FAFB;
+ padding-bottom: 140rpx;
+}
+
+/* ===== 顶部栏 ===== */
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16rpx 40rpx 24rpx;
+ background: #F9FAFB;
+}
+
+.topbar-title {
+ font-size: 48rpx;
+ font-weight: 700;
+ color: #111827;
+ letter-spacing: -0.5rpx;
+}
+
+.topbar-actions {
+ display: flex;
+ gap: 12rpx;
+ align-items: center;
+}
+
+.settings-btn {
+ width: 72rpx;
+ height: 72rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.settings-icon {
+ font-size: 44rpx;
+ color: #4B5563;
+}
+
+.icon-btn {
+ width: 72rpx;
+ height: 72rpx;
+ border-radius: 50%;
+ background: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+}
+
+.icon-btn::after {
+ border: none;
+}
+
+.icon-text {
+ font-size: 36rpx;
+}
+
+/* ===== 用户卡片 ===== */
+.user-card {
+ margin: 0 32rpx 28rpx;
+ background: #FFFFFF;
+ border-radius: 28rpx;
+ padding: 36rpx 32rpx;
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+ box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05);
+ position: relative;
+}
+
+.user-card:active {
+ opacity: 0.9;
+}
+
+/* 头像区域(已登录,仅展示) */
+.avatar-wrap {
+ position: relative;
+ width: 144rpx;
+ height: 144rpx;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.avatar-ring {
+ width: 144rpx;
+ height: 144rpx;
+ border-radius: 50%;
+ padding: 4rpx;
+ background: linear-gradient(135deg, #FFDEE9 0%, #B5FFFC 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ box-shadow: inset 0 2rpx 10rpx rgba(0,0,0,0.05);
+ box-sizing: border-box;
+}
+
+.avatar-img {
+ width: 128rpx;
+ height: 128rpx;
+ border-radius: 50%;
+ border: 4rpx solid #FFFFFF;
+ display: block;
+}
+
+.avatar-letter-wrap {
+ width: 128rpx;
+ height: 128rpx;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 4rpx solid #FFFFFF;
+}
+
+.avatar-letter {
+ font-size: 64rpx;
+ font-weight: 700;
+ color: #FFFFFF;
+}
+
+.online-dot {
+ position: absolute;
+ bottom: 8rpx;
+ right: 8rpx;
+ width: 28rpx;
+ height: 28rpx;
+ background: #22C55E;
+ border-radius: 50%;
+ border: 4rpx solid #FFFFFF;
+ z-index: 10;
+ pointer-events: none;
+}
+
+/* 用户信息 */
+.user-meta {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 8rpx;
+ padding-left: 12rpx;
+}
+
+.user-name {
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #111827;
+ display: block;
+}
+
+.user-sub {
+ font-size: 24rpx;
+ color: #9CA3AF;
+}
+
+.nickname-text {
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #111827;
+ line-height: 1.3;
+ display: block;
+}
+
+/* 类型标签 */
+.tags-scroll {
+ width: 100%;
+ white-space: nowrap;
+}
+
+.tags-row {
+ display: flex;
+ gap: 10rpx;
+ flex-wrap: nowrap;
+}
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 6rpx 16rpx;
+ border-radius: 10rpx;
+ flex-shrink: 0;
+}
+
+.tag-text {
+ font-size: 20rpx;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.tag-purple {
+ background: #EDE9FE;
+}
+.tag-purple .tag-text {
+ color: #7C3AED;
+}
+
+.tag-blue {
+ background: #DBEAFE;
+}
+.tag-blue .tag-text {
+ color: #2563EB;
+}
+
+.tag-orange {
+ background: #FEF3C7;
+}
+.tag-orange .tag-text {
+ color: #D97706;
+}
+
+.tag-gray {
+ background: #F3F4F6;
+}
+.tag-gray .tag-text {
+ color: #9CA3AF;
+}
+
+.chevron {
+ font-size: 48rpx;
+ color: #D1D5DB;
+ flex-shrink: 0;
+}
+
+/* ===== Section ===== */
+.section {
+ margin-bottom: 28rpx;
+}
+
+.px-section {
+ padding: 0 32rpx;
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+ margin-bottom: 20rpx;
+}
+
+.section-title {
+ font-size: 32rpx;
+ font-weight: 700;
+ color: #1F2937;
+ display: block;
+ margin-bottom: 20rpx;
+}
+
+.depth-parse-title {
+ letter-spacing: 4rpx;
+ margin-bottom: 16rpx;
+}
+
+.depth-unified-card {
+ background: #FFFFFF;
+ border-radius: 24rpx;
+ overflow: hidden;
+ box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
+ border: 1rpx solid rgba(0,0,0,0.04);
+}
+
+.depth-unified-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 28rpx 32rpx 12rpx;
+}
+
+.depth-unified-subtitle {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: #374151;
+}
+
+.depth-header-link {
+ display: flex;
+ align-items: center;
+ gap: 4rpx;
+}
+
+.depth-header-link:active {
+ opacity: 0.75;
+}
+
+.depth-header-link-text {
+ font-size: 24rpx;
+ color: #F43F5E;
+ font-weight: 500;
+}
+
+.depth-header-chevron {
+ font-size: 28rpx;
+ color: #F43F5E;
+ font-weight: 300;
+}
+
+.depth-empty-hint {
+ padding: 8rpx 32rpx 20rpx;
+}
+
+.depth-empty-hint text {
+ font-size: 24rpx;
+ color: #9CA3AF;
+ line-height: 1.55;
+}
+
+.depth-inner-divider {
+ height: 1rpx;
+ background: #F3F4F6;
+ margin: 8rpx 32rpx 0;
+}
+
+.cards-scroll--in-card {
+ width: 100%;
+}
+
+.cards-row--in-card {
+ padding: 8rpx 24rpx 20rpx;
+}
+
+.menu-item--flat {
+ padding: 28rpx 32rpx;
+}
+
+.menu-divider--in-card {
+ margin: 0 32rpx;
+}
+
+.section-header .section-title {
+ margin-bottom: 0;
+}
+
+.section-link {
+ display: flex;
+ align-items: center;
+}
+
+.section-link-text {
+ font-size: 24rpx;
+ color: #F43F5E;
+ font-weight: 500;
+}
+
+/* ===== 测试结果卡片(横向滚动) ===== */
+.cards-scroll {
+ width: 100%;
+ white-space: nowrap;
+}
+
+.cards-row {
+ display: flex;
+ gap: 24rpx;
+ padding: 8rpx 32rpx 16rpx;
+ width: max-content;
+}
+
+.result-card {
+ width: 280rpx;
+ height: 240rpx;
+ border-radius: 24rpx;
+ padding: 32rpx 28rpx 24rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ position: relative;
+ overflow: hidden;
+ box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+ border: 1rpx solid rgba(0,0,0,0.04);
+ background: #FFFFFF;
+ flex-shrink: 0;
+}
+
+.card-deco {
+ position: absolute;
+ top: -24rpx;
+ right: -24rpx;
+ width: 120rpx;
+ height: 120rpx;
+ border-radius: 50%;
+ opacity: 0.3;
+}
+
+.card-purple .card-deco { background: #DDD6FE; }
+.card-blue .card-deco { background: #BFDBFE; }
+.card-orange .card-deco { background: #FDE68A; }
+.card-rose .card-deco { background: #FECDD3; }
+
+.card-icon-wrap {
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 16rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.card-icon-purple { background: #EDE9FE; }
+.card-icon-blue { background: #DBEAFE; }
+.card-icon-orange { background: #FEF3C7; }
+.card-icon-rose { background: #FFE4E6; }
+
+.card-icon {
+ font-size: 32rpx;
+}
+
+.card-label {
+ font-size: 22rpx;
+ color: #9CA3AF;
+ font-weight: 500;
+}
+
+.card-value {
+ font-size: 44rpx;
+ font-weight: 800;
+ letter-spacing: -1rpx;
+}
+
+.card-purple .card-value { color: #7C3AED; }
+.card-blue .card-value { color: #2563EB; }
+.card-orange .card-value { color: #D97706; }
+.card-rose .card-value { color: #E11D48; }
+
+.card-time {
+ font-size: 20rpx;
+ color: #D1D5DB;
+}
+
+.card-time {
+ font-size: 20rpx;
+ color: #D1D5DB;
+}
+
+/* ===== 推广中心 ===== */
+.promo-card {
+ background: #FFFFFF;
+ border-radius: 32rpx;
+ padding: 40rpx;
+ box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05);
+ border: 1rpx solid rgba(0,0,0,0.04);
+}
+
+.promo-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 40rpx;
+}
+
+.promo-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.promo-icon {
+ font-size: 32rpx;
+}
+
+.promo-title {
+ font-size: 32rpx;
+ font-weight: 700;
+ color: #1F2937;
+}
+
+.promo-stats {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.promo-stat-item {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8rpx;
+}
+
+.promo-stat-label {
+ font-size: 24rpx;
+ color: #9CA3AF;
+}
+
+.promo-stat-value {
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #111827;
+ font-family: "DIN Alternate", "Courier New", Courier, monospace;
+}
+
+.promo-stat-divider {
+ width: 1rpx;
+ height: 60rpx;
+ background: #F3F4F6;
+}
+
+.promo-highlight {
+ color: #F43F5E;
+}
+
+/* ===== 服务菜单 ===== */
+.menu-card {
+ background: #FFFFFF;
+ border-radius: 24rpx;
+ overflow: hidden;
+ box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
+ border: 1rpx solid rgba(0,0,0,0.04);
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ padding: 32rpx 36rpx;
+ gap: 28rpx;
+ position: relative;
+}
+
+.menu-item:active {
+ background: #F9FAFB;
+}
+
+.menu-divider {
+ height: 1rpx;
+ background: #F3F4F6;
+ margin: 0 36rpx;
+}
+
+.menu-icon-wrap {
+ width: 80rpx;
+ height: 80rpx;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.menu-icon-red { background: #FEF2F2; }
+.menu-icon-indigo { background: #EEF2FF; }
+.menu-icon-amber { background: #FFFBEB; }
+.menu-icon-purple { background: #EDE9FE; }
+
+.menu-icon {
+ font-size: 36rpx;
+}
+
+.menu-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.menu-title {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #111827;
+ display: block;
+ margin-bottom: 6rpx;
+}
+
+.menu-sub {
+ font-size: 24rpx;
+ color: #9CA3AF;
+ display: block;
+}
+
+.menu-chevron {
+ font-size: 48rpx;
+ color: #D1D5DB;
+ flex-shrink: 0;
+}
+
+/* ===== 退出登录按钮 ===== */
+.logout-btn {
+ width: 100%;
+ padding: 32rpx;
+ background: #FFF1F2;
+ border: 1rpx solid #FFE4E6;
+ border-radius: 24rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16rpx;
+ box-sizing: border-box;
+}
+
+.logout-btn:active {
+ opacity: 0.8;
+}
+
+.logout-icon {
+ font-size: 32rpx;
+}
+
+.logout-text {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #EF4444;
+}
+
+.bottom-safe {
+ height: 40rpx;
+}
diff --git a/douyin-miniprogram/pages/purchase/index.js b/douyin-miniprogram/pages/purchase/index.js
index 45c3b95..2c1358f 100644
--- a/douyin-miniprogram/pages/purchase/index.js
+++ b/douyin-miniprogram/pages/purchase/index.js
@@ -1,247 +1,251 @@
-// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增)
-const app = getApp()
-const payment = require('../../utils/payment')
-const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
-
-Page({
- data: {
- activeTab: 'personal',
- personalCategories: [],
- enterpriseCategories: [],
- loading: true,
- purchasing: false,
- hasPhone: false,
- successModal: {
- visible: false,
- title: '',
- content: '',
- wechat: ''
- }
- },
-
- onLoad(options) {
- const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal'
- this.setData({ activeTab: tab })
- tt.setNavigationBarTitle({ title: tab === 'enterprise' ? '开通企业版' : '开通个人版' })
- this.loadDeepPricing()
- },
-
- onShow() {
- if (!ensureProfileCompleteAndRedirect()) return
- this.setData({ hasPhone: hasPhone() })
- },
-
- loadDeepPricing() {
- const apiBase = app.globalData.apiBase || ''
- if (!apiBase) {
- this.setData({ loading: false })
- return
- }
- this.setData({ loading: true })
- Promise.all([
- this.requestDeepPricing('personal'),
- this.requestDeepPricing('enterprise')
- ]).then(([personal, enterprise]) => {
- this.setData({
- personalCategories: personal || [],
- enterpriseCategories: enterprise || [],
- loading: false
- })
- }).catch(() => {
- this.setData({ loading: false })
- })
- },
-
- requestDeepPricing(scope) {
- return new Promise((resolve) => {
- tt.request({
- url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`,
- method: 'GET',
- data: { scope },
- success: (res) => {
- if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
- resolve(res.data.data.categories)
- } else {
- resolve([])
- }
- },
- fail: () => resolve([])
- })
- })
- },
-
- switchTab(e) {
- const tab = e.currentTarget.dataset.tab
- this.setData({ activeTab: tab })
- },
-
- // 无需再次授权时,直接点击按钮执行购买/咨询
- handlePurchaseTap(e) {
- const tab = e.currentTarget.dataset.tab
- const index = e.currentTarget.dataset.index
- this.handlePurchase(tab, index)
- },
-
- // 实际执行购买/咨询逻辑(已确保有手机号)
- handlePurchase(tab, index) {
- if (!ensureProfileCompleteAndRedirect()) return
- if (index === undefined || index === null) return
- const list = tab === 'enterprise' ? this.data.enterpriseCategories : this.data.personalCategories
- const category = list[index]
- if (!category) return
-
- if (category.actionType === 'buy' && category.productKey) {
- this.purchasePersonal(category)
- } else {
- this.applyConsult(category)
- }
- },
-
- // 购买/企业咨询按钮:就地触发微信系统手机号授权,然后执行 handlePurchase
- onGetPhoneNumberForPurchase(e) {
- const tab = e.currentTarget.dataset.tab
- const index = e.currentTarget.dataset.index
- const { code, errMsg } = e.detail || {}
-
- if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
- if (!hasPhone()) {
- tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
- return
- }
- // 用户拒绝但之前已授权过,本地已有手机号,则直接继续
- this.handlePurchase(tab, index)
- return
- }
-
- if (!code) {
- if (hasPhone()) {
- this.handlePurchase(tab, index)
- } else {
- tt.showToast({ title: '获取手机号失败', icon: 'none' })
- }
- return
- }
-
- bindPhoneByCode(code)
- .then(() => {
- this.setData({ hasPhone: true })
- this.handlePurchase(tab, index)
- })
- .catch(() => {
- // 失败时只提示,不阻塞后续再次点击
- })
- },
-
- purchasePersonal(category) {
- if (this.data.purchasing) return
- this.setData({ purchasing: true })
- tt.showLoading({ title: '处理中...', mask: true })
- const deepProductId = category.id || category.productKey || ''
- const title = category.title || '个人深度服务(1v1深度解读)'
- payment.purchasePersonalDeepService({
- deepProductId,
- description: title,
- success: () => {
- tt.hideLoading()
- this.setData({ purchasing: false })
- this._reportCrmLead(category, 'buy')
- const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
- const wechat = (category.serviceWechat || '').trim()
- this._showSuccessModal('购买成功', successMsg, wechat)
- },
- fail: () => {
- tt.hideLoading()
- this.setData({ purchasing: false })
- }
- })
- },
-
- applyConsult(category) {
- // serviceWechat 展示给用户,consultWechat 是存客宝 API key
- const wechat = (category.serviceWechat || '').trim()
- const apiKey = (category.consultWechat || '').trim()
- const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
- tt.showLoading({ title: '提交中...', mask: true })
- if (apiKey) {
- this._reportCrmLead(category, 'consult')
- }
- setTimeout(() => {
- tt.hideLoading()
- this._showSuccessModal('申请成功', successMsg, wechat)
- }, 600)
- },
-
- _showSuccessModal(title, content, wechat) {
- this.setData({
- successModal: {
- visible: true,
- title: title || '成功',
- content: content || '',
- wechat: wechat || ''
- }
- })
- },
-
- closeSuccessModal() {
- this.setData({ 'successModal.visible': false })
- },
-
- copyWechat() {
- const wechat = this.data.successModal.wechat
- if (!wechat) return
- tt.setClipboardData({
- data: wechat,
- success: () => tt.showToast({ title: '已复制微信号', icon: 'success' })
- })
- },
-
- /**
- * 向后端上报存客宝线索,后端负责签名和调用存客宝 API
- * @param {Object} category 深度服务类目对象(需含 consultWechat / title)
- * @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
- */
- _reportCrmLead(category, actionType) {
- const apiKey = category.consultWechat || ''
- if (!apiKey) return
- const apiBase = app.globalData.apiBase || ''
- if (!apiBase) return
-
- const isEnterprise = this.data.activeTab === 'enterprise'
- const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
- const remark = actionType === 'buy' ? '完成付款' : '申请咨询'
-
- tt.request({
- url: `${apiBase.replace(/\/$/, '')}/api/crm/report`,
- method: 'POST',
- header: {
- Authorization: `Bearer ${tt.getStorageSync('token') || ''}`,
- 'Content-Type': 'application/json',
- },
- data: {
- apiKey,
- source,
- remark,
- siteTags: category.title || '',
- },
- success(res) {
- console.log('[CRM] 线索上报结果', res.data)
- },
- fail(err) {
- console.warn('[CRM] 线索上报请求失败', err)
- },
- })
- },
-
- onShareAppMessage() {
- const { getSharePath } = require('../../utils/share')
- return { title: '神仙团队性格测试 - 发现你的内在潜能', path: getSharePath('/pages/purchase/index') }
- },
-
- onShareTimeline() {
- const { buildShareQuery } = require('../../utils/share')
- return {
- title: '神仙团队性格测试 - 发现你的内在潜能',
- query: buildShareQuery()
- }
- }
-})
+// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增)
+const app = getApp()
+const payment = require('../../utils/payment')
+const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
+
+Page({
+ data: {
+ activeTab: 'personal',
+ personalCategories: [],
+ enterpriseCategories: [],
+ loading: true,
+ purchasing: false,
+ hasPhone: false,
+ successModal: {
+ visible: false,
+ title: '',
+ content: '',
+ wechat: ''
+ }
+ },
+
+ onLoad(options) {
+ const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal'
+ this.setData({ activeTab: tab })
+ tt.setNavigationBarTitle({ title: '深度服务' })
+ this.loadDeepPricing()
+ },
+
+ onShow() {
+ if (!ensureProfileCompleteAndRedirect()) return
+ this.setData({ hasPhone: hasPhone() })
+ },
+
+ loadDeepPricing() {
+ const apiBase = app.globalData.apiBase || ''
+ if (!apiBase) {
+ this.setData({ loading: false })
+ return
+ }
+ this.setData({ loading: true })
+ Promise.all([
+ this.requestDeepPricing('personal'),
+ this.requestDeepPricing('enterprise')
+ ]).then(([personal, enterprise]) => {
+ this.setData({
+ personalCategories: personal || [],
+ enterpriseCategories: enterprise || [],
+ loading: false
+ })
+ }).catch(() => {
+ this.setData({ loading: false })
+ })
+ },
+
+ requestDeepPricing(scope) {
+ return new Promise((resolve) => {
+ tt.request({
+ url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`,
+ method: 'GET',
+ data: { scope },
+ success: (res) => {
+ if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
+ resolve(res.data.data.categories)
+ } else {
+ resolve([])
+ }
+ },
+ fail: () => resolve([])
+ })
+ })
+ },
+
+ switchTab(e) {
+ const tab = e.currentTarget.dataset.tab
+ if (tab !== 'personal' && tab !== 'enterprise') return
+ this.setData({ activeTab: tab })
+ tt.setNavigationBarTitle({ title: '深度服务' })
+ },
+
+ // 无需再次授权时,直接点击按钮执行购买/咨询
+ handlePurchaseTap(e) {
+ const tab = e.currentTarget.dataset.tab
+ const index = e.currentTarget.dataset.index
+ this.handlePurchase(tab, index)
+ },
+
+ // 实际执行购买/咨询逻辑(已确保有手机号)
+ handlePurchase(tab, index) {
+ if (!ensureProfileCompleteAndRedirect()) return
+ if (index === undefined || index === null) return
+ const list = tab === 'enterprise' ? this.data.enterpriseCategories : this.data.personalCategories
+ const category = list[index]
+ if (!category) return
+
+ if (category.actionType === 'buy' && category.productKey) {
+ this.purchasePersonal(category)
+ } else {
+ this.applyConsult(category)
+ }
+ },
+
+ // 购买/企业咨询按钮:就地触发微信系统手机号授权,然后执行 handlePurchase
+ onGetPhoneNumberForPurchase(e) {
+ const tab = e.currentTarget.dataset.tab
+ const index = e.currentTarget.dataset.index
+ const { code, errMsg } = e.detail || {}
+
+ if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
+ if (!hasPhone()) {
+ tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
+ return
+ }
+ // 用户拒绝但之前已授权过,本地已有手机号,则直接继续
+ this.handlePurchase(tab, index)
+ return
+ }
+
+ if (!code) {
+ if (hasPhone()) {
+ this.handlePurchase(tab, index)
+ } else {
+ tt.showToast({ title: '获取手机号失败', icon: 'none' })
+ }
+ return
+ }
+
+ bindPhoneByCode(code)
+ .then(() => {
+ this.setData({ hasPhone: true })
+ this.handlePurchase(tab, index)
+ })
+ .catch(() => {
+ // 失败时只提示,不阻塞后续再次点击
+ })
+ },
+
+ purchasePersonal(category) {
+ if (this.data.purchasing) return
+ this.setData({ purchasing: true })
+ tt.showLoading({ title: '处理中...', mask: true })
+ const deepProductId = category.id || category.productKey || ''
+ const title = category.title || '个人深度服务(1v1深度解读)'
+ payment.purchasePersonalDeepService({
+ deepProductId,
+ description: title,
+ success: () => {
+ tt.hideLoading()
+ this.setData({ purchasing: false })
+ this._reportCrmLead(category, 'buy')
+ const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
+ const wechat = (category.serviceWechat || '').trim()
+ this._showSuccessModal('购买成功', successMsg, wechat)
+ },
+ fail: () => {
+ tt.hideLoading()
+ this.setData({ purchasing: false })
+ }
+ })
+ },
+
+ applyConsult(category) {
+ // serviceWechat 展示给用户,consultWechat 是存客宝 API key
+ const wechat = (category.serviceWechat || '').trim()
+ const apiKey = (category.consultWechat || '').trim()
+ const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
+ tt.showLoading({ title: '提交中...', mask: true })
+ if (apiKey) {
+ this._reportCrmLead(category, 'consult')
+ }
+ setTimeout(() => {
+ tt.hideLoading()
+ this._showSuccessModal('申请成功', successMsg, wechat)
+ }, 600)
+ },
+
+ _showSuccessModal(title, content, wechat) {
+ this.setData({
+ successModal: {
+ visible: true,
+ title: title || '成功',
+ content: content || '',
+ wechat: wechat || ''
+ }
+ })
+ },
+
+ catchTap() {},
+
+ closeSuccessModal() {
+ this.setData({ 'successModal.visible': false })
+ },
+
+ copyWechat() {
+ const wechat = this.data.successModal.wechat
+ if (!wechat) return
+ tt.setClipboardData({
+ data: wechat,
+ success: () => tt.showToast({ title: '已复制微信号', icon: 'success' })
+ })
+ },
+
+ /**
+ * 向后端上报存客宝线索,后端负责签名和调用存客宝 API
+ * @param {Object} category 深度服务类目对象(需含 consultWechat / title)
+ * @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
+ */
+ _reportCrmLead(category, actionType) {
+ const apiKey = category.consultWechat || ''
+ if (!apiKey) return
+ const apiBase = app.globalData.apiBase || ''
+ if (!apiBase) return
+
+ const isEnterprise = this.data.activeTab === 'enterprise'
+ const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
+ const remark = actionType === 'buy' ? '完成付款' : '申请咨询'
+
+ tt.request({
+ url: `${apiBase.replace(/\/$/, '')}/api/crm/report`,
+ method: 'POST',
+ header: {
+ Authorization: `Bearer ${tt.getStorageSync('token') || ''}`,
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ apiKey,
+ source,
+ remark,
+ siteTags: category.title || '',
+ },
+ success(res) {
+ console.log('[CRM] 线索上报结果', res.data)
+ },
+ fail(err) {
+ console.warn('[CRM] 线索上报请求失败', err)
+ },
+ })
+ },
+
+ onShareAppMessage() {
+ const { getSharePath } = require('../../utils/share')
+ return { title: '神仙团队性格测试 - 发现你的内在潜能', path: getSharePath('/pages/purchase/index') }
+ },
+
+ onShareTimeline() {
+ const { buildShareQuery } = require('../../utils/share')
+ return {
+ title: '神仙团队性格测试 - 发现你的内在潜能',
+ query: buildShareQuery()
+ }
+ }
+})
diff --git a/douyin-miniprogram/pages/purchase/index.ttml b/douyin-miniprogram/pages/purchase/index.ttml
index d1296e4..0bdf06a 100644
--- a/douyin-miniprogram/pages/purchase/index.ttml
+++ b/douyin-miniprogram/pages/purchase/index.ttml
@@ -1,135 +1,146 @@
-
-
-
-
-
-
-
- ✓
-
-
- {{successModal.title}}
- {{successModal.content}}
-
- 客服微信
- {{successModal.wechat}}
-
-
-
-
-
-
-
-
-
-
-
- 加载中...
-
-
-
-
-
-
-
-
- ✓
- {{f}}
-
-
-
-
-
-
-
-
- 暂无可用的个人版套餐
-
-
-
-
-
-
-
-
-
- ✓
- {{f}}
-
-
-
-
-
-
-
-
- 暂无可用的企业版套餐
-
-
-
-
-
- 🔒
- 微信安全支付
-
-
- ✅
- 即时到账开通
-
-
- 📞
- 7×24客服
-
-
-
+
+
+
+
+
+
+
+ ✓
+
+
+ {{successModal.title}}
+ {{successModal.content}}
+
+ 客服微信
+ {{successModal.wechat}}
+
+
+
+
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+ 先选服务类型,查看说明与权益;具体安排可在下一步与顾问沟通确认。
+
+
+
+
+
+ 个人解读
+
+
+ 团队与企业
+
+
+
+
+
+
+
+
+
+ ✓
+ {{f}}
+
+
+
+
+
+
+ 暂无可用的个人版套餐
+
+
+
+
+
+
+
+
+ ✓
+ {{f}}
+
+
+
+
+
+
+ 暂无可用的企业版套餐
+
+
+
+
+
+ 🔒
+ 流程在平台内完成
+
+
+ ✅
+ 顾问跟进确认
+
+
+ 📞
+ 支持咨询与售后
+
+
+
+
diff --git a/douyin-miniprogram/pages/purchase/index.ttss b/douyin-miniprogram/pages/purchase/index.ttss
index e6cbdd2..eab0513 100644
--- a/douyin-miniprogram/pages/purchase/index.ttss
+++ b/douyin-miniprogram/pages/purchase/index.ttss
@@ -1,789 +1,807 @@
-/* pages/purchase/index.wxss - 按旧版模板重构 */
-
-/* ── 成功弹窗 ── */
-.success-mask {
- position: fixed;
- top: 0; left: 0; right: 0; bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 999;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.success-dialog {
- width: 600rpx;
- background: #fff;
- border-radius: 24rpx;
- padding: 56rpx 40rpx 40rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
-.success-icon-wrap {
- margin-bottom: 32rpx;
-}
-
-.success-icon-circle {
- width: 120rpx;
- height: 120rpx;
- border-radius: 50%;
- background: linear-gradient(135deg, #22c55e, #16a34a);
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.35);
-}
-
-.success-icon-check {
- font-size: 56rpx;
- color: #fff;
- font-weight: bold;
- line-height: 1;
-}
-
-.success-dialog-title {
- font-size: 40rpx;
- font-weight: bold;
- color: #1a1a1a;
- margin-bottom: 20rpx;
-}
-
-.success-dialog-content {
- font-size: 28rpx;
- color: #555;
- text-align: center;
- line-height: 1.7;
- margin-bottom: 32rpx;
-}
-
-.success-wechat-wrap {
- width: 100%;
- background: #f5f5f5;
- border-radius: 12rpx;
- padding: 20rpx 24rpx;
- margin-bottom: 32rpx;
- display: flex;
- align-items: center;
- gap: 16rpx;
-}
-
-.success-wechat-label {
- font-size: 24rpx;
- color: #999;
- flex-shrink: 0;
-}
-
-.success-wechat-val {
- font-size: 28rpx;
- color: #333;
- font-weight: 500;
- flex: 1;
- word-break: break-all;
-}
-
-.success-dialog-btns {
- width: 100%;
- display: flex;
- flex-direction: row;
- gap: 16rpx;
-}
-
-.success-btn-copy {
- flex: 1;
- background: #2563eb;
- color: #fff;
- font-size: 28rpx;
- font-weight: 500;
- padding: 24rpx 0;
- border-radius: 12rpx;
- border: none;
-}
-
-.success-btn-close {
- flex: 1;
- background: #f5f5f5;
- color: #333;
- font-size: 28rpx;
- padding: 24rpx 0;
- border-radius: 12rpx;
- border: none;
-}
-
-
-.container {
- width: 100vw;
- min-height: 100vh;
- background-color: #f5f5f5;
- padding-bottom: 160rpx;
-}
-
-/* 用户权益状态 */
-.status-card {
- display: flex;
- align-items: center;
- padding: 40rpx;
- margin: 24rpx;
- background: #fff;
- border-radius: 16rpx;
- box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
-}
-
-.status-card.vip {
- background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
- border: 2rpx solid #9333ea;
-}
-
-.status-icon {
- font-size: 48rpx;
- margin-right: 20rpx;
-}
-
-.status-info {
- flex: 1;
-}
-
-.status-title {
- display: block;
- font-size: 32rpx;
- font-weight: bold;
- color: #333;
-}
-
-.status-desc {
- display: block;
- font-size: 24rpx;
- color: #666;
- margin-top: 4rpx;
-}
-
-.test-count {
- text-align: center;
-}
-
-.count-num {
- display: block;
- font-size: 40rpx;
- font-weight: bold;
- color: #9333ea;
-}
-
-.count-label {
- display: block;
- font-size: 22rpx;
- color: #999;
-}
-
-/* 区块 */
-.section {
- margin: 24rpx;
-}
-
-.section-title {
- display: block;
- font-size: 34rpx;
- font-weight: bold;
- color: #333;
- margin-bottom: 8rpx;
-}
-
-.section-subtitle {
- display: block;
- font-size: 24rpx;
- color: #999;
- margin-bottom: 24rpx;
-}
-
-/* Tabs 区域 */
-.tabs-container {
- background-color: #fff;
- padding: 24rpx 32rpx;
- margin: 24rpx;
- border-radius: 16rpx;
-}
-
-.tabs-list {
- display: flex;
- background-color: #f5f5f5;
- border-radius: 12rpx;
- padding: 8rpx;
-}
-
-.tab-item {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16rpx 24rpx;
- border-radius: 8rpx;
- transition: all 0.3s;
-}
-
-.tab-item.active {
- background-color: #fff;
-}
-
-.tab-icon {
- font-size: 32rpx;
- margin-right: 8rpx;
-}
-
-.tab-text {
- font-size: 28rpx;
- color: #666;
-}
-
-.tab-item.active .tab-text {
- color: #333;
- font-weight: 500;
-}
-
-.loading-wrap {
- padding: 80rpx;
- text-align: center;
-}
-
-.loading-text {
- font-size: 28rpx;
- color: #999;
-}
-
-.empty-tip {
- padding: 60rpx 24rpx;
- text-align: center;
- font-size: 28rpx;
- color: #999;
-}
-
-/* 定价卡片(已去除个人版/企业版彩色边框,统一灰色细边) */
-.pricing-card {
- background-color: #fff;
- border-radius: 16rpx;
- padding: 40rpx;
- margin-bottom: 24rpx;
- border: 2rpx solid #e5e5e5;
-}
-
-.pricing-card.featured,
-.pricing-card.featured-blue {
- border-color: #e5e5e5;
- border-width: 2rpx;
-}
-
-/* 企业版 header flex 布局 */
-.card-header-flex {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 32rpx;
-}
-
-.header-left {
- flex: 1;
-}
-
-.plan-title-sm {
- font-size: 36rpx;
- font-weight: bold;
- color: #333;
- display: block;
- margin-bottom: 8rpx;
-}
-
-.plan-desc {
- font-size: 24rpx;
- color: #666;
- display: block;
-}
-
-.header-right {
- text-align: right;
-}
-
-.price-amount-small {
- font-size: 40rpx;
- font-weight: bold;
- color: #333;
- display: block;
- margin-bottom: 4rpx;
-}
-
-.price-limit {
- font-size: 22rpx;
- color: #999;
- display: block;
-}
-
-.check-icon.blue {
- background-color: #2563eb;
-}
-
-.purchase-button.secondary {
- background-color: #2563eb;
-}
-
-.card-header {
- text-align: center;
- margin-bottom: 32rpx;
-}
-
-.plan-title {
- font-size: 40rpx;
- font-weight: bold;
- color: #333;
- display: block;
- margin-bottom: 20rpx;
-}
-
-.price-section {
- display: flex;
- align-items: baseline;
- justify-content: center;
- margin-bottom: 16rpx;
-}
-
-.price-symbol {
- font-size: 40rpx;
- color: #333;
- font-weight: bold;
-}
-
-.price-amount {
- font-size: 72rpx;
- font-weight: bold;
- color: #333;
- margin: 0 8rpx;
-}
-
-.price-unit {
- font-size: 28rpx;
- color: #666;
-}
-
-.plan-subtitle {
- font-size: 26rpx;
- color: #666;
- display: block;
-}
-
-/* 功能列表 */
-.features-list {
- margin-bottom: 32rpx;
-}
-
-.feature-item {
- display: flex;
- align-items: flex-start;
- margin-bottom: 20rpx;
-}
-
-.feature-item:last-child {
- margin-bottom: 0;
-}
-
-.check-icon {
- width: 32rpx;
- height: 32rpx;
- border-radius: 50%;
- background-color: #22c55e;
- color: #fff;
- font-size: 20rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 16rpx;
- flex-shrink: 0;
- text-align: center;
-}
-
-.feature-text {
- flex: 1;
- font-size: 26rpx;
- color: #666;
- line-height: 1.6;
-}
-
-/* 购买按钮 */
-.purchase-button {
- width: 100%;
- padding: 28rpx;
- border-radius: 12rpx;
- text-align: center;
- box-sizing: border-box;
-}
-
-.purchase-button.primary {
- background-color: #9333ea;
-}
-
-.purchase-button .button-text {
- font-size: 32rpx;
- color: #fff;
- font-weight: 500;
-}
-
-/* 分润机制 */
-.commission-section {
- background-color: #f9fafb;
- border-radius: 16rpx;
- padding: 32rpx;
- margin: 24rpx;
-}
-
-.commission-header {
- display: flex;
- align-items: center;
- margin-bottom: 24rpx;
-}
-
-.commission-icon {
- font-size: 32rpx;
- margin-right: 12rpx;
-}
-
-.commission-title {
- font-size: 32rpx;
- font-weight: 500;
- color: #333;
-}
-
-.commission-list {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.commission-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.commission-label {
- font-size: 26rpx;
- color: #666;
-}
-
-.commission-value {
- font-size: 28rpx;
- font-weight: 500;
- color: #333;
-}
-
-/* VIP套餐网格 */
-.vip-cards {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 16rpx;
- margin-bottom: 32rpx;
-}
-
-.vip-card {
- position: relative;
- background: #fff;
- border: 2rpx solid #e5e5e5;
- border-radius: 16rpx;
- padding: 24rpx;
- text-align: center;
-}
-
-.vip-card.selected {
- border-color: #9333ea;
- background: #faf5ff;
-}
-
-.vip-card.hot {
- border-color: #f59e0b;
-}
-
-.vip-tag {
- position: absolute;
- top: 8rpx;
- right: 8rpx;
- background: #9333ea;
- color: #fff;
- font-size: 18rpx;
- padding: 4rpx 12rpx;
- border-radius: 8rpx;
-}
-
-.vip-hot-tag,
-.vip-best-tag {
- position: absolute;
- top: -2rpx;
- left: 50%;
- transform: translateX(-50%);
- background: #f59e0b;
- color: #fff;
- font-size: 18rpx;
- padding: 4rpx 16rpx;
- border-radius: 0 0 8rpx 8rpx;
-}
-
-.vip-best-tag {
- background: #ef4444;
-}
-
-.vip-name {
- display: block;
- font-size: 26rpx;
- color: #333;
- font-weight: 500;
- margin-top: 16rpx;
- margin-bottom: 12rpx;
-}
-
-.vip-price {
- display: flex;
- align-items: baseline;
- justify-content: center;
- margin-bottom: 8rpx;
-}
-
-.vip-price .price-symbol {
- font-size: 28rpx;
- color: #333;
-}
-
-.vip-price .price-num {
- font-size: 48rpx;
- font-weight: bold;
- color: #333;
-}
-
-.vip-original {
- display: block;
- font-size: 22rpx;
- color: #999;
- text-decoration: line-through;
-}
-
-.vip-avg {
- display: block;
- font-size: 22rpx;
- color: #9333ea;
- margin-top: 4rpx;
-}
-
-/* 次数包 */
-.count-cards {
- display: flex;
- gap: 16rpx;
-}
-
-.count-card {
- flex: 1;
- position: relative;
- background: #fff;
- border: 2rpx solid #e5e5e5;
- border-radius: 16rpx;
- padding: 24rpx 16rpx;
- text-align: center;
-}
-
-.count-card.selected {
- border-color: #9333ea;
- background: #faf5ff;
-}
-
-.count-save {
- position: absolute;
- top: -2rpx;
- right: -2rpx;
- background: #ef4444;
- color: #fff;
- font-size: 18rpx;
- padding: 4rpx 12rpx;
- border-radius: 0 8rpx 0 8rpx;
-}
-
-.count-num-big {
- font-size: 48rpx;
- font-weight: bold;
- color: #333;
-}
-
-.count-unit {
- font-size: 24rpx;
- color: #999;
- display: block;
- margin-bottom: 12rpx;
-}
-
-.count-price {
- display: flex;
- align-items: baseline;
- justify-content: center;
-}
-
-.count-price .price-symbol {
- font-size: 24rpx;
- color: #333;
-}
-
-.count-price .price-num {
- font-size: 36rpx;
- font-weight: bold;
- color: #333;
-}
-
-/* 单次购买 */
-.single-list {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.single-item {
- display: flex;
- align-items: center;
- background: #fff;
- border-radius: 16rpx;
- padding: 24rpx;
- border: 2rpx solid #e5e5e5;
-}
-
-.single-icon {
- font-size: 36rpx;
- margin-right: 16rpx;
-}
-
-.single-info {
- flex: 1;
-}
-
-.single-name {
- display: block;
- font-size: 28rpx;
- font-weight: 500;
- color: #333;
-}
-
-.single-desc {
- display: block;
- font-size: 22rpx;
- color: #999;
- margin-top: 4rpx;
-}
-
-.single-price {
- text-align: center;
-}
-
-.single-price .price-num {
- display: block;
- font-size: 28rpx;
- font-weight: bold;
- color: #9333ea;
-}
-
-.price-action {
- display: block;
- font-size: 22rpx;
- color: #9333ea;
-}
-
-/* 底部安全提示 */
-.safety-tips {
- display: flex;
- justify-content: center;
- gap: 32rpx;
- padding: 24rpx;
- margin: 24rpx;
-}
-
-.safety-item {
- display: flex;
- align-items: center;
- gap: 8rpx;
-}
-
-.safety-icon {
- font-size: 24rpx;
-}
-
-.safety-text {
- font-size: 22rpx;
- color: #999;
-}
-
-.privileges {
- background: #fff;
- border-radius: 16rpx;
- padding: 32rpx;
-}
-
-.privilege-list {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.privilege-item {
- display: flex;
- align-items: center;
- gap: 12rpx;
-}
-
-.privilege-icon {
- font-size: 28rpx;
-}
-
-.privilege-text {
- font-size: 26rpx;
- color: #333;
-}
-
-.bottom-placeholder {
- height: 160rpx;
-}
-
-/* 底部购买栏 */
-.bottom-bar {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- background: #fff;
- display: flex;
- align-items: center;
- padding: 24rpx 32rpx;
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
- z-index: 100;
-}
-
-.price-info {
- flex: 1;
- display: flex;
- align-items: baseline;
-}
-
-.total-label {
- font-size: 28rpx;
- color: #666;
-}
-
-.total-price {
- font-size: 40rpx;
- font-weight: bold;
- color: #e63946;
-}
-
-.buy-btn {
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- padding: 24rpx 48rpx;
- border-radius: 48rpx;
- box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
-}
-
-.buy-btn.disabled {
- opacity: 0.6;
-}
-
-.buy-btn-text {
- font-size: 30rpx;
- font-weight: 600;
- color: #fff;
-}
+/* pages/purchase/index.wxss - 按旧版模板重构 */
+
+/* ── 成功弹窗 ── */
+.success-mask {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.success-dialog {
+ width: 600rpx;
+ background: #fff;
+ border-radius: 24rpx;
+ padding: 56rpx 40rpx 40rpx;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.success-icon-wrap {
+ margin-bottom: 32rpx;
+}
+
+.success-icon-circle {
+ width: 120rpx;
+ height: 120rpx;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #22c55e, #16a34a);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.35);
+}
+
+.success-icon-check {
+ font-size: 56rpx;
+ color: #fff;
+ font-weight: bold;
+ line-height: 1;
+}
+
+.success-dialog-title {
+ font-size: 40rpx;
+ font-weight: bold;
+ color: #1a1a1a;
+ margin-bottom: 20rpx;
+}
+
+.success-dialog-content {
+ font-size: 28rpx;
+ color: #555;
+ text-align: center;
+ line-height: 1.7;
+ margin-bottom: 32rpx;
+}
+
+.success-wechat-wrap {
+ width: 100%;
+ background: #f5f5f5;
+ border-radius: 12rpx;
+ padding: 20rpx 24rpx;
+ margin-bottom: 32rpx;
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.success-wechat-label {
+ font-size: 24rpx;
+ color: #999;
+ flex-shrink: 0;
+}
+
+.success-wechat-val {
+ font-size: 28rpx;
+ color: #333;
+ font-weight: 500;
+ flex: 1;
+ word-break: break-all;
+}
+
+.success-dialog-btns {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ gap: 16rpx;
+}
+
+.success-btn-copy {
+ flex: 1;
+ background: #2563eb;
+ color: #fff;
+ font-size: 28rpx;
+ font-weight: 500;
+ padding: 24rpx 0;
+ border-radius: 12rpx;
+ border: none;
+}
+
+.success-btn-close {
+ flex: 1;
+ background: #f5f5f5;
+ color: #333;
+ font-size: 28rpx;
+ padding: 24rpx 0;
+ border-radius: 12rpx;
+ border: none;
+}
+
+
+.container {
+ width: 100vw;
+ min-height: 100vh;
+ background-color: #f5f5f5;
+ padding-bottom: 160rpx;
+}
+
+.deep-intro {
+ margin: 24rpx 24rpx 0;
+ padding: 24rpx 28rpx;
+ background: #fff;
+ border-radius: 16rpx;
+ border: 1rpx solid rgba(0, 0, 0, 0.04);
+}
+
+.deep-intro-text {
+ font-size: 26rpx;
+ color: #6b7280;
+ line-height: 1.65;
+}
+
+.deep-tabs.tabs-container {
+ margin-top: 20rpx;
+}
+
+/* 用户权益状态 */
+.status-card {
+ display: flex;
+ align-items: center;
+ padding: 40rpx;
+ margin: 24rpx;
+ background: #fff;
+ border-radius: 16rpx;
+ box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.status-card.vip {
+ background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
+ border: 2rpx solid #9333ea;
+}
+
+.status-icon {
+ font-size: 48rpx;
+ margin-right: 20rpx;
+}
+
+.status-info {
+ flex: 1;
+}
+
+.status-title {
+ display: block;
+ font-size: 32rpx;
+ font-weight: bold;
+ color: #333;
+}
+
+.status-desc {
+ display: block;
+ font-size: 24rpx;
+ color: #666;
+ margin-top: 4rpx;
+}
+
+.test-count {
+ text-align: center;
+}
+
+.count-num {
+ display: block;
+ font-size: 40rpx;
+ font-weight: bold;
+ color: #9333ea;
+}
+
+.count-label {
+ display: block;
+ font-size: 22rpx;
+ color: #999;
+}
+
+/* 区块 */
+.section {
+ margin: 24rpx;
+}
+
+.section-title {
+ display: block;
+ font-size: 34rpx;
+ font-weight: bold;
+ color: #333;
+ margin-bottom: 8rpx;
+}
+
+.section-subtitle {
+ display: block;
+ font-size: 24rpx;
+ color: #999;
+ margin-bottom: 24rpx;
+}
+
+/* Tabs 区域 */
+.tabs-container {
+ background-color: #fff;
+ padding: 24rpx 32rpx;
+ margin: 24rpx;
+ border-radius: 16rpx;
+}
+
+.tabs-list {
+ display: flex;
+ background-color: #f5f5f5;
+ border-radius: 12rpx;
+ padding: 8rpx;
+}
+
+.tab-item {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16rpx 24rpx;
+ border-radius: 8rpx;
+ transition: all 0.3s;
+}
+
+.tab-item.active {
+ background-color: #fff;
+}
+
+.tab-icon {
+ font-size: 32rpx;
+ margin-right: 8rpx;
+}
+
+.tab-text {
+ font-size: 28rpx;
+ color: #666;
+}
+
+.tab-item.active .tab-text {
+ color: #333;
+ font-weight: 500;
+}
+
+.loading-wrap {
+ padding: 80rpx;
+ text-align: center;
+}
+
+.loading-text {
+ font-size: 28rpx;
+ color: #999;
+}
+
+.empty-tip {
+ padding: 60rpx 24rpx;
+ text-align: center;
+ font-size: 28rpx;
+ color: #999;
+}
+
+/* 定价卡片(已去除个人版/企业版彩色边框,统一灰色细边) */
+.pricing-card {
+ background-color: #fff;
+ border-radius: 16rpx;
+ padding: 40rpx;
+ margin-bottom: 24rpx;
+ border: 2rpx solid #e5e5e5;
+}
+
+.pricing-card.featured,
+.pricing-card.featured-blue {
+ border-color: #e5e5e5;
+ border-width: 2rpx;
+}
+
+/* 企业版 header flex 布局 */
+.card-header-flex {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 32rpx;
+}
+
+.header-left {
+ flex: 1;
+}
+
+.plan-title-sm {
+ font-size: 36rpx;
+ font-weight: bold;
+ color: #333;
+ display: block;
+ margin-bottom: 8rpx;
+}
+
+.plan-desc {
+ font-size: 24rpx;
+ color: #666;
+ display: block;
+}
+
+.header-right {
+ text-align: right;
+}
+
+.price-amount-small {
+ font-size: 40rpx;
+ font-weight: bold;
+ color: #333;
+ display: block;
+ margin-bottom: 4rpx;
+}
+
+.price-limit {
+ font-size: 22rpx;
+ color: #999;
+ display: block;
+}
+
+.check-icon.blue {
+ background-color: #2563eb;
+}
+
+.purchase-button.secondary {
+ background-color: #2563eb;
+}
+
+.card-header {
+ text-align: center;
+ margin-bottom: 32rpx;
+}
+
+.plan-title {
+ font-size: 40rpx;
+ font-weight: bold;
+ color: #333;
+ display: block;
+ margin-bottom: 20rpx;
+}
+
+.price-section {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ margin-bottom: 16rpx;
+}
+
+.price-symbol {
+ font-size: 40rpx;
+ color: #333;
+ font-weight: bold;
+}
+
+.price-amount {
+ font-size: 72rpx;
+ font-weight: bold;
+ color: #333;
+ margin: 0 8rpx;
+}
+
+.price-unit {
+ font-size: 28rpx;
+ color: #666;
+}
+
+.plan-subtitle {
+ font-size: 26rpx;
+ color: #666;
+ display: block;
+}
+
+/* 功能列表 */
+.features-list {
+ margin-bottom: 32rpx;
+}
+
+.feature-item {
+ display: flex;
+ align-items: flex-start;
+ margin-bottom: 20rpx;
+}
+
+.feature-item:last-child {
+ margin-bottom: 0;
+}
+
+.check-icon {
+ width: 32rpx;
+ height: 32rpx;
+ border-radius: 50%;
+ background-color: #22c55e;
+ color: #fff;
+ font-size: 20rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 16rpx;
+ flex-shrink: 0;
+ text-align: center;
+}
+
+.feature-text {
+ flex: 1;
+ font-size: 26rpx;
+ color: #666;
+ line-height: 1.6;
+}
+
+/* 购买按钮 */
+.purchase-button {
+ width: 100%;
+ padding: 28rpx;
+ border-radius: 12rpx;
+ text-align: center;
+ box-sizing: border-box;
+}
+
+.purchase-button.primary {
+ background-color: #9333ea;
+}
+
+.purchase-button .button-text {
+ font-size: 32rpx;
+ color: #fff;
+ font-weight: 500;
+}
+
+/* 分润机制 */
+.commission-section {
+ background-color: #f9fafb;
+ border-radius: 16rpx;
+ padding: 32rpx;
+ margin: 24rpx;
+}
+
+.commission-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 24rpx;
+}
+
+.commission-icon {
+ font-size: 32rpx;
+ margin-right: 12rpx;
+}
+
+.commission-title {
+ font-size: 32rpx;
+ font-weight: 500;
+ color: #333;
+}
+
+.commission-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.commission-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.commission-label {
+ font-size: 26rpx;
+ color: #666;
+}
+
+.commission-value {
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #333;
+}
+
+/* VIP套餐网格 */
+.vip-cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 16rpx;
+ margin-bottom: 32rpx;
+}
+
+.vip-card {
+ position: relative;
+ background: #fff;
+ border: 2rpx solid #e5e5e5;
+ border-radius: 16rpx;
+ padding: 24rpx;
+ text-align: center;
+}
+
+.vip-card.selected {
+ border-color: #9333ea;
+ background: #faf5ff;
+}
+
+.vip-card.hot {
+ border-color: #f59e0b;
+}
+
+.vip-tag {
+ position: absolute;
+ top: 8rpx;
+ right: 8rpx;
+ background: #9333ea;
+ color: #fff;
+ font-size: 18rpx;
+ padding: 4rpx 12rpx;
+ border-radius: 8rpx;
+}
+
+.vip-hot-tag,
+.vip-best-tag {
+ position: absolute;
+ top: -2rpx;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #f59e0b;
+ color: #fff;
+ font-size: 18rpx;
+ padding: 4rpx 16rpx;
+ border-radius: 0 0 8rpx 8rpx;
+}
+
+.vip-best-tag {
+ background: #ef4444;
+}
+
+.vip-name {
+ display: block;
+ font-size: 26rpx;
+ color: #333;
+ font-weight: 500;
+ margin-top: 16rpx;
+ margin-bottom: 12rpx;
+}
+
+.vip-price {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ margin-bottom: 8rpx;
+}
+
+.vip-price .price-symbol {
+ font-size: 28rpx;
+ color: #333;
+}
+
+.vip-price .price-num {
+ font-size: 48rpx;
+ font-weight: bold;
+ color: #333;
+}
+
+.vip-original {
+ display: block;
+ font-size: 22rpx;
+ color: #999;
+ text-decoration: line-through;
+}
+
+.vip-avg {
+ display: block;
+ font-size: 22rpx;
+ color: #9333ea;
+ margin-top: 4rpx;
+}
+
+/* 次数包 */
+.count-cards {
+ display: flex;
+ gap: 16rpx;
+}
+
+.count-card {
+ flex: 1;
+ position: relative;
+ background: #fff;
+ border: 2rpx solid #e5e5e5;
+ border-radius: 16rpx;
+ padding: 24rpx 16rpx;
+ text-align: center;
+}
+
+.count-card.selected {
+ border-color: #9333ea;
+ background: #faf5ff;
+}
+
+.count-save {
+ position: absolute;
+ top: -2rpx;
+ right: -2rpx;
+ background: #ef4444;
+ color: #fff;
+ font-size: 18rpx;
+ padding: 4rpx 12rpx;
+ border-radius: 0 8rpx 0 8rpx;
+}
+
+.count-num-big {
+ font-size: 48rpx;
+ font-weight: bold;
+ color: #333;
+}
+
+.count-unit {
+ font-size: 24rpx;
+ color: #999;
+ display: block;
+ margin-bottom: 12rpx;
+}
+
+.count-price {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+}
+
+.count-price .price-symbol {
+ font-size: 24rpx;
+ color: #333;
+}
+
+.count-price .price-num {
+ font-size: 36rpx;
+ font-weight: bold;
+ color: #333;
+}
+
+/* 单次购买 */
+.single-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.single-item {
+ display: flex;
+ align-items: center;
+ background: #fff;
+ border-radius: 16rpx;
+ padding: 24rpx;
+ border: 2rpx solid #e5e5e5;
+}
+
+.single-icon {
+ font-size: 36rpx;
+ margin-right: 16rpx;
+}
+
+.single-info {
+ flex: 1;
+}
+
+.single-name {
+ display: block;
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #333;
+}
+
+.single-desc {
+ display: block;
+ font-size: 22rpx;
+ color: #999;
+ margin-top: 4rpx;
+}
+
+.single-price {
+ text-align: center;
+}
+
+.single-price .price-num {
+ display: block;
+ font-size: 28rpx;
+ font-weight: bold;
+ color: #9333ea;
+}
+
+.price-action {
+ display: block;
+ font-size: 22rpx;
+ color: #9333ea;
+}
+
+/* 底部安全提示 */
+.safety-tips {
+ display: flex;
+ justify-content: center;
+ gap: 32rpx;
+ padding: 24rpx;
+ margin: 24rpx;
+}
+
+.safety-item {
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+}
+
+.safety-icon {
+ font-size: 24rpx;
+}
+
+.safety-text {
+ font-size: 22rpx;
+ color: #999;
+}
+
+.privileges {
+ background: #fff;
+ border-radius: 16rpx;
+ padding: 32rpx;
+}
+
+.privilege-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.privilege-item {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.privilege-icon {
+ font-size: 28rpx;
+}
+
+.privilege-text {
+ font-size: 26rpx;
+ color: #333;
+}
+
+.bottom-placeholder {
+ height: 160rpx;
+}
+
+/* 底部购买栏 */
+.bottom-bar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ padding: 24rpx 32rpx;
+ padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+ box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
+ z-index: 100;
+}
+
+.price-info {
+ flex: 1;
+ display: flex;
+ align-items: baseline;
+}
+
+.total-label {
+ font-size: 28rpx;
+ color: #666;
+}
+
+.total-price {
+ font-size: 40rpx;
+ font-weight: bold;
+ color: #e63946;
+}
+
+.buy-btn {
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ padding: 24rpx 48rpx;
+ border-radius: 48rpx;
+ box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
+}
+
+.buy-btn.disabled {
+ opacity: 0.6;
+}
+
+.buy-btn-text {
+ font-size: 30rpx;
+ font-weight: 600;
+ color: #fff;
+}
diff --git a/douyin-miniprogram/pages/recharge/index.js b/douyin-miniprogram/pages/recharge/index.js
index 95a3e28..663425d 100644
--- a/douyin-miniprogram/pages/recharge/index.js
+++ b/douyin-miniprogram/pages/recharge/index.js
@@ -1,83 +1,97 @@
-const app = getApp()
-const payment = require('../../utils/payment')
-const { request } = require('../../utils/request')
-
-Page({
- data: {
- enterpriseId: 0,
- enterpriseName: '',
- amountFen: 0,
- amountYuan: '0.00',
- paying: false
- },
-
- onLoad(options) {
- const rawScene = options && options.scene ? decodeURIComponent(options.scene) : ''
- const sceneParams = {}
- if (rawScene) {
- rawScene.split('&').forEach(pair => {
- const [k, v] = pair.split('=')
- if (k) sceneParams[k] = v || ''
- })
- }
-
- const enterpriseId = parseInt(sceneParams.eid || options.eid || 0, 10) || 0
- const amountFen = parseInt(sceneParams.a || options.amountFen || 0, 10) || 0
- const amountYuan = (amountFen / 100).toFixed(2)
-
- if (enterpriseId > 0) {
- app.globalData.enterpriseIdFromScene = enterpriseId
- }
-
- this.setData({
- enterpriseId,
- enterpriseName: (app.globalData.userInfo && app.globalData.userInfo.enterpriseName) || '',
- amountFen,
- amountYuan
- })
-
- app.ensureLogin()
- .then(() => {
- if (enterpriseId > 0) {
- request({
- url: '/api/enterprise/bind',
- method: 'POST',
- data: { enterpriseId },
- success: (res) => {
- const payload = res && res.data && res.data.data ? res.data.data : {}
- const enterpriseName = payload.enterpriseName || this.data.enterpriseName || ''
- this.setData({ enterpriseName })
- },
- fail: () => {}
- })
- }
- })
- .catch(() => {
- tt.showToast({ title: '请先登录', icon: 'none' })
- })
- },
-
- submitRecharge() {
- if (this.data.paying) return
- if (!this.data.enterpriseId || !this.data.amountFen) {
- tt.showToast({ title: '充值参数无效', icon: 'none' })
- return
- }
-
- this.setData({ paying: true })
- payment.recharge({
- amountYuan: Number(this.data.amountYuan),
- enterpriseId: this.data.enterpriseId,
- success: () => {
- this.setData({ paying: false })
- tt.showToast({ title: '充值成功', icon: 'success' })
- setTimeout(() => {
- tt.navigateTo({ url: `/pages/enterprise/index?eid=${this.data.enterpriseId}` })
- }, 1200)
- },
- fail: () => {
- this.setData({ paying: false })
- }
- })
- }
-})
+const app = getApp()
+const payment = require('../../utils/payment')
+const { request } = require('../../utils/request')
+let analyticsMod = null
+try {
+ analyticsMod = require('../../utils/analytics')
+} catch (e) {}
+
+Page({
+ data: {
+ enterpriseId: 0,
+ enterpriseName: '',
+ amountFen: 0,
+ amountYuan: '0.00',
+ paying: false
+ },
+
+ onLoad(options) {
+ const rawScene = options && options.scene ? decodeURIComponent(options.scene) : ''
+ const sceneParams = {}
+ if (rawScene) {
+ rawScene.split('&').forEach(pair => {
+ const [k, v] = pair.split('=')
+ if (k) sceneParams[k] = v || ''
+ })
+ }
+
+ const enterpriseId = parseInt(sceneParams.eid || options.eid || 0, 10) || 0
+ const amountFen = parseInt(sceneParams.a || options.amountFen || 0, 10) || 0
+ const amountYuan = (amountFen / 100).toFixed(2)
+
+ if (enterpriseId > 0) {
+ app.globalData.enterpriseIdFromScene = enterpriseId
+ }
+
+ this.setData({
+ enterpriseId,
+ enterpriseName: (app.globalData.userInfo && app.globalData.userInfo.enterpriseName) || '',
+ amountFen,
+ amountYuan
+ })
+
+ app.ensureLogin()
+ .then(() => {
+ if (enterpriseId > 0) {
+ request({
+ url: '/api/enterprise/bind',
+ method: 'POST',
+ data: { enterpriseId },
+ success: (res) => {
+ const payload = res && res.data && res.data.data ? res.data.data : {}
+ const enterpriseName = payload.enterpriseName || this.data.enterpriseName || ''
+ this.setData({ enterpriseName })
+ },
+ fail: () => {}
+ })
+ }
+ })
+ .catch(() => {
+ tt.showToast({ title: '请先登录', icon: 'none' })
+ })
+ },
+
+ submitRecharge() {
+ if (this.data.paying) return
+ if (!this.data.enterpriseId || !this.data.amountFen) {
+ tt.showToast({ title: '充值参数无效', icon: 'none' })
+ return
+ }
+
+ this.setData({ paying: true })
+ if (analyticsMod && typeof analyticsMod.track === 'function') {
+ analyticsMod.track('click_recharge', {
+ action: '充值页确认充值',
+ enterpriseId: this.data.enterpriseId,
+ amountFen: this.data.amountFen
+ })
+ if (typeof analyticsMod.flush === 'function') {
+ analyticsMod.flush()
+ }
+ }
+ payment.recharge({
+ amountYuan: Number(this.data.amountYuan),
+ enterpriseId: this.data.enterpriseId,
+ success: () => {
+ this.setData({ paying: false })
+ tt.showToast({ title: '充值成功', icon: 'success' })
+ setTimeout(() => {
+ tt.navigateTo({ url: `/pages/enterprise/index?eid=${this.data.enterpriseId}` })
+ }, 1200)
+ },
+ fail: () => {
+ this.setData({ paying: false })
+ }
+ })
+ }
+})
diff --git a/douyin-miniprogram/pages/test/mbti.js b/douyin-miniprogram/pages/test/mbti.js
index 29d8e6a..3f2cbc8 100644
--- a/douyin-miniprogram/pages/test/mbti.js
+++ b/douyin-miniprogram/pages/test/mbti.js
@@ -1,180 +1,223 @@
-// pages/test/mbti.js - MBTI测试页面逻辑
-const { mbtiQuestions, shuffleQuestions } = require('../../utils/questions')
-const { mbtiDescriptions } = require('../../utils/descriptions')
-const payment = require('../../utils/payment')
-const app = getApp()
-
-Page({
- data: {
- questions: [],
- currentIndex: 0,
- currentQuestion: null,
- answers: {},
- selectedAnswer: null,
- total: mbtiQuestions.length,
- answeredCount: 0,
- progress: 0,
- timeRemaining: 30 * 60, // 30分钟
- formatTime: '30:00',
- isSubmitting: false,
- canAccess: false
- },
-
- timer: null,
-
- onLoad() {
- const questions = shuffleQuestions(mbtiQuestions)
- this.setData({
- questions,
- currentQuestion: questions[0],
- canAccess: true
- })
- this.startTimer()
- },
-
- // 检查访问权限
- checkAccess() {
- // 当前策略:所有测试免费开放,直接允许访问
- // 若后续恢复收费,可重新启用 payment.canTakeTest 等校验逻辑
- return true
- },
-
- onUnload() {
- if (this.timer) {
- clearInterval(this.timer)
- }
- },
-
- // 启动计时器
- startTimer() {
- this.timer = setInterval(() => {
- let time = this.data.timeRemaining - 1
- if (time <= 0) {
- clearInterval(this.timer)
- this.submitTest()
- return
- }
- const minutes = Math.floor(time / 60)
- const seconds = time % 60
- this.setData({
- timeRemaining: time,
- formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
- })
- }, 1000)
- },
-
- // 选择答案
- selectAnswer(e) {
- const value = e.currentTarget.dataset.value
- const questionId = this.data.currentQuestion.id
-
- let answers = { ...this.data.answers }
- answers[questionId] = value
-
- this.setData({
- selectedAnswer: value,
- answers: answers,
- answeredCount: Object.keys(answers).length,
- progress: (Object.keys(answers).length / this.data.total) * 100
- })
-
- // 自动跳转下一题
- setTimeout(() => {
- if (this.data.currentIndex < this.data.total - 1) {
- this.nextQuestion()
- }
- }, 300)
- },
-
- // 上一题
- prevQuestion() {
- if (this.data.currentIndex > 0) {
- const newIndex = this.data.currentIndex - 1
- const newQuestion = this.data.questions[newIndex]
- this.setData({
- currentIndex: newIndex,
- currentQuestion: newQuestion,
- selectedAnswer: this.data.answers[newQuestion.id] || null
- })
- }
- },
-
- // 下一题
- nextQuestion() {
- if (this.data.currentIndex < this.data.total - 1) {
- const newIndex = this.data.currentIndex + 1
- const newQuestion = this.data.questions[newIndex]
- this.setData({
- currentIndex: newIndex,
- currentQuestion: newQuestion,
- selectedAnswer: this.data.answers[newQuestion.id] || null
- })
- }
- },
-
- // 提交测试
- submitTest() {
- if (this.data.isSubmitting) return
- this.setData({ isSubmitting: true })
-
- const result = this.calculateResult()
-
- // 保存结果
- const resultData = {
- ...result,
- testDuration: 30 * 60 - this.data.timeRemaining,
- completedAt: new Date().toISOString(),
- timestamp: new Date().toISOString()
- }
- tt.setStorageSync('mbtiResult', resultData)
- app.saveTestResult('mbti', resultData)
-
- // 跳转到结果页
- tt.redirectTo({
- url: '/pages/result/mbti'
- })
- },
-
- // 计算MBTI结果
- calculateResult() {
- const answers = this.data.answers
- const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
-
- // 统计各维度得分
- Object.values(answers).forEach(value => {
- if (scores.hasOwnProperty(value)) {
- scores[value]++
- }
- })
-
- // 确定MBTI类型
- const mbtiType = [
- scores.E >= scores.I ? 'E' : 'I',
- scores.S >= scores.N ? 'S' : 'N',
- scores.T >= scores.F ? 'T' : 'F',
- scores.J >= scores.P ? 'J' : 'P'
- ].join('')
-
- // 计算各维度百分比
- const dimensionScores = {
- EI: { E: scores.E, I: scores.I, dominant: scores.E >= scores.I ? 'E' : 'I', percentage: Math.round((Math.max(scores.E, scores.I) / (scores.E + scores.I)) * 100) },
- SN: { S: scores.S, N: scores.N, dominant: scores.S >= scores.N ? 'S' : 'N', percentage: Math.round((Math.max(scores.S, scores.N) / (scores.S + scores.N)) * 100) },
- TF: { T: scores.T, F: scores.F, dominant: scores.T >= scores.F ? 'T' : 'F', percentage: Math.round((Math.max(scores.T, scores.F) / (scores.T + scores.F)) * 100) },
- JP: { J: scores.J, P: scores.P, dominant: scores.J >= scores.P ? 'J' : 'P', percentage: Math.round((Math.max(scores.J, scores.P) / (scores.J + scores.P)) * 100) }
- }
-
- // 计算置信度
- const confidence = Math.round(
- (dimensionScores.EI.percentage + dimensionScores.SN.percentage +
- dimensionScores.TF.percentage + dimensionScores.JP.percentage) / 4
- )
-
- return {
- mbtiType,
- scores,
- dimensionScores,
- confidence,
- description: mbtiDescriptions[mbtiType] || {}
- }
- }
-})
+// pages/test/mbti.js - MBTI测试页面逻辑(与微信端对齐:最后一题必提交结果)
+const { mbtiQuestions, shuffleQuestions } = require('../../utils/questions')
+const { mbtiDescriptions } = require('../../utils/descriptions')
+const payment = require('../../utils/payment')
+const app = getApp()
+
+Page({
+ data: {
+ questions: [],
+ currentIndex: 0,
+ currentQuestion: null,
+ answers: {},
+ selectedAnswer: null,
+ total: mbtiQuestions.length,
+ answeredCount: 0,
+ progress: 0,
+ timeRemaining: 30 * 60,
+ formatTime: '30:00',
+ isSubmitting: false,
+ canAccess: false
+ },
+
+ timer: null,
+
+ onLoad() {
+ const questions = shuffleQuestions(mbtiQuestions)
+ const total = questions.length
+ this.setData({
+ questions,
+ currentQuestion: questions[0],
+ canAccess: true,
+ total,
+ progress: total ? Math.round((1 / total) * 100) : 0
+ })
+ this.startTimer()
+ },
+
+ checkAccess() {
+ return true
+ },
+
+ onUnload() {
+ if (this.timer) {
+ clearInterval(this.timer)
+ }
+ },
+
+ startTimer() {
+ this.timer = setInterval(() => {
+ let time = this.data.timeRemaining - 1
+ if (time <= 0) {
+ clearInterval(this.timer)
+ this.submitTest({ allowIncomplete: true })
+ return
+ }
+ const minutes = Math.floor(time / 60)
+ const seconds = time % 60
+ this.setData({
+ timeRemaining: time,
+ formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
+ })
+ }, 1000)
+ },
+
+ selectAnswer(e) {
+ const value = e.currentTarget.dataset.value
+ const questionId = this.data.currentQuestion.id
+ const idx = this.data.currentIndex
+ const tot = this.data.total
+
+ let answers = { ...this.data.answers }
+ answers[questionId] = value
+ const answeredCount = Object.keys(answers).length
+ const progress = tot ? Math.round(((idx + 1) / tot) * 100) : 0
+
+ this.setData({
+ selectedAnswer: value,
+ answers,
+ answeredCount,
+ progress
+ })
+
+ setTimeout(() => {
+ if (idx < tot - 1) {
+ this.nextQuestion()
+ } else {
+ this.submitTest()
+ }
+ }, 320)
+ },
+
+ prevQuestion() {
+ if (this.data.currentIndex > 0) {
+ const newIndex = this.data.currentIndex - 1
+ const newQuestion = this.data.questions[newIndex]
+ const tot = this.data.total
+ this.setData({
+ currentIndex: newIndex,
+ currentQuestion: newQuestion,
+ selectedAnswer: this.data.answers[newQuestion.id] || null,
+ progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
+ })
+ }
+ },
+
+ nextQuestion() {
+ if (this.data.currentIndex < this.data.total - 1) {
+ const newIndex = this.data.currentIndex + 1
+ const newQuestion = this.data.questions[newIndex]
+ const tot = this.data.total
+ this.setData({
+ currentIndex: newIndex,
+ currentQuestion: newQuestion,
+ selectedAnswer: this.data.answers[newQuestion.id] || null,
+ progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
+ })
+ }
+ },
+
+ finishTest() {
+ const q = this.data.currentQuestion
+ if (!q) return
+ if (this.data.answers[q.id] == null && this.data.selectedAnswer == null) {
+ tt.showToast({ title: '请先选择一项', icon: 'none' })
+ return
+ }
+ const tot = this.data.total
+ if (Object.keys(this.data.answers).length < tot) {
+ tt.showToast({ title: '还有题目未作答,请返回补答', icon: 'none' })
+ return
+ }
+ this.submitTest()
+ },
+
+ submitTest(opt = {}) {
+ if (this.data.isSubmitting) return
+ const allowIncomplete = !!opt.allowIncomplete
+ if (this.timer) {
+ clearInterval(this.timer)
+ this.timer = null
+ }
+
+ const tot = this.data.total
+ const n = Object.keys(this.data.answers).length
+ if (!allowIncomplete && n < tot) {
+ tt.showToast({ title: `还有 ${tot - n} 题未作答`, icon: 'none' })
+ this.startTimer()
+ return
+ }
+
+ this.setData({ isSubmitting: true })
+
+ let result
+ try {
+ result = this.calculateResult()
+ } catch (err) {
+ console.error('calculateResult', err)
+ tt.showToast({ title: '计算结果失败,请重试', icon: 'none' })
+ this.setData({ isSubmitting: false })
+ this.startTimer()
+ return
+ }
+
+ const resultData = {
+ ...result,
+ answers: this.data.answers,
+ testDuration: 30 * 60 - this.data.timeRemaining,
+ completedAt: new Date().toISOString(),
+ timestamp: new Date().toISOString()
+ }
+ tt.setStorageSync('mbtiResult', resultData)
+ app.saveTestResult('mbti', resultData)
+
+ tt.redirectTo({
+ url: '/pages/result/mbti'
+ })
+ },
+
+ calculateResult() {
+ const answers = this.data.answers
+ const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
+
+ Object.values(answers).forEach(value => {
+ if (scores.hasOwnProperty(value)) {
+ scores[value]++
+ }
+ })
+
+ const mbtiType = [
+ scores.E >= scores.I ? 'E' : 'I',
+ scores.S >= scores.N ? 'S' : 'N',
+ scores.T >= scores.F ? 'T' : 'F',
+ scores.J >= scores.P ? 'J' : 'P'
+ ].join('')
+
+ const pct = (a, b) => {
+ const s = a + b
+ if (!s) return 50
+ return Math.round((Math.max(a, b) / s) * 100)
+ }
+
+ const dimensionScores = {
+ EI: { E: scores.E, I: scores.I, dominant: scores.E >= scores.I ? 'E' : 'I', percentage: pct(scores.E, scores.I) },
+ SN: { S: scores.S, N: scores.N, dominant: scores.S >= scores.N ? 'S' : 'N', percentage: pct(scores.S, scores.N) },
+ TF: { T: scores.T, F: scores.F, dominant: scores.T >= scores.F ? 'T' : 'F', percentage: pct(scores.T, scores.F) },
+ JP: { J: scores.J, P: scores.P, dominant: scores.J >= scores.P ? 'J' : 'P', percentage: pct(scores.J, scores.P) }
+ }
+
+ let confidence = Math.round(
+ (dimensionScores.EI.percentage + dimensionScores.SN.percentage +
+ dimensionScores.TF.percentage + dimensionScores.JP.percentage) / 4
+ )
+ if (!Number.isFinite(confidence)) confidence = 0
+
+ return {
+ mbtiType,
+ scores,
+ dimensionScores,
+ confidence,
+ description: mbtiDescriptions[mbtiType] || {}
+ }
+ }
+})
diff --git a/douyin-miniprogram/pages/test/mbti.ttml b/douyin-miniprogram/pages/test/mbti.ttml
index 77c6220..99ccdb6 100644
--- a/douyin-miniprogram/pages/test/mbti.ttml
+++ b/douyin-miniprogram/pages/test/mbti.ttml
@@ -1,48 +1,48 @@
-
-
-
-
- 问题 {{currentIndex + 1}}/{{total}}
- 剩余时间: {{formatTime}}
-
-
-
-
-
-
-
-
- {{currentQuestion.question}}
-
-
-
-
-
- {{option.text}}
-
-
-
-
-
-
-
-
-
- {{isSubmitting ? '计算中...' : '完成测试,查看结果'}}
-
-
-
+
+
+
+
+ 问题 {{currentIndex + 1}}/{{total}}
+ 剩余时间: {{formatTime}}
+
+
+
+
+
+
+
+
+ {{currentQuestion.question}}
+
+
+
+
+
+ {{option.text}}
+
+
+
+
+ 最后一题:选择后约 0.3 秒自动跳转结果页;若未跳转,请点右下角「查看结果」。
+
+
+
+
+
diff --git a/douyin-miniprogram/pages/test/mbti.ttss b/douyin-miniprogram/pages/test/mbti.ttss
index d3dd6f8..f54d7b9 100644
--- a/douyin-miniprogram/pages/test/mbti.ttss
+++ b/douyin-miniprogram/pages/test/mbti.ttss
@@ -1,172 +1,181 @@
-/* pages/test/mbti.wxss - 按旧版模板重构 */
-.test-page {
- width: 100%;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- background-color: #fff;
-}
-
-.progress-section {
- padding: 32rpx;
- border-bottom: 1rpx solid #e5e5e5;
- flex-shrink: 0;
-}
-
-.progress-info {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16rpx;
-}
-
-.question-count {
- font-size: 28rpx;
- font-weight: 500;
- color: #333;
-}
-
-.time-remaining {
- font-size: 28rpx;
- color: #999;
-}
-
-.progress-bar-container {
- width: 100%;
- height: 8rpx;
- background-color: #e5e5e5;
- border-radius: 8rpx;
- overflow: hidden;
-}
-
-.progress-bar {
- height: 100%;
- background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
- border-radius: 8rpx;
- transition: width 0.3s ease;
-}
-
-.content-area {
- flex: 1;
- overflow-y: auto;
- padding: 32rpx;
-}
-
-.question-card {
- background-color: #fff;
- border-radius: 24rpx;
- padding: 48rpx;
- box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
-}
-
-.question-text {
- display: block;
- font-size: 40rpx;
- font-weight: 500;
- color: #333;
- line-height: 1.6;
- margin-bottom: 48rpx;
-}
-
-.options-container {
- display: flex;
- flex-direction: column;
- gap: 32rpx;
-}
-
-.option-item {
- display: flex;
- align-items: center;
- padding: 32rpx;
- border: 2rpx solid #e5e5e5;
- border-radius: 16rpx;
- transition: all 0.3s ease;
-}
-
-.option-item.selected {
- background-color: rgba(255, 107, 138, 0.12);
- border-color: #FF6B8A;
-}
-
-.radio-button {
- width: 40rpx;
- height: 40rpx;
- border-radius: 50%;
- border: 2rpx solid #d1d5db;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 24rpx;
- flex-shrink: 0;
- transition: all 0.3s ease;
-}
-
-.radio-button.checked {
- background-color: #FF6B8A;
- border-color: #FF6B8A;
-}
-
-.radio-inner {
- width: 16rpx;
- height: 16rpx;
- border-radius: 50%;
- background-color: #fff;
-}
-
-.option-text {
- flex: 1;
- font-size: 32rpx;
- color: #333;
- line-height: 1.5;
-}
-
-.footer-buttons {
- display: flex;
- gap: 24rpx;
- padding: 32rpx;
- border-top: 1rpx solid #e5e5e5;
- flex-shrink: 0;
-}
-
-.nav-button {
- flex: 1;
- padding: 28rpx;
- border-radius: 16rpx;
- text-align: center;
-}
-
-.nav-button.secondary {
- background-color: #fff;
- border: 2rpx solid #FF6B8A;
-}
-
-.nav-button.secondary .button-text {
- color: #FF6B8A;
-}
-
-.nav-button.disabled {
- opacity: 0.4;
- pointer-events: none;
-}
-
-.button-text {
- font-size: 32rpx;
- font-weight: 500;
-}
-
-.submit-wrap {
- padding: 0 32rpx 32rpx;
-}
-
-.submit-button {
- background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
- border-radius: 16rpx;
- padding: 28rpx;
- text-align: center;
-}
-
-.submit-text {
- font-size: 32rpx;
- font-weight: 600;
- color: #fff;
-}
+/* pages/test/mbti.wxss - 按旧版模板重构 */
+.test-page {
+ width: 100%;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background-color: #fff;
+}
+
+.progress-section {
+ padding: 32rpx;
+ border-bottom: 1rpx solid #e5e5e5;
+ flex-shrink: 0;
+}
+
+.progress-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16rpx;
+}
+
+.question-count {
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #333;
+}
+
+.time-remaining {
+ font-size: 28rpx;
+ color: #999;
+}
+
+.progress-bar-container {
+ width: 100%;
+ height: 8rpx;
+ background-color: #e5e5e5;
+ border-radius: 8rpx;
+ overflow: hidden;
+}
+
+.progress-bar {
+ height: 100%;
+ background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
+ border-radius: 8rpx;
+ transition: width 0.3s ease;
+}
+
+.content-area {
+ flex: 1;
+ overflow-y: auto;
+ padding: 32rpx;
+}
+
+.question-card {
+ background-color: #fff;
+ border-radius: 24rpx;
+ padding: 48rpx;
+ box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
+}
+
+.question-text {
+ display: block;
+ font-size: 40rpx;
+ font-weight: 500;
+ color: #333;
+ line-height: 1.6;
+ margin-bottom: 48rpx;
+}
+
+.options-container {
+ display: flex;
+ flex-direction: column;
+ gap: 32rpx;
+}
+
+.option-item {
+ display: flex;
+ align-items: center;
+ padding: 32rpx;
+ border: 2rpx solid #e5e5e5;
+ border-radius: 16rpx;
+ transition: all 0.3s ease;
+}
+
+.option-item.selected {
+ background-color: rgba(255, 107, 138, 0.12);
+ border-color: #FF6B8A;
+}
+
+.radio-button {
+ width: 40rpx;
+ height: 40rpx;
+ border-radius: 50%;
+ border: 2rpx solid #d1d5db;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 24rpx;
+ flex-shrink: 0;
+ transition: all 0.3s ease;
+}
+
+.radio-button.checked {
+ background-color: #FF6B8A;
+ border-color: #FF6B8A;
+}
+
+.radio-inner {
+ width: 16rpx;
+ height: 16rpx;
+ border-radius: 50%;
+ background-color: #fff;
+}
+
+.option-text {
+ flex: 1;
+ font-size: 32rpx;
+ color: #333;
+ line-height: 1.5;
+}
+
+.footer-buttons {
+ display: flex;
+ gap: 24rpx;
+ padding: 32rpx;
+ border-top: 1rpx solid #e5e5e5;
+ flex-shrink: 0;
+}
+
+.nav-button {
+ flex: 1;
+ padding: 28rpx;
+ border-radius: 16rpx;
+ text-align: center;
+}
+
+.nav-button.secondary {
+ background-color: #fff;
+ border: 2rpx solid #FF6B8A;
+}
+
+.nav-button.secondary .button-text {
+ color: #FF6B8A;
+}
+
+.nav-button.primary {
+ background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%);
+ border: none;
+ box-shadow: 0 8rpx 24rpx rgba(255, 107, 138, 0.35);
+}
+
+.button-text-on-primary {
+ color: #ffffff !important;
+ font-weight: 600;
+}
+
+.nav-button.disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.last-hint {
+ margin-top: 24rpx;
+ padding: 20rpx 24rpx;
+ background: rgba(255, 107, 138, 0.08);
+ border-radius: 16rpx;
+ border: 1rpx solid rgba(255, 107, 138, 0.2);
+}
+
+.last-hint-text {
+ font-size: 26rpx;
+ color: #be185d;
+ line-height: 1.5;
+}
+
+.button-text {
+ font-size: 32rpx;
+ font-weight: 500;
+}
+
diff --git a/douyin-miniprogram/utils/analytics.js b/douyin-miniprogram/utils/analytics.js
new file mode 100644
index 0000000..7b9fc7b
--- /dev/null
+++ b/douyin-miniprogram/utils/analytics.js
@@ -0,0 +1,73 @@
+/**
+ * 抖音小程序埋点(同微信接口)
+ */
+const { request } = require('./request.js')
+
+const MAX_BATCH = 30
+const queue = []
+let lastPageReport = { path: '', t: 0 }
+
+function getAppSafe() {
+ try {
+ return getApp()
+ } catch (e) {
+ return null
+ }
+}
+
+function getCurrentRoute() {
+ const pages = getCurrentPages()
+ const p = pages[pages.length - 1]
+ return p && p.route ? p.route : ''
+}
+
+function track(eventName, props) {
+ if (!eventName || typeof eventName !== 'string') return
+ const app = getAppSafe()
+ const openId =
+ app && app.globalData
+ ? (app.globalData.openId || (app.globalData.userInfo && (app.globalData.userInfo.openid || app.globalData.userInfo.openId)) || '')
+ : ''
+ queue.push({
+ event_name: eventName,
+ page_path: getCurrentRoute(),
+ props: props && typeof props === 'object' ? props : {},
+ client_ts: Date.now(),
+ openid: openId || undefined
+ })
+ if (queue.length >= MAX_BATCH) {
+ flush()
+ }
+}
+
+function reportPageView() {
+ const path = getCurrentRoute()
+ if (!path) return
+ const now = Date.now()
+ if (path === lastPageReport.path && now - lastPageReport.t < 2000) {
+ return
+ }
+ lastPageReport = { path, t: now }
+ track('page_view', { path })
+}
+
+function flush() {
+ if (queue.length === 0) return
+ const events = queue.splice(0, queue.length)
+ request({
+ url: '/api/analytics/events',
+ method: 'POST',
+ needAuth: true,
+ data: { events },
+ allow401: false,
+ success() {},
+ fail() {}
+ })
+}
+
+module.exports = {
+ track,
+ flush,
+ reportPageView,
+ getCurrentRoute
+}
diff --git a/douyin-miniprogram/utils/payment.js b/douyin-miniprogram/utils/payment.js
index 7adf9a7..e20bcdd 100644
--- a/douyin-miniprogram/utils/payment.js
+++ b/douyin-miniprogram/utils/payment.js
@@ -1,532 +1,546 @@
-// douyin-miniprogram/utils/payment.js
-// 抖音支付工具类 - 从微信版移植
-// 后端需实现 /api/payment/create 返回 { order_id, order_token } 用于 tt.pay
-
-const app = getApp()
-
-/**
- * 生成订单号
- * 规则:前缀+时间戳+随机数,最长64位(抖音限制比微信宽松)
- */
-function generateOrderId(productType) {
- const now = new Date()
- const pad = (n, len = 2) => String(n).padStart(len, '0')
- const prefixMap = {
- face: 'FACE',
- mbti: 'MBTI',
- disc: 'DISC',
- pdp: 'PDP',
- report: 'REPT',
- team_analysis: 'TEAM',
- vip: 'VIP',
- test_count: 'TNUM',
- single_test: 'TSGL',
- recharge: 'RCG',
- deep_personal: 'DPER',
- deep_team: 'DTEAM'
- }
-
- const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6)
- const dateStr =
- now.getFullYear().toString() +
- pad(now.getMonth() + 1) +
- pad(now.getDate()) +
- pad(now.getHours()) +
- pad(now.getMinutes()) +
- pad(now.getSeconds())
-
- const rand = pad(Math.floor(Math.random() * 1000), 3)
- const raw = `${prefix}${dateStr}${rand}`
-
- return raw.length > 64 ? raw.slice(0, 64) : raw
-}
-
-/**
- * 发起抖音支付
- * 流程:后端创建订单 → 返回 order_id + order_token → tt.pay 调起收银台
- * 后端需实现 /api/payment/create 接口,当 paymentMethod='douyin' 时返回:
- * { order_id: 'xxx', order_token: 'xxx' }
- */
-function douyinPay(options) {
- const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
-
- tt.showLoading({
- title: '正在支付...',
- mask: true
- })
-
- tt.request({
- url: `${app.globalData.apiBase}/api/payment/create`,
- method: 'POST',
- header: {
- 'Authorization': `Bearer ${tt.getStorageSync('token')}`,
- 'Content-Type': 'application/json'
- },
- data: {
- orderId,
- amount,
- description,
- productType,
- paymentMethod: 'douyin',
- openId: app.globalData.openId || '',
- enterpriseId: enterpriseId || 0,
- testResultId: testResultId || 0,
- deepProductId: deepProductId || ''
- },
- success: (res) => {
- tt.hideLoading()
-
- if (res.statusCode === 200 && res.data.code === 200) {
- const paymentData = res.data.data
-
- tt.pay({
- orderInfo: {
- order_id: paymentData.order_id,
- order_token: paymentData.order_token
- },
- service: 5,
- success: (payRes) => {
- console.log('抖音支付回调', payRes)
-
- if (payRes.code === 0) {
- pollOrderStatus(orderId, 5, 1000, (ok, order) => {
- if (ok) {
- tt.showToast({
- title: '支付成功',
- icon: 'success',
- duration: 2000
- })
- success && success({ payRes, order })
- } else {
- tt.showToast({
- title: '支付结果处理中,请稍后查看',
- icon: 'none',
- duration: 2500
- })
- success && success({ payRes, order: null })
- }
- })
- } else if (payRes.code === 4) {
- tt.showToast({
- title: '支付已取消',
- icon: 'none'
- })
- fail && fail(payRes)
- } else if (payRes.code === 9) {
- pollOrderStatus(orderId, 5, 1500, (ok, order) => {
- if (ok) {
- tt.showToast({ title: '支付成功', icon: 'success', duration: 2000 })
- success && success({ payRes, order })
- } else {
- tt.showToast({ title: '支付结果待确认,请稍后查看', icon: 'none', duration: 2500 })
- fail && fail(payRes)
- }
- })
- } else {
- tt.showToast({
- title: '支付失败',
- icon: 'none'
- })
- fail && fail(payRes)
- }
- },
- fail: (payErr) => {
- console.error('抖音支付失败', payErr)
- tt.showToast({
- title: '支付失败',
- icon: 'none'
- })
- fail && fail(payErr)
- }
- })
- } else {
- tt.showToast({
- title: res.data.message || '创建订单失败',
- icon: 'none'
- })
- fail && fail(res)
- }
- },
- fail: (err) => {
- tt.hideLoading()
- console.error('请求失败', err)
- tt.showToast({
- title: '网络请求失败',
- icon: 'none'
- })
- fail && fail(err)
- }
- })
-}
-
-function notifyPaymentSuccess(orderId, prepayId) {
- tt.request({
- url: `${app.globalData.apiBase}/api/payment/notify`,
- method: 'POST',
- header: {
- 'Authorization': `Bearer ${tt.getStorageSync('token')}`,
- 'Content-Type': 'application/json'
- },
- data: {
- orderId,
- prepayId,
- status: 'success'
- },
- success: (res) => {
- console.log('支付通知成功', res)
- },
- fail: (err) => {
- console.error('支付通知失败', err)
- }
- })
-}
-
-function queryOrderStatus(orderId, callback) {
- tt.request({
- url: `${app.globalData.apiBase}/api/payment/query`,
- method: 'GET',
- header: {
- 'Authorization': `Bearer ${tt.getStorageSync('token')}`
- },
- data: { orderId },
- success: (res) => {
- if (res.statusCode === 200 && res.data.code === 200) {
- callback && callback(true, res.data.data)
- } else {
- callback && callback(false, null)
- }
- },
- fail: () => {
- callback && callback(false, null)
- }
- })
-}
-
-function pollOrderStatus(orderId, maxAttempts = 5, intervalMs = 1000, done) {
- let attempts = 0
-
- const tick = () => {
- attempts += 1
- queryOrderStatus(orderId, (ok, order) => {
- if (ok && order && (order.status === 'paid' || order.status === 'completed')) {
- done && done(true, order)
- return
- }
- if (attempts >= maxAttempts) {
- done && done(false, order || null)
- return
- }
- setTimeout(tick, intervalMs)
- })
- }
-
- tick()
-}
-
-function purchaseVIP(vipType, success, fail) {
- const prices = {
- month: 1990,
- quarter: 4990,
- year: 9900,
- lifetime: 19900,
- personal_insight: 19800
- }
-
- const names = {
- month: '月度VIP会员',
- quarter: '季度VIP会员',
- year: '年度VIP会员',
- lifetime: '终身VIP会员',
- personal_insight: '个人深度洞察版'
- }
-
- const orderId = generateOrderId('vip')
-
- douyinPay({
- orderId,
- amount: prices[vipType],
- description: `MBTI性格测试 - ${names[vipType]}`,
- productType: 'vip',
- success: (res) => {
- updateVIPStatus(vipType)
- success && success(res)
- },
- fail
- })
-}
-
-function purchaseTestCount(count, success, fail) {
- let price = count * 390
- if (count >= 10) price = Math.floor(count * 290)
- if (count >= 50) price = Math.floor(count * 198)
-
- const orderId = generateOrderId('test_count')
-
- douyinPay({
- orderId,
- amount: price,
- description: `MBTI性格测试 - ${count}次测试次数`,
- productType: 'test_count',
- success: (res) => {
- addTestCount(count)
- success && success(res)
- },
- fail
- })
-}
-
-function purchaseSingleTest(testType, success, fail) {
- const prices = {
- mbti: 990,
- disc: 690,
- pdp: 690,
- ai: 1990
- }
-
- const names = {
- mbti: 'MBTI性格测试',
- disc: 'DISC行为风格测试',
- pdp: 'PDP动物性格测试',
- ai: '性格分析'
- }
-
- const orderId = generateOrderId(`single_${testType}`)
-
- douyinPay({
- orderId,
- amount: prices[testType],
- description: names[testType],
- productType: 'single_test',
- success: (res) => {
- unlockTest(testType)
- success && success(res)
- },
- fail
- })
-}
-
-function purchaseByPricing(productType, description, extra, maybeFail) {
- let opts = {}
- if (typeof extra === 'function' || extra === undefined) {
- opts.success = extra
- opts.fail = maybeFail
- } else {
- opts = extra || {}
- }
-
- const { testResultId, success, fail } = opts
- const orderId = generateOrderId(productType)
-
- douyinPay({
- orderId,
- amount: 0,
- description,
- productType,
- testResultId,
- success,
- fail
- })
-}
-
-function purchaseFaceTest(arg1, arg2) {
- let opts = {}
- if (typeof arg1 === 'function' || arg1 === undefined) {
- opts.success = arg1
- opts.fail = arg2
- } else {
- opts = arg1 || {}
- }
- const { testResultId, success, fail } = opts
- purchaseByPricing('face', '性格分析完整报告', { testResultId, success, fail })
-}
-
-function purchaseMbtiTest(arg1, arg2) {
- const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
- const { testResultId, success, fail } = opts
- purchaseByPricing('mbti', 'MBTI性格测试付费版', { testResultId, success, fail })
-}
-
-function purchaseDiscTest(arg1, arg2) {
- const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
- const { testResultId, success, fail } = opts
- purchaseByPricing('disc', 'DISC行为风格测试付费版', { testResultId, success, fail })
-}
-
-function purchasePdpTest(arg1, arg2) {
- const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
- const { testResultId, success, fail } = opts
- purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail })
-}
-
-function purchaseResumeAnalysis(arg1, arg2) {
- const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
- const { testResultId, success, fail } = opts
- purchaseByPricing('resume', '简历综合分析付费版', { testResultId, success, fail })
-}
-
-function purchaseFullReport(success, fail) {
- purchaseByPricing('report', '完整人格与职业发展报告', success, fail)
-}
-
-function purchaseTeamAnalysis(success, fail) {
- purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail)
-}
-
-function recharge(arg1, arg2, arg3) {
- const opts = (typeof arg1 === 'object' && arg1 !== null)
- ? arg1
- : { amountYuan: arg1, success: arg2, fail: arg3 }
- const safeAmount = Number(opts.amountYuan) || 0
- const amount = Math.round(safeAmount * 100)
- const orderId = generateOrderId('recharge')
-
- douyinPay({
- orderId,
- amount,
- description: `账户充值 ¥${safeAmount.toFixed(2)}`,
- productType: 'recharge',
- enterpriseId: opts.enterpriseId || 0,
- success: opts.success,
- fail: opts.fail
- })
-}
-
-function purchasePersonalDeepService(arg1, arg2, arg3) {
- let deepProductId = ''
- let description = ''
- let success
- let fail
-
- if (typeof arg1 === 'object' && arg1 !== null) {
- deepProductId = arg1.deepProductId || ''
- description = arg1.description || ''
- success = arg1.success
- fail = arg1.fail
- } else {
- deepProductId = typeof arg1 === 'string' ? arg1 : ''
- success = arg2
- fail = arg3
- }
-
- const desc = description || '个人深度服务(1v1深度解读)'
- const orderId = generateOrderId('deep_personal')
-
- douyinPay({
- orderId,
- amount: 0,
- description: desc,
- productType: 'deep_personal',
- deepProductId,
- success,
- fail
- })
-}
-
-function purchaseTeamDeepService(success, fail) {
- const orderId = generateOrderId('deep_team')
-
- douyinPay({
- orderId,
- amount: 0,
- description: '团队深度服务(团队画像+策略)',
- productType: 'deep_team',
- success,
- fail
- })
-}
-
-function updateVIPStatus(vipType) {
- const durations = {
- month: 30,
- quarter: 90,
- year: 365,
- lifetime: 36500
- }
-
- const expireDate = new Date()
- expireDate.setDate(expireDate.getDate() + durations[vipType])
-
- const vipInfo = {
- isVIP: true,
- vipType,
- expireDate: expireDate.toISOString(),
- purchaseDate: new Date().toISOString()
- }
-
- tt.setStorageSync('vipInfo', vipInfo)
- app.globalData.vipInfo = vipInfo
-}
-
-function addTestCount(count) {
- const currentCount = tt.getStorageSync('testCount') || 0
- const newCount = currentCount + count
- tt.setStorageSync('testCount', newCount)
- app.globalData.testCount = newCount
-}
-
-function unlockTest(testType) {
- const unlockedTests = tt.getStorageSync('unlockedTests') || []
- if (!unlockedTests.includes(testType)) {
- unlockedTests.push(testType)
- tt.setStorageSync('unlockedTests', unlockedTests)
- }
- app.globalData.unlockedTests = unlockedTests
-}
-
-function checkVIP() {
- const vipInfo = tt.getStorageSync('vipInfo')
- if (!vipInfo || !vipInfo.isVIP) return false
-
- const expireDate = new Date(vipInfo.expireDate)
- return expireDate > new Date()
-}
-
-function canTakeTest(testType) {
- return true
-}
-
-function consumeTestCount() {
- const testCount = tt.getStorageSync('testCount') || 0
- if (testCount > 0) {
- tt.setStorageSync('testCount', testCount - 1)
- app.globalData.testCount = testCount - 1
- return true
- }
- return false
-}
-
-function getUserBenefits() {
- return {
- isVIP: checkVIP(),
- vipInfo: tt.getStorageSync('vipInfo') || null,
- testCount: tt.getStorageSync('testCount') || 0,
- unlockedTests: tt.getStorageSync('unlockedTests') || []
- }
-}
-
-module.exports = {
- douyinPay,
- queryOrderStatus,
- pollOrderStatus,
- generateOrderId,
- purchaseVIP,
- purchaseTestCount,
- purchaseSingleTest,
- purchaseFaceTest,
- purchaseMbtiTest,
- purchaseDiscTest,
- purchasePdpTest,
- purchaseResumeAnalysis,
- purchaseFullReport,
- purchaseTeamAnalysis,
- recharge,
- purchasePersonalDeepService,
- purchaseTeamDeepService,
- checkVIP,
- canTakeTest,
- consumeTestCount,
- getUserBenefits,
- updateVIPStatus,
- addTestCount,
- unlockTest
-}
+// douyin-miniprogram/utils/payment.js
+// 抖音支付工具类 - 从微信版移植
+// 后端需实现 /api/payment/create 返回 { order_id, order_token } 用于 tt.pay
+
+const app = getApp()
+
+/**
+ * 生成订单号
+ * 规则:前缀+时间戳+随机数,最长64位(抖音限制比微信宽松)
+ */
+function generateOrderId(productType) {
+ const now = new Date()
+ const pad = (n, len = 2) => String(n).padStart(len, '0')
+ const prefixMap = {
+ face: 'FACE',
+ mbti: 'MBTI',
+ disc: 'DISC',
+ pdp: 'PDP',
+ report: 'REPT',
+ team_analysis: 'TEAM',
+ vip: 'VIP',
+ test_count: 'TNUM',
+ single_test: 'TSGL',
+ recharge: 'RCG',
+ deep_personal: 'DPER',
+ deep_team: 'DTEAM'
+ }
+
+ const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6)
+ const dateStr =
+ now.getFullYear().toString() +
+ pad(now.getMonth() + 1) +
+ pad(now.getDate()) +
+ pad(now.getHours()) +
+ pad(now.getMinutes()) +
+ pad(now.getSeconds())
+
+ const rand = pad(Math.floor(Math.random() * 1000), 3)
+ const raw = `${prefix}${dateStr}${rand}`
+
+ return raw.length > 64 ? raw.slice(0, 64) : raw
+}
+
+/**
+ * 发起抖音支付
+ * 流程:后端创建订单 → 返回 order_id + order_token → tt.pay 调起收银台
+ * 后端需实现 /api/payment/create 接口,当 paymentMethod='douyin' 时返回:
+ * { order_id: 'xxx', order_token: 'xxx' }
+ */
+function douyinPay(options) {
+ const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
+
+ try {
+ const analyticsMod = require('./analytics')
+ if (analyticsMod && typeof analyticsMod.track === 'function') {
+ if (productType === 'recharge') {
+ analyticsMod.track('click_recharge', { action: '点击充值并发起支付', productType: 'recharge' })
+ } else {
+ analyticsMod.track('click_pay', { action: '发起支付', productType: productType || '' })
+ }
+ if (typeof analyticsMod.flush === 'function') {
+ analyticsMod.flush()
+ }
+ }
+ } catch (e) {}
+
+ tt.showLoading({
+ title: '正在支付...',
+ mask: true
+ })
+
+ tt.request({
+ url: `${app.globalData.apiBase}/api/payment/create`,
+ method: 'POST',
+ header: {
+ 'Authorization': `Bearer ${tt.getStorageSync('token')}`,
+ 'Content-Type': 'application/json'
+ },
+ data: {
+ orderId,
+ amount,
+ description,
+ productType,
+ paymentMethod: 'douyin',
+ openId: app.globalData.openId || '',
+ enterpriseId: enterpriseId || 0,
+ testResultId: testResultId || 0,
+ deepProductId: deepProductId || ''
+ },
+ success: (res) => {
+ tt.hideLoading()
+
+ if (res.statusCode === 200 && res.data.code === 200) {
+ const paymentData = res.data.data
+
+ tt.pay({
+ orderInfo: {
+ order_id: paymentData.order_id,
+ order_token: paymentData.order_token
+ },
+ service: 5,
+ success: (payRes) => {
+ console.log('抖音支付回调', payRes)
+
+ if (payRes.code === 0) {
+ pollOrderStatus(orderId, 5, 1000, (ok, order) => {
+ if (ok) {
+ tt.showToast({
+ title: '支付成功',
+ icon: 'success',
+ duration: 2000
+ })
+ success && success({ payRes, order })
+ } else {
+ tt.showToast({
+ title: '支付结果处理中,请稍后查看',
+ icon: 'none',
+ duration: 2500
+ })
+ success && success({ payRes, order: null })
+ }
+ })
+ } else if (payRes.code === 4) {
+ tt.showToast({
+ title: '支付已取消',
+ icon: 'none'
+ })
+ fail && fail(payRes)
+ } else if (payRes.code === 9) {
+ pollOrderStatus(orderId, 5, 1500, (ok, order) => {
+ if (ok) {
+ tt.showToast({ title: '支付成功', icon: 'success', duration: 2000 })
+ success && success({ payRes, order })
+ } else {
+ tt.showToast({ title: '支付结果待确认,请稍后查看', icon: 'none', duration: 2500 })
+ fail && fail(payRes)
+ }
+ })
+ } else {
+ tt.showToast({
+ title: '支付失败',
+ icon: 'none'
+ })
+ fail && fail(payRes)
+ }
+ },
+ fail: (payErr) => {
+ console.error('抖音支付失败', payErr)
+ tt.showToast({
+ title: '支付失败',
+ icon: 'none'
+ })
+ fail && fail(payErr)
+ }
+ })
+ } else {
+ tt.showToast({
+ title: res.data.message || '创建订单失败',
+ icon: 'none'
+ })
+ fail && fail(res)
+ }
+ },
+ fail: (err) => {
+ tt.hideLoading()
+ console.error('请求失败', err)
+ tt.showToast({
+ title: '网络请求失败',
+ icon: 'none'
+ })
+ fail && fail(err)
+ }
+ })
+}
+
+function notifyPaymentSuccess(orderId, prepayId) {
+ tt.request({
+ url: `${app.globalData.apiBase}/api/payment/notify`,
+ method: 'POST',
+ header: {
+ 'Authorization': `Bearer ${tt.getStorageSync('token')}`,
+ 'Content-Type': 'application/json'
+ },
+ data: {
+ orderId,
+ prepayId,
+ status: 'success'
+ },
+ success: (res) => {
+ console.log('支付通知成功', res)
+ },
+ fail: (err) => {
+ console.error('支付通知失败', err)
+ }
+ })
+}
+
+function queryOrderStatus(orderId, callback) {
+ tt.request({
+ url: `${app.globalData.apiBase}/api/payment/query`,
+ method: 'GET',
+ header: {
+ 'Authorization': `Bearer ${tt.getStorageSync('token')}`
+ },
+ data: { orderId },
+ success: (res) => {
+ if (res.statusCode === 200 && res.data.code === 200) {
+ callback && callback(true, res.data.data)
+ } else {
+ callback && callback(false, null)
+ }
+ },
+ fail: () => {
+ callback && callback(false, null)
+ }
+ })
+}
+
+function pollOrderStatus(orderId, maxAttempts = 5, intervalMs = 1000, done) {
+ let attempts = 0
+
+ const tick = () => {
+ attempts += 1
+ queryOrderStatus(orderId, (ok, order) => {
+ if (ok && order && (order.status === 'paid' || order.status === 'completed')) {
+ done && done(true, order)
+ return
+ }
+ if (attempts >= maxAttempts) {
+ done && done(false, order || null)
+ return
+ }
+ setTimeout(tick, intervalMs)
+ })
+ }
+
+ tick()
+}
+
+function purchaseVIP(vipType, success, fail) {
+ const prices = {
+ month: 1990,
+ quarter: 4990,
+ year: 9900,
+ lifetime: 19900,
+ personal_insight: 19800
+ }
+
+ const names = {
+ month: '月度VIP会员',
+ quarter: '季度VIP会员',
+ year: '年度VIP会员',
+ lifetime: '终身VIP会员',
+ personal_insight: '个人深度洞察版'
+ }
+
+ const orderId = generateOrderId('vip')
+
+ douyinPay({
+ orderId,
+ amount: prices[vipType],
+ description: `MBTI性格测试 - ${names[vipType]}`,
+ productType: 'vip',
+ success: (res) => {
+ updateVIPStatus(vipType)
+ success && success(res)
+ },
+ fail
+ })
+}
+
+function purchaseTestCount(count, success, fail) {
+ let price = count * 390
+ if (count >= 10) price = Math.floor(count * 290)
+ if (count >= 50) price = Math.floor(count * 198)
+
+ const orderId = generateOrderId('test_count')
+
+ douyinPay({
+ orderId,
+ amount: price,
+ description: `MBTI性格测试 - ${count}次测试次数`,
+ productType: 'test_count',
+ success: (res) => {
+ addTestCount(count)
+ success && success(res)
+ },
+ fail
+ })
+}
+
+function purchaseSingleTest(testType, success, fail) {
+ const prices = {
+ mbti: 990,
+ disc: 690,
+ pdp: 690,
+ ai: 1990
+ }
+
+ const names = {
+ mbti: 'MBTI性格测试',
+ disc: 'DISC行为风格测试',
+ pdp: 'PDP动物性格测试',
+ ai: '性格分析'
+ }
+
+ const orderId = generateOrderId(`single_${testType}`)
+
+ douyinPay({
+ orderId,
+ amount: prices[testType],
+ description: names[testType],
+ productType: 'single_test',
+ success: (res) => {
+ unlockTest(testType)
+ success && success(res)
+ },
+ fail
+ })
+}
+
+function purchaseByPricing(productType, description, extra, maybeFail) {
+ let opts = {}
+ if (typeof extra === 'function' || extra === undefined) {
+ opts.success = extra
+ opts.fail = maybeFail
+ } else {
+ opts = extra || {}
+ }
+
+ const { testResultId, success, fail } = opts
+ const orderId = generateOrderId(productType)
+
+ douyinPay({
+ orderId,
+ amount: 0,
+ description,
+ productType,
+ testResultId,
+ success,
+ fail
+ })
+}
+
+function purchaseFaceTest(arg1, arg2) {
+ let opts = {}
+ if (typeof arg1 === 'function' || arg1 === undefined) {
+ opts.success = arg1
+ opts.fail = arg2
+ } else {
+ opts = arg1 || {}
+ }
+ const { testResultId, success, fail } = opts
+ purchaseByPricing('face', '性格分析完整报告', { testResultId, success, fail })
+}
+
+function purchaseMbtiTest(arg1, arg2) {
+ const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
+ const { testResultId, success, fail } = opts
+ purchaseByPricing('mbti', 'MBTI性格测试付费版', { testResultId, success, fail })
+}
+
+function purchaseDiscTest(arg1, arg2) {
+ const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
+ const { testResultId, success, fail } = opts
+ purchaseByPricing('disc', 'DISC行为风格测试付费版', { testResultId, success, fail })
+}
+
+function purchasePdpTest(arg1, arg2) {
+ const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
+ const { testResultId, success, fail } = opts
+ purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail })
+}
+
+function purchaseResumeAnalysis(arg1, arg2) {
+ const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
+ const { testResultId, success, fail } = opts
+ purchaseByPricing('resume', '简历综合分析付费版', { testResultId, success, fail })
+}
+
+function purchaseFullReport(success, fail) {
+ purchaseByPricing('report', '完整人格与职业发展报告', success, fail)
+}
+
+function purchaseTeamAnalysis(success, fail) {
+ purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail)
+}
+
+function recharge(arg1, arg2, arg3) {
+ const opts = (typeof arg1 === 'object' && arg1 !== null)
+ ? arg1
+ : { amountYuan: arg1, success: arg2, fail: arg3 }
+ const safeAmount = Number(opts.amountYuan) || 0
+ const amount = Math.round(safeAmount * 100)
+ const orderId = generateOrderId('recharge')
+
+ douyinPay({
+ orderId,
+ amount,
+ description: `账户充值 ¥${safeAmount.toFixed(2)}`,
+ productType: 'recharge',
+ enterpriseId: opts.enterpriseId || 0,
+ success: opts.success,
+ fail: opts.fail
+ })
+}
+
+function purchasePersonalDeepService(arg1, arg2, arg3) {
+ let deepProductId = ''
+ let description = ''
+ let success
+ let fail
+
+ if (typeof arg1 === 'object' && arg1 !== null) {
+ deepProductId = arg1.deepProductId || ''
+ description = arg1.description || ''
+ success = arg1.success
+ fail = arg1.fail
+ } else {
+ deepProductId = typeof arg1 === 'string' ? arg1 : ''
+ success = arg2
+ fail = arg3
+ }
+
+ const desc = description || '个人深度服务(1v1深度解读)'
+ const orderId = generateOrderId('deep_personal')
+
+ douyinPay({
+ orderId,
+ amount: 0,
+ description: desc,
+ productType: 'deep_personal',
+ deepProductId,
+ success,
+ fail
+ })
+}
+
+function purchaseTeamDeepService(success, fail) {
+ const orderId = generateOrderId('deep_team')
+
+ douyinPay({
+ orderId,
+ amount: 0,
+ description: '团队深度服务(团队画像+策略)',
+ productType: 'deep_team',
+ success,
+ fail
+ })
+}
+
+function updateVIPStatus(vipType) {
+ const durations = {
+ month: 30,
+ quarter: 90,
+ year: 365,
+ lifetime: 36500
+ }
+
+ const expireDate = new Date()
+ expireDate.setDate(expireDate.getDate() + durations[vipType])
+
+ const vipInfo = {
+ isVIP: true,
+ vipType,
+ expireDate: expireDate.toISOString(),
+ purchaseDate: new Date().toISOString()
+ }
+
+ tt.setStorageSync('vipInfo', vipInfo)
+ app.globalData.vipInfo = vipInfo
+}
+
+function addTestCount(count) {
+ const currentCount = tt.getStorageSync('testCount') || 0
+ const newCount = currentCount + count
+ tt.setStorageSync('testCount', newCount)
+ app.globalData.testCount = newCount
+}
+
+function unlockTest(testType) {
+ const unlockedTests = tt.getStorageSync('unlockedTests') || []
+ if (!unlockedTests.includes(testType)) {
+ unlockedTests.push(testType)
+ tt.setStorageSync('unlockedTests', unlockedTests)
+ }
+ app.globalData.unlockedTests = unlockedTests
+}
+
+function checkVIP() {
+ const vipInfo = tt.getStorageSync('vipInfo')
+ if (!vipInfo || !vipInfo.isVIP) return false
+
+ const expireDate = new Date(vipInfo.expireDate)
+ return expireDate > new Date()
+}
+
+function canTakeTest(testType) {
+ return true
+}
+
+function consumeTestCount() {
+ const testCount = tt.getStorageSync('testCount') || 0
+ if (testCount > 0) {
+ tt.setStorageSync('testCount', testCount - 1)
+ app.globalData.testCount = testCount - 1
+ return true
+ }
+ return false
+}
+
+function getUserBenefits() {
+ return {
+ isVIP: checkVIP(),
+ vipInfo: tt.getStorageSync('vipInfo') || null,
+ testCount: tt.getStorageSync('testCount') || 0,
+ unlockedTests: tt.getStorageSync('unlockedTests') || []
+ }
+}
+
+module.exports = {
+ douyinPay,
+ queryOrderStatus,
+ pollOrderStatus,
+ generateOrderId,
+ purchaseVIP,
+ purchaseTestCount,
+ purchaseSingleTest,
+ purchaseFaceTest,
+ purchaseMbtiTest,
+ purchaseDiscTest,
+ purchasePdpTest,
+ purchaseResumeAnalysis,
+ purchaseFullReport,
+ purchaseTeamAnalysis,
+ recharge,
+ purchasePersonalDeepService,
+ purchaseTeamDeepService,
+ checkVIP,
+ canTakeTest,
+ consumeTestCount,
+ getUserBenefits,
+ updateVIPStatus,
+ addTestCount,
+ unlockTest
+}
diff --git a/miniprogram/app.js b/miniprogram/app.js
index 3b07c10..09e100b 100644
--- a/miniprogram/app.js
+++ b/miniprogram/app.js
@@ -9,6 +9,7 @@ App({
siteTitle: '神仙团队AI性格测试',
textConfig: null, // 从 /api/config/runtime 动态加载:analyzingTitle, startButtonText, reportTitle, aiAnalysisText 等
maintenanceMode: undefined, // 审核模式,undefined=未加载,等 getRuntimeConfig 后再决定,避免 tabBar 闪烁
+ reviewMode: undefined, // 面相审核开关,与 camera/index 一致,由 runtime.reviewMode 写入
// 当前使用范围:personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入)
appScope: 'personal',
// 扫码进入企业页时 scene 解析出的企业ID(e_123),提交测试/分析时优先使用
@@ -38,11 +39,14 @@ App({
// 静默登录获取openId
this.silentLogin()
- // 预加载站点/小程序名称、审核模式(供导航栏展示)
+ // 预加载站点/小程序名称、维护模式与面相审核模式(camera/首页文案)
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle
if (cfg.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!cfg.maintenanceMode
+ if (typeof cfg.reviewMode === 'boolean') {
+ this.globalData.reviewMode = cfg.reviewMode
+ }
if (cfg.defaultEnterpriseId != null && Number(cfg.defaultEnterpriseId) > 0) {
this.globalData.defaultEnterpriseId = Number(cfg.defaultEnterpriseId)
} else {
@@ -52,6 +56,20 @@ App({
}).catch(() => {})
},
+ onShow() {
+ try {
+ const { reportPageView } = require('./utils/analytics.js')
+ reportPageView()
+ } catch (e) {}
+ },
+
+ onHide() {
+ try {
+ const { flush } = require('./utils/analytics.js')
+ flush()
+ } catch (e) {}
+ },
+
// 加载本地存储数据
loadStoredData() {
const token = wx.getStorageSync('token')
@@ -338,6 +356,9 @@ App({
if (data.siteTitle) this.globalData.siteTitle = data.siteTitle
if (data.textConfig) this.globalData.textConfig = data.textConfig
if (data.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!data.maintenanceMode
+ if (typeof data.reviewMode === 'boolean') {
+ this.globalData.reviewMode = data.reviewMode
+ }
if (data.defaultEnterpriseId != null && Number(data.defaultEnterpriseId) > 0) {
this.globalData.defaultEnterpriseId = Number(data.defaultEnterpriseId)
} else {
diff --git a/miniprogram/app.json b/miniprogram/app.json
index 23195ab..58e19d4 100644
--- a/miniprogram/app.json
+++ b/miniprogram/app.json
@@ -39,7 +39,7 @@
},
{
"pagePath": "pages/index/camera",
- "text": "查看报告",
+ "text": "拍摄",
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},
diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js
index 81515df..a83ff7f 100644
--- a/miniprogram/custom-tab-bar/index.js
+++ b/miniprogram/custom-tab-bar/index.js
@@ -2,33 +2,23 @@
Component({
data: {
selected: 0,
- reviewMode: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' },
- { pagePath: '/pages/index/camera', text: '查看报告', textKey: 'camera', icon: 'camera' },
+ { pagePath: '/pages/index/camera', text: '拍摄', textKey: 'camera', icon: 'camera' },
{ pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' }
]
},
lifetimes: {
attached() {
this.updateSelected()
- this.checkReviewMode()
}
},
pageLifetimes: {
show() {
this.updateSelected()
- this.checkReviewMode()
}
},
methods: {
- checkReviewMode() {
- try {
- const app = getApp()
- const rm = !!(app && app.globalData && app.globalData.reviewMode)
- this.setData({ reviewMode: rm })
- } catch (e) {}
- },
updateSelected() {
try {
const pages = getCurrentPages()
@@ -66,13 +56,6 @@ Component({
} catch (e) {}
}
- // 审核模式:中间按钮跳转到测试选择页而非相机
- if (index === 1 && this.data.reviewMode) {
- wx.navigateTo({ url: '/pages/test-select/index' })
- this.setData({ selected: index })
- return
- }
-
wx.switchTab({ url })
this.setData({ selected: index })
}
diff --git a/miniprogram/custom-tab-bar/index.wxml b/miniprogram/custom-tab-bar/index.wxml
index cb4f441..42bf60c 100644
--- a/miniprogram/custom-tab-bar/index.wxml
+++ b/miniprogram/custom-tab-bar/index.wxml
@@ -1,4 +1,4 @@
-
+
@@ -11,30 +11,22 @@
首页
-
-
-
- 测试
-
-
-
-
-
+
+
+
+
+
+
+
+ 拍摄
-
+
{
- if (cfg && cfg.textConfig) {
- app.globalData.textConfig = cfg.textConfig
- this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析' })
+ }
+ app.getRuntimeConfig().then((cfg) => {
+ if (cfg) {
+ if (typeof cfg.reviewMode === 'boolean') {
+ app.globalData.reviewMode = cfg.reviewMode
}
- }).catch(() => {})
+ if (cfg.textConfig) {
+ app.globalData.textConfig = cfg.textConfig
+ this.setData({
+ aiAnalysisText: cfg.textConfig.aiAnalysisText || '分析',
+ reviewMode: !!app.globalData.reviewMode
+ })
+ return
+ }
+ }
+ this.setData({ reviewMode: !!app.globalData.reviewMode })
+ }).catch(() => {
+ this.setData({ reviewMode: !!app.globalData.reviewMode })
+ })
+ },
+
+ /** 非审核模式且相机在页上时再创建上下文 */
+ onReady() {
+ if (!app.globalData.reviewMode) {
+ this.initCameraContext()
+ }
+ },
+
+ goToQuestionnaire() {
+ wx.navigateTo({ url: '/pages/test-select/index' })
+ },
+
+ initCameraContext() {
+ try {
+ if (typeof wx.createCameraContext === 'function') {
+ this.cameraContext = wx.createCameraContext()
+ }
+ } catch (e) {
+ console.error('initCameraContext', e)
+ this.cameraContext = null
}
},
onShow() {
- // 审核模式下重定向到测试选择页
- if (app.globalData.reviewMode) {
- wx.navigateTo({ url: '/pages/test-select/index' })
- return
- }
- if (!ensureProfileCompleteAndRedirect()) return
+ const rm = !!app.globalData.reviewMode
+ this.setData({ reviewMode: rm })
+
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
}
+
+ // 审核模式:只展示本页引导,不再强跳 navigateTo(失败时曾导致白屏且无拍摄区)
+ if (rm) {
+ return
+ }
+
+ if (!ensureProfileCompleteAndRedirect()) {
+ return
+ }
+
+ if (this.data.photos.length < 3) {
+ this.initCameraContext()
+ }
+
this.setData({ needPhoneAuth: !hasPhone() })
const tc = app.globalData.textConfig
if (tc && tc.aiAnalysisText) {
@@ -77,6 +121,14 @@ Page({
return
}
+ if (!this.cameraContext) {
+ this.initCameraContext()
+ }
+ if (!this.cameraContext || typeof this.cameraContext.takePhoto !== 'function') {
+ wx.showToast({ title: '相机未就绪,请稍候再试', icon: 'none' })
+ return
+ }
+
this.cameraContext.takePhoto({
quality: 'high',
success: (res) => {
@@ -95,7 +147,17 @@ Page({
}
},
fail: (err) => {
- wx.showToast({ title: '拍照失败', icon: 'none' })
+ const msg = (err && (err.errMsg || err.message)) ? String(err.errMsg || err.message) : ''
+ if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
+ wx.showModal({
+ title: '需要相机权限',
+ content: '请在设置中允许使用摄像头',
+ confirmText: '去设置',
+ success: (r) => { if (r.confirm) wx.openSetting() }
+ })
+ } else {
+ wx.showToast({ title: '拍照失败,请重试', icon: 'none' })
+ }
console.error('拍照失败:', err)
}
})
@@ -116,6 +178,7 @@ Page({
guideText: '请正对镜头'
})
wx.showToast({ title: '已清空,请重新拍摄', icon: 'success' })
+ setTimeout(() => this.initCameraContext(), 200)
}
}
})
@@ -123,7 +186,6 @@ Page({
// 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页
completeCapture() {
- if (!ensureProfileCompleteAndRedirect()) return
if (!hasPhone()) {
wx.showToast({ title: '请先授权手机号', icon: 'none' })
this.setData({ needPhoneAuth: true })
diff --git a/miniprogram/pages/index/camera.json b/miniprogram/pages/index/camera.json
index 88e3bdf..e0985ca 100644
--- a/miniprogram/pages/index/camera.json
+++ b/miniprogram/pages/index/camera.json
@@ -1,6 +1,8 @@
-{
- "navigationBarTitleText": "拍照",
- "navigationBarBackgroundColor": "#FFFFFF",
- "navigationBarTextStyle": "black",
- "usingComponents": {}
-}
+{
+ "navigationBarTitleText": "拍摄",
+ "navigationBarBackgroundColor": "#FFFFFF",
+ "navigationBarTextStyle": "black",
+ "usingComponents": {
+ "custom-tab-bar": "/custom-tab-bar/index"
+ }
+}
diff --git a/miniprogram/pages/index/camera.wxml b/miniprogram/pages/index/camera.wxml
index 014275a..5446c68 100644
--- a/miniprogram/pages/index/camera.wxml
+++ b/miniprogram/pages/index/camera.wxml
@@ -1,69 +1,80 @@
-
-
-
-
- 步骤 {{photoIndex + 1}}/3
- {{photos.length}}/3 张照片已完成
-
-
-
-
-
-
-
-
- {{photoIndex + 1}}
-
- {{guideText}}
- 请保持自然表情,确保光线充足
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}
-
-
-
-
-
-
-
- 拍摄{{guideText}}照片
-
-
- 从相册选择
-
-
-
-
-
- 重新拍摄
-
-
- 立即{{aiAnalysisText || '分析'}}
-
-
-
-
-
-
- 为保障服务与联系,请先授权手机号。
-
-
-
+
+
+
+
+
+ 问卷审核模式
+ 当前未开放实时拍摄面相,请先做 MBTI / DISC / PDP 等问卷测试;后台关闭「审核模式」后即可使用拍摄报告。
+ 去做性格测试
+
+
+
+
+
+ 步骤 {{photoIndex + 1}}/3
+ {{photos.length}}/3 张照片已完成
+
+
+
+
+
+
+
+
+ {{photoIndex + 1}}
+
+ {{guideText}}
+ 请保持自然表情,确保光线充足
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}
+
+
+
+
+
+
+
+ 拍摄{{guideText}}照片
+
+
+ 从相册选择
+
+
+
+
+
+ 重新拍摄
+
+
+ 立即{{aiAnalysisText || '分析'}}
+
+
+
+
+
+
+ 为保障服务与联系,请先授权手机号。
+
+
+
+
+
diff --git a/miniprogram/pages/index/camera.wxss b/miniprogram/pages/index/camera.wxss
index 5430ea3..92e3505 100644
--- a/miniprogram/pages/index/camera.wxss
+++ b/miniprogram/pages/index/camera.wxss
@@ -1,312 +1,367 @@
-/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar(含中间浮起圆钮)预留空间 */
-.container {
- width: 100%;
- height: 100vh;
- height: 100dvh;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background-color: #fff;
- /* 预留底部空间,避免与自定义 tabBar(约 100rpx 高 + 中间圆钮上浮约 56rpx)重叠 */
- padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
-}
-
-.progress-section {
- flex-shrink: 0;
- margin: 16rpx 24rpx 0;
- padding: 20rpx 32rpx 16rpx;
- border-radius: 10rpx;
- background: linear-gradient(to right, #fff5f5, #ffe5e8);
-}
-
-.progress-info {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 10rpx;
-}
-
-.step-text {
- font-size: 26rpx;
- font-weight: 600;
- color: #e63946;
-}
-
-.photo-count {
- font-size: 22rpx;
- color: #999;
-}
-
-.progress-bars {
- display: flex;
- gap: 12rpx;
- margin-bottom: 12rpx;
-}
-
-.progress-bar {
- flex: 1;
- height: 6rpx;
- border-radius: 6rpx;
-}
-
-.progress-bar.completed {
- background-color: #52c41a;
-}
-
-.progress-bar.pending {
- background-color: #e5e5e5;
-}
-
-.instruction-card {
- background: rgba(255, 255, 255, 0.9);
- border-radius: 12rpx;
- padding: 14rpx 18rpx;
- border: 1rpx solid rgba(230, 57, 70, 0.1);
-}
-
-.instruction-content {
- display: flex;
- align-items: center;
- gap: 12rpx;
-}
-
-.step-number {
- width: 40rpx;
- height: 40rpx;
- flex-shrink: 0;
- border-radius: 50%;
- background-color: #e63946;
- color: #fff;
- font-size: 24rpx;
- font-weight: bold;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.instruction-text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2rpx;
-}
-
-.angle-text {
- font-size: 26rpx;
- font-weight: 600;
- color: #c41d2a;
-}
-
-.tip-text {
- font-size: 22rpx;
- color: #666;
- line-height: 1.3;
-}
-
-.camera-container {
- flex: 1;
- min-height: 0;
- padding: 16rpx 24rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.camera-preview {
- width: 100%;
- max-width: 100%;
- max-height: 100%;
- aspect-ratio: 1;
- border-radius: 32rpx;
- overflow: hidden;
- border: 6rpx solid #e5e5e5;
- background-color: #000;
- position: relative;
-}
-
-.camera {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
-}
-
-.photos-preview {
- width: 100%;
- height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- gap: 12rpx;
-}
-
-.photo-item {
- position: relative;
- flex: 1;
- min-width: 0;
- height: 100%;
- max-height: 100%;
- border-radius: 16rpx;
- overflow: hidden;
- border: 4rpx solid #e5e5e5;
-}
-
-.photo-image {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.photo-label {
- position: absolute;
- top: 8rpx;
- left: 8rpx;
- background: rgba(230, 57, 70, 0.9);
- color: #fff;
- font-size: 20rpx;
- font-weight: 600;
- padding: 4rpx 12rpx;
- border-radius: 12rpx;
-}
-
-.button-container {
- flex-shrink: 0;
- padding: 16rpx 24rpx 0;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.capture-actions {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 20rpx;
-}
-
-.capture-actions .capture-button {
- width: 100%;
-}
-
-.album-button {
- width: 100%;
- padding: 20rpx;
- border-radius: 50rpx;
- text-align: center;
- background: #fff;
- border: 2rpx solid #e63946;
- box-sizing: border-box;
-}
-
-.album-button:active {
- background: #fff5f5;
-}
-
-.album-button-text {
- font-size: 28rpx;
- font-weight: 600;
- color: #e63946;
- letter-spacing: 1rpx;
-}
-
-.phone-auth-section {
- padding: 24rpx 24rpx 0;
- display: flex;
- flex-direction: column;
- gap: 16rpx;
-}
-
-.phone-auth-tip {
- font-size: 26rpx;
- color: #4b5563;
-}
-
-.phone-auth-btn {
- height: 80rpx;
- line-height: 80rpx;
- border-radius: 40rpx;
- background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
- color: #ffffff;
- font-size: 28rpx;
-}
-
-.phone-auth-btn::after {
- border: none;
-}
-
-.capture-button {
- width: 100%;
- max-width: 100%;
- padding: 22rpx;
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- border-radius: 50rpx;
- text-align: center;
- box-sizing: border-box;
- box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
-}
-
-.button-text {
- font-size: 30rpx;
- font-weight: 700;
- color: #fff;
- letter-spacing: 1rpx;
-}
-
-.action-buttons {
- display: flex;
- gap: 20rpx;
- width: 100%;
-}
-
-.action-button {
- flex: 1;
- padding: 28rpx 24rpx;
- border-radius: 50rpx;
- text-align: center;
- box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
- transition: all 0.3s ease;
-}
-
-.action-button.secondary {
- background: #fff;
- border: 2rpx solid #e63946;
-}
-
-.action-button.secondary:active {
- background: #fff5f5;
- transform: scale(0.98);
-}
-
-.action-button.primary {
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
-}
-
-.action-button.primary:active {
- transform: scale(0.98);
- box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
-}
-
-.action-button-text {
- font-size: 28rpx;
- font-weight: 700;
- letter-spacing: 1rpx;
-}
-
-.action-button.secondary .action-button-text {
- color: #e63946;
-}
-
-.action-button.primary .action-button-text {
- color: #fff;
-}
+/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar(含中间浮起圆钮)预留空间 */
+.camera-page-root {
+ min-height: 100vh;
+ min-height: 100dvh;
+ position: relative;
+}
+
+.container {
+ width: 100%;
+ height: 100vh;
+ height: 100dvh;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background-color: #fff;
+ /* 预留底部空间,避免与自定义 tabBar(约 100rpx 高 + 中间圆钮上浮约 56rpx)重叠 */
+ padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
+}
+
+.progress-section {
+ flex-shrink: 0;
+ margin: 16rpx 24rpx 0;
+ padding: 20rpx 32rpx 16rpx;
+ border-radius: 10rpx;
+ background: linear-gradient(to right, #fff5f5, #ffe5e8);
+}
+
+.progress-info {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10rpx;
+}
+
+.step-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #e63946;
+}
+
+.photo-count {
+ font-size: 22rpx;
+ color: #999;
+}
+
+.progress-bars {
+ display: flex;
+ gap: 12rpx;
+ margin-bottom: 12rpx;
+}
+
+.progress-bar {
+ flex: 1;
+ height: 6rpx;
+ border-radius: 6rpx;
+}
+
+.progress-bar.completed {
+ background-color: #52c41a;
+}
+
+.progress-bar.pending {
+ background-color: #e5e5e5;
+}
+
+.instruction-card {
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 12rpx;
+ padding: 14rpx 18rpx;
+ border: 1rpx solid rgba(230, 57, 70, 0.1);
+}
+
+.instruction-content {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+}
+
+.step-number {
+ width: 40rpx;
+ height: 40rpx;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background-color: #e63946;
+ color: #fff;
+ font-size: 24rpx;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.instruction-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2rpx;
+}
+
+.angle-text {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #c41d2a;
+}
+
+.tip-text {
+ font-size: 22rpx;
+ color: #666;
+ line-height: 1.3;
+}
+
+.camera-container {
+ flex: 1;
+ min-height: 0;
+ padding: 16rpx 24rpx;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.camera-preview {
+ width: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: 1;
+ border-radius: 32rpx;
+ overflow: hidden;
+ border: 6rpx solid #e5e5e5;
+ background-color: #000;
+ position: relative;
+}
+
+.camera {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.photos-preview {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 12rpx;
+}
+
+.photo-item {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ max-height: 100%;
+ border-radius: 16rpx;
+ overflow: hidden;
+ border: 4rpx solid #e5e5e5;
+}
+
+.photo-image {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.photo-label {
+ position: absolute;
+ top: 8rpx;
+ left: 8rpx;
+ background: rgba(230, 57, 70, 0.9);
+ color: #fff;
+ font-size: 20rpx;
+ font-weight: 600;
+ padding: 4rpx 12rpx;
+ border-radius: 12rpx;
+}
+
+.button-container {
+ flex-shrink: 0;
+ padding: 16rpx 24rpx 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.capture-actions {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20rpx;
+}
+
+.capture-actions .capture-button {
+ width: 100%;
+}
+
+.album-button {
+ width: 100%;
+ padding: 20rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ background: #fff;
+ border: 2rpx solid #e63946;
+ box-sizing: border-box;
+}
+
+.album-button:active {
+ background: #fff5f5;
+}
+
+.album-button-text {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: #e63946;
+ letter-spacing: 1rpx;
+}
+
+.phone-auth-section {
+ padding: 24rpx 24rpx 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.phone-auth-tip {
+ font-size: 26rpx;
+ color: #4b5563;
+}
+
+.phone-auth-btn {
+ height: 80rpx;
+ line-height: 80rpx;
+ border-radius: 40rpx;
+ background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
+ color: #ffffff;
+ font-size: 28rpx;
+}
+
+.phone-auth-btn::after {
+ border: none;
+}
+
+.capture-button {
+ width: 100%;
+ max-width: 100%;
+ padding: 22rpx;
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ border-radius: 50rpx;
+ text-align: center;
+ box-sizing: border-box;
+ box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
+}
+
+.button-text {
+ font-size: 30rpx;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 1rpx;
+}
+
+.action-buttons {
+ display: flex;
+ gap: 20rpx;
+ width: 100%;
+}
+
+.action-button {
+ flex: 1;
+ padding: 28rpx 24rpx;
+ border-radius: 50rpx;
+ text-align: center;
+ box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+}
+
+.action-button.secondary {
+ background: #fff;
+ border: 2rpx solid #e63946;
+}
+
+.action-button.secondary:active {
+ background: #fff5f5;
+ transform: scale(0.98);
+}
+
+.action-button.primary {
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4);
+}
+
+.action-button.primary:active {
+ transform: scale(0.98);
+ box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3);
+}
+
+.action-button-text {
+ font-size: 28rpx;
+ font-weight: 700;
+ letter-spacing: 1rpx;
+}
+
+.action-button.secondary .action-button-text {
+ color: #e63946;
+}
+
+.action-button.primary .action-button-text {
+ color: #fff;
+}
+
+/* 审核模式引导(替代白屏) */
+.review-mode-panel {
+ justify-content: center;
+ align-items: center;
+ padding: 48rpx 40rpx;
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
+}
+
+.review-mode-card {
+ width: 100%;
+ max-width: 620rpx;
+ padding: 48rpx 40rpx;
+ background: #fff;
+ border-radius: 24rpx;
+ box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.06);
+ border: 1rpx solid rgba(0, 0, 0, 0.04);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24rpx;
+}
+
+.review-mode-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.review-mode-desc {
+ font-size: 28rpx;
+ color: #64748b;
+ line-height: 1.65;
+ text-align: center;
+}
+
+.review-mode-btn {
+ margin-top: 16rpx;
+ padding: 24rpx 56rpx;
+ border-radius: 999rpx;
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ color: #fff;
+ font-size: 30rpx;
+ font-weight: 600;
+}
+
+.review-mode-btn:active {
+ opacity: 0.9;
+}
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index 1d77a9d..8d84095 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -29,7 +29,7 @@ Page({
navbarHeight: navbarHeightRpx,
showEnterpriseEntry: userInfo.hasEnterprise === true,
siteTitle: gd.reviewMode ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
- startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
+ startButtonText: gd.reviewMode ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: gd.reviewMode ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: !!gd.reviewMode
})
@@ -45,7 +45,7 @@ Page({
if (cfg.textConfig) {
getApp().globalData.textConfig = cfg.textConfig
this.setData({
- startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '开始面相测试'),
+ startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '拍摄'),
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
})
}
@@ -100,7 +100,7 @@ Page({
const rm = !!gd.reviewMode
this.setData({
siteTitle: rm ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
- startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试'),
+ startButtonText: rm ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
aiAnalysisText: rm ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm
})
@@ -124,14 +124,10 @@ Page({
}
},
- // 开始测试(审核模式跳问卷,正常模式跳拍照)
+ // 与底栏中间一致:始终进入拍摄 Tab(审核态在 camera 页内展示问卷引导)
startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {}
- if (this.data.reviewMode) {
- wx.navigateTo({ url: '/pages/test-select/index' })
- } else {
- wx.switchTab({ url: '/pages/index/camera' })
- }
+ wx.switchTab({ url: '/pages/index/camera' })
},
// 上传照片(个人版入口:强制本次链路为个人定价)
diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss
index 89b89b9..ccf696c 100644
--- a/miniprogram/pages/index/index.wxss
+++ b/miniprogram/pages/index/index.wxss
@@ -1,305 +1,304 @@
-/* pages/index/index.wxss - 个人版首页,样式与企业版一致 */
-.container {
- min-height: 100vh;
- width: 100vw;
- overflow-x: hidden;
- overflow-y: auto;
- background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
- position: relative;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
- padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx);
-}
-
-/* 背景装饰圆形(与企业版一致) */
-.bg-decoration {
- position: absolute;
- width: 600rpx;
- height: 600rpx;
- border-radius: 50%;
- pointer-events: none;
- z-index: 0;
-}
-
-.bg-top-right {
- top: -200rpx;
- right: -200rpx;
- background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
-}
-
-.bg-bottom-left {
- bottom: 0;
- left: 0;
- background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
-}
-
-/* 自定义导航栏 */
-.custom-navbar {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- background: #ffffff;
- z-index: 10000;
- width: 100%;
-}
-
-.navbar-content {
- height: 88rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 40rpx;
- position: relative;
- background: #ffffff;
- min-height: 88rpx;
-}
-
-.navbar-title {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- font-size: 36rpx;
- font-weight: 700;
- color: #e63946;
- text-align: center;
- flex: 1;
-}
-
-.navbar-placeholder {
- width: 140rpx;
- flex-shrink: 0;
-}
-
-/* 与企业版切换按钮同款样式(红主色) */
-.switch-enterprise-btn {
- display: flex;
- align-items: center;
- gap: 8rpx;
- padding: 12rpx 20rpx;
- background: rgba(230, 57, 70, 0.1);
- border-radius: 30rpx;
- border: 1rpx solid rgba(230, 57, 70, 0.2);
- flex-shrink: 0;
- z-index: 10;
-}
-
-.enterprise-icon {
- font-size: 28rpx;
-}
-
-.enterprise-text {
- font-size: 24rpx;
- color: #e63946;
- font-weight: 600;
-}
-
-.top-image-section {
- width: 100%;
- padding: 15rpx 40rpx 20rpx;
- flex-shrink: 0;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
- margin-top: 0;
-}
-
-.image-container {
- position: relative;
- width: 100%;
-}
-
-.image-wrapper {
- position: relative;
- width: 100%;
- padding-top: 100%;
- border-radius: 50%;
- overflow: hidden;
- background: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
-}
-
-.main-image {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 85%;
- height: 85%;
- display: block;
-}
-
-.float-tag {
- position: absolute;
- padding: 12rpx 24rpx;
- border-radius: 30rpx;
- font-size: 24rpx;
- font-weight: 600;
- color: #e63946;
- background: rgba(255, 255, 255, 0.95);
- box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
- white-space: nowrap;
- z-index: 10;
-}
-
-.tag-1 {
- top: 15%;
- right: 10rpx;
-}
-
-.tag-2 {
- bottom: 25%;
- left: 0;
-}
-
-.tag-3 {
- bottom: 25%;
- right: 0;
-}
-
-.process-section {
- padding: 15rpx 40rpx 20rpx;
- flex-shrink: 0;
- position: relative;
- z-index: 1;
-}
-
-.section-title {
- font-size: 32rpx;
- font-weight: 700;
- color: #e63946;
- text-align: center;
- margin-bottom: 25rpx;
-}
-
-.process-steps {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 10rpx;
-}
-
-.step-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex: 1;
-}
-
-.step-circle {
- width: 70rpx;
- height: 70rpx;
- border-radius: 50%;
- background: #b8b9bc;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 36rpx;
- font-weight: 700;
- color: #fff;
- margin-bottom: 12rpx;
-}
-
-.step-circle.active {
- background: #e63946;
-}
-
-.step-label {
- font-size: 20rpx;
- color: #666;
- margin-bottom: 4rpx;
-}
-
-.step-text {
- font-size: 24rpx;
- color: #333;
- font-weight: 600;
-}
-
-.step-line {
- width: 50rpx;
- height: 4rpx;
- background: #e0e0e0;
- margin: 0 6rpx 50rpx;
-}
-
-.start-button {
- margin: 20rpx 40rpx 0;
- background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
- border-radius: 50rpx;
- padding: 24rpx;
- text-align: center;
- box-shadow: 0 8rpx 30rpx rgba(230, 57, 70, 0.3);
- position: relative;
- z-index: 1;
-}
-
-.button-text {
- font-size: 30rpx;
- color: #fff;
- font-weight: 700;
-}
-
-.upload-button {
- margin: 0 40rpx 16rpx;
- border: 2rpx solid #8b5cf6;
- border-radius: 50rpx;
- padding: 18rpx;
- text-align: center;
- background: #fff;
- flex-shrink: 0;
-}
-
-.upload-text {
- font-size: 26rpx;
- color: #8b5cf6;
- font-weight: 600;
-}
-
-/* AI 生成内容标识横幅(监管合规,始终显示) */
-.ai-disclosure-banner {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12rpx;
- margin: 24rpx 40rpx 16rpx;
- padding: 16rpx 24rpx;
- background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 100%);
- border: 1rpx solid #c7d2fe;
- border-radius: 12rpx;
- position: relative;
- z-index: 1;
-}
-
-.ai-disclosure-icon {
- width: 44rpx;
- height: 44rpx;
- border-radius: 8rpx;
- background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
- color: #fff;
- font-size: 20rpx;
- font-weight: 700;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
-}
-
-.ai-disclosure-text {
- font-size: 22rpx;
- color: #4338ca;
- line-height: 1.4;
-}
-
-/* 确保底部导航显示 */
-custom-tab-bar {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 9999;
-}
+/* pages/index/index.wxss - 个人版首页,样式与企业版一致 */
+.container {
+ min-height: 100vh;
+ width: 100vw;
+ overflow-x: hidden;
+ overflow-y: auto;
+ background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ /* 底栏 + 中间浮钮留白,避免与主按钮叠在一起 */
+ padding-bottom: calc(168rpx + env(safe-area-inset-bottom) + 48rpx);
+}
+
+/* 背景装饰圆形(与企业版一致) */
+.bg-decoration {
+ position: absolute;
+ width: 600rpx;
+ height: 600rpx;
+ border-radius: 50%;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.bg-top-right {
+ top: -200rpx;
+ right: -200rpx;
+ background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
+}
+
+.bg-bottom-left {
+ bottom: 0;
+ left: 0;
+ background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
+}
+
+/* 自定义导航栏 */
+.custom-navbar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: #ffffff;
+ z-index: 10000;
+ width: 100%;
+}
+
+.navbar-content {
+ height: 88rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 40rpx;
+ position: relative;
+ background: #ffffff;
+ min-height: 88rpx;
+}
+
+.navbar-title {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #e63946;
+ text-align: center;
+ flex: 1;
+}
+
+.navbar-placeholder {
+ width: 140rpx;
+ flex-shrink: 0;
+}
+
+/* 与企业版切换按钮同款样式(红主色) */
+.switch-enterprise-btn {
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+ padding: 12rpx 20rpx;
+ background: rgba(230, 57, 70, 0.1);
+ border-radius: 30rpx;
+ border: 1rpx solid rgba(230, 57, 70, 0.2);
+ flex-shrink: 0;
+ z-index: 10;
+}
+
+.enterprise-icon {
+ font-size: 28rpx;
+}
+
+.enterprise-text {
+ font-size: 24rpx;
+ color: #e63946;
+ font-weight: 600;
+}
+
+.top-image-section {
+ width: 100%;
+ padding: 15rpx 40rpx 20rpx;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 1;
+ margin-top: 0;
+}
+
+.image-container {
+ position: relative;
+ width: 100%;
+}
+
+.image-wrapper {
+ position: relative;
+ width: 100%;
+ padding-top: 100%;
+ border-radius: 50%;
+ overflow: hidden;
+ background: #ffffff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
+}
+
+.main-image {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 85%;
+ height: 85%;
+ display: block;
+}
+
+.float-tag {
+ position: absolute;
+ padding: 12rpx 24rpx;
+ border-radius: 30rpx;
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #e63946;
+ background: rgba(255, 255, 255, 0.95);
+ box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2);
+ white-space: nowrap;
+ z-index: 10;
+}
+
+.tag-1 {
+ top: 15%;
+ right: 10rpx;
+}
+
+.tag-2 {
+ bottom: 25%;
+ left: 0;
+}
+
+.tag-3 {
+ bottom: 25%;
+ right: 0;
+}
+
+.process-section {
+ padding: 15rpx 40rpx 20rpx;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+}
+
+.section-title {
+ font-size: 32rpx;
+ font-weight: 700;
+ color: #e63946;
+ text-align: center;
+ margin-bottom: 25rpx;
+}
+
+.process-steps {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 10rpx;
+}
+
+.step-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+}
+
+.step-circle {
+ width: 70rpx;
+ height: 70rpx;
+ border-radius: 50%;
+ background: #b8b9bc;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: 12rpx;
+}
+
+.step-circle.active {
+ background: #e63946;
+}
+
+.step-label {
+ font-size: 20rpx;
+ color: #666;
+ margin-bottom: 4rpx;
+}
+
+.step-text {
+ font-size: 24rpx;
+ color: #333;
+ font-weight: 600;
+}
+
+.step-line {
+ width: 50rpx;
+ height: 4rpx;
+ background: #e0e0e0;
+ margin: 0 6rpx 50rpx;
+}
+
+.start-button {
+ margin: 20rpx 40rpx 0;
+ background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
+ border-radius: 50rpx;
+ padding: 24rpx;
+ text-align: center;
+ box-shadow: 0 8rpx 30rpx rgba(230, 57, 70, 0.3);
+ position: relative;
+ z-index: 1;
+}
+
+.button-text {
+ font-size: 30rpx;
+ color: #fff;
+ font-weight: 700;
+}
+
+.upload-button {
+ margin: 0 40rpx 16rpx;
+ border: 2rpx solid #8b5cf6;
+ border-radius: 50rpx;
+ padding: 18rpx;
+ text-align: center;
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.upload-text {
+ font-size: 26rpx;
+ color: #8b5cf6;
+ font-weight: 600;
+}
+
+/* AI 生成内容标识横幅(监管合规,始终显示) */
+.ai-disclosure-banner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12rpx;
+ margin: 24rpx 40rpx 16rpx;
+ padding: 16rpx 24rpx;
+ background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 100%);
+ border: 1rpx solid #c7d2fe;
+ border-radius: 12rpx;
+ position: relative;
+ z-index: 1;
+}
+
+.ai-disclosure-icon {
+ width: 44rpx;
+ height: 44rpx;
+ border-radius: 8rpx;
+ background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+ color: #fff;
+ font-size: 20rpx;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.ai-disclosure-text {
+ font-size: 22rpx;
+ color: #4338ca;
+ line-height: 1.4;
+}
+
+/* 自定义 tab 由内层 .tab-bar fixed,避免与组件双重 fixed 导致错位 */
+custom-tab-bar {
+ display: block;
+ height: 0;
+ overflow: visible;
+}
diff --git a/miniprogram/pages/index/upload.js b/miniprogram/pages/index/upload.js
index 6820ae1..c55b27b 100644
--- a/miniprogram/pages/index/upload.js
+++ b/miniprogram/pages/index/upload.js
@@ -39,7 +39,9 @@ Page({
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
- if (!ensureProfileCompleteAndRedirect()) return
+ if (!ensureProfileCompleteAndRedirect()) {
+ return
+ }
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 })
}
@@ -170,7 +172,11 @@ Page({
// 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页
completeCapture() {
- if (!ensureProfileCompleteAndRedirect()) return
+ if (!hasPhone()) {
+ wx.showToast({ title: '请先授权手机号', icon: 'none' })
+ this.setData({ needPhoneAuth: true })
+ return
+ }
const urls = (this.data.uploadedUrls || []).filter(Boolean)
if (!urls.length) {
wx.showToast({ title: '请先上传至少一张照片', icon: 'none' })
diff --git a/miniprogram/pages/index/upload.wxml b/miniprogram/pages/index/upload.wxml
index 3452d40..3179dc4 100644
--- a/miniprogram/pages/index/upload.wxml
+++ b/miniprogram/pages/index/upload.wxml
@@ -1,113 +1,113 @@
-
-
-
-
-
-
- 多角度拍摄更精准
-
- 为了更准确地分析您的性格特征,
- 请上传以下三个维度的照片。
-
-
-
-
-
-
-
-
- 1
-
-
- 正面
- 目视前方,五官端正,不遮挡额头
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
- 2
-
-
- 左侧 45度
- 展示左侧面颊,用于分析面部轮廓
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
- 3
-
-
- 右侧 45度
- 展示右侧面颊,完整捕捉面部信息
-
-
-
-
-
- 📷
- 点击上传
-
-
-
-
-
-
-
-
-
-
-
- 温馨提示
-
-
- · 光线充足,背景整洁,避免过曝或过暗
- · 保持表情自然,无需刻意微笑
- · 如有佩戴眼镜,请确保镜片不反光
-
-
-
-
-
- 为保障服务与联系,请先授权手机号。
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ 多角度拍摄更精准
+
+ 为了更准确地分析您的性格特征,
+ 请上传以下三个维度的照片。
+
+
+
+
+
+
+
+
+ 1
+
+
+ 正面
+ 目视前方,五官端正,不遮挡额头
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+ 2
+
+
+ 左侧 45度
+ 展示左侧面颊,用于分析面部轮廓
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+ 3
+
+
+ 右侧 45度
+ 展示右侧面颊,完整捕捉面部信息
+
+
+
+
+
+ 📷
+ 点击上传
+
+
+
+
+
+
+
+
+
+
+
+ 温馨提示
+
+
+ · 光线充足,背景整洁,避免过曝或过暗
+ · 保持表情自然,无需刻意微笑
+ · 如有佩戴眼镜,请确保镜片不反光
+
+
+
+
+
+ 为保障服务与联系,请先授权手机号。
+
+
+
+
+
+
+
+
+
+
diff --git a/miniprogram/pages/phone-auth/index.wxml b/miniprogram/pages/phone-auth/index.wxml
index 3b5d05e..b158667 100644
--- a/miniprogram/pages/phone-auth/index.wxml
+++ b/miniprogram/pages/phone-auth/index.wxml
@@ -1,5 +1,5 @@
-
- 为保障服务与联系需要,请授权您的手机号。
- 授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。
-
-
+
+ 为保障服务与联系需要,请授权您的手机号。
+ 授权后可在「拍摄」「支付」「企业咨询」等场景使用,仅需授权一次。
+
+
diff --git a/miniprogram/pages/profile/index.js b/miniprogram/pages/profile/index.js
index 4b0cf8e..7647c62 100644
--- a/miniprogram/pages/profile/index.js
+++ b/miniprogram/pages/profile/index.js
@@ -258,10 +258,17 @@ Page({
},
goToIndex() { wx.switchTab({ url: '/pages/index/index' }) },
- goToCamera() { wx.switchTab({ url: '/pages/index/camera' }) },
- goToHistory() { wx.navigateTo({ url: '/pages/history/index' }) },
+ goToHistory() {
+ try { require('../../utils/analytics').track('tap_test_history', {}) } catch (e) {}
+ wx.navigateTo({ url: '/pages/history/index' })
+ },
goToUserProfile() { wx.navigateTo({ url: '/pages/user-profile/index' }) },
- goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
+ /** 深度服务统一入口(页内 Tab:个人 / 团队与企业) */
+ goToDeepService() {
+ try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
+ wx.navigateTo({ url: '/pages/purchase/index' })
+ },
+ goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { wx.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToEnterprise() { wx.navigateTo({ url: '/pages/enterprise/index' }) },
diff --git a/miniprogram/pages/profile/index.json b/miniprogram/pages/profile/index.json
index 2c69765..57a864e 100644
--- a/miniprogram/pages/profile/index.json
+++ b/miniprogram/pages/profile/index.json
@@ -1,6 +1,8 @@
-{
- "navigationBarTitleText": "",
- "navigationBarBackgroundColor": "#F9FAFB",
- "navigationBarTextStyle": "black",
- "usingComponents": {}
-}
+{
+ "navigationBarTitleText": "",
+ "navigationBarBackgroundColor": "#F9FAFB",
+ "navigationBarTextStyle": "black",
+ "usingComponents": {
+ "custom-tab-bar": "/custom-tab-bar/index"
+ }
+}
diff --git a/miniprogram/pages/profile/index.wxml b/miniprogram/pages/profile/index.wxml
index e1f9cf0..f280009 100644
--- a/miniprogram/pages/profile/index.wxml
+++ b/miniprogram/pages/profile/index.wxml
@@ -68,132 +68,75 @@
›
-
-
-
-
-
-
-
-
-
- 🧠
-
- MBTI性格
- {{mbtiType}}
- {{mbtiTime}}
-
-
-
-
-
- 📊
-
- DISC测评
- {{discType}}型
- {{discTime}}
-
-
-
-
-
- 🦁
-
- PDP行为
- {{pdpType}}
- {{pdpTime}}
-
-
-
-
-
- 👁️
-
- 面相分析
- {{aiType}}
- {{aiTime}}
+
+
+ 深度解析
+
+
-
-
-
-
-
-
-
-
-
-
-
- 深度服务
-