feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

View File

@@ -31,6 +31,14 @@ class AppUser extends BaseController
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
$coldFaceLevels = [];
if (is_array($coldFaceLevelRaw)) {
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
}
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
$where = [];
if ($keyword !== '') {
@@ -72,6 +80,19 @@ class AppUser extends BaseController
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
if (!empty($coldFaceLevels)) {
try {
$cfSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->whereIn('coldFaceLevel', $coldFaceLevels)
->field('userId')
->buildSql(true);
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
} catch (\Throwable $e) {
// coldFace 字段不存在时忽略筛选
}
}
if ($keyword !== '') {
$like = '%' . addcslashes($keyword, '%_\\') . '%';
$baseQuery->whereRaw(
@@ -215,6 +236,31 @@ class AppUser extends BaseController
} catch (\Throwable $e) {
$payStats = [];
}
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
$coldFaceMap = [];
try {
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRows = $cfQuery
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
->select()
->toArray();
foreach ($cfRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$coldFaceMap[$uid] = [
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
'level' => $r['coldFaceLevel'] ?? null,
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
];
}
}
} catch (\Throwable $e) {
$coldFaceMap = [];
}
}
foreach ($list as &$row) {
@@ -236,6 +282,18 @@ class AppUser extends BaseController
$pay = $payStats[$id] ?? null;
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
$cf = $coldFaceMap[$id] ?? null;
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
$calc = $this->calcColdFace($testsForUser);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
}
return paginate_response($list, $total, $page, $pageSize);
@@ -322,6 +380,35 @@ class AppUser extends BaseController
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
// 冷脸分字段(详情)
$coldFace = null;
try {
$cfQuery = Db::name('user_profile')->where('userId', $id);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
if ($cfRow) {
$coldFace = [
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
'level' => $cfRow['coldFaceLevel'] ?? null,
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
];
}
} catch (\Throwable $e) {
$coldFace = null;
}
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
$calc = $this->calcColdFace($tests);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$data['coldFaceScore'] = $coldFace['score'] ?? null;
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
return success($data);
}

View File

@@ -36,7 +36,7 @@ class Finance extends BaseController
$baseOrderQuery = Db::name('orders')
->where('enterpriseId', $enterpriseId)
->whereIn('status', ['paid', 'completed'])
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report']);
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
@@ -54,16 +54,44 @@ class Finance extends BaseController
->where('status', 'frozen')
->sum('commissionFen') ?? 0);
// 本月累计扣款(平台扣费 + 佣金扣减)
$monthConsumeFen = (int) (Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('type', 'consume')
->where('createdAt', '>=', $monthStart)
->sum('amount') ?? 0);
// 今日扣款
$todayConsumeFen = (int) (Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('type', 'consume')
->where('createdAt', '>=', $todayStart)
->sum('amount') ?? 0);
// 建议充值:按本月实际天数日均 × 14 天,低于 500 元按 500 元兜底;余额足够则 0
$balanceFen = (int) ($enterprise['balance'] ?? 0);
$daysElapsed = max(1, (int) date('j'));
$avgDaily = $monthConsumeFen > 0 ? intval($monthConsumeFen / $daysElapsed) : 0;
$suggestThreshold = $avgDaily * 14;
$suggestRechargeFen = 0;
if ($avgDaily > 0 && $balanceFen < $suggestThreshold) {
$suggestRechargeFen = max($suggestThreshold - $balanceFen, 50000); // ≥ 500 元
}
return success([
'enterpriseId' => $enterpriseId,
'enterpriseName' => $enterprise['name'] ?? '',
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
'balanceFen' => $balanceFen,
'totalIncomeFen' => $totalIncomeFen,
'todayIncomeFen' => $todayIncomeFen,
'monthIncomeFen' => $monthIncomeFen,
'manualRechargeFen' => $manualRechargeFen,
'frozenCommissionFen' => $frozenCommissionFen,
'paidOrderCount' => $paidOrderCount,
'monthConsumeFen' => $monthConsumeFen,
'todayConsumeFen' => $todayConsumeFen,
'avgDailyConsumeFen' => $avgDaily,
'suggestRechargeFen' => $suggestRechargeFen,
]);
} catch (\Throwable $e) {
return error('获取企业财务概览失败:' . $e->getMessage(), 500);

View File

@@ -31,6 +31,7 @@ class Order extends BaseController
$keyword = trim(Request::param('keyword', ''));
$status = trim(Request::param('status', ''));
$productType = trim(Request::param('productType', ''));
$inviterId = (int) Request::param('inviterId', 0);
// 超管:全平台订单;其余管理员仅本企业
$enterpriseId = null;
@@ -53,6 +54,24 @@ class Order extends BaseController
if ($productType !== '') {
$query->where('productType', $productType);
}
if ($inviterId > 0) {
// 通过分销绑定表过滤「该分销商带来的订单」orders.userId = distribution_bindings.inviteeIdinviter=inviterId
try {
$inviteeIds = Db::name('distribution_bindings')
->where('inviterId', $inviterId)
->column('inviteeId');
$inviteeIds = array_values(array_filter(array_map('intval', $inviteeIds ?: [])));
if (empty($inviteeIds)) {
return success([
'list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize,
'hasMore' => false, 'paidCompletedCount' => 0, 'totalRevenueFen' => 0,
]);
}
$query->whereIn('userId', $inviteeIds);
} catch (\Throwable $e) {
// 表不存在或字段差异:忽略筛选
}
}
if ($keyword !== '') {
if (is_numeric($keyword)) {
$query->where(function ($q) use ($keyword) {
@@ -119,12 +138,58 @@ class Order extends BaseController
}
}
// 当前页订单的分销分润记录
$commissionsByOrder = [];
if (!empty($orderIds)) {
try {
$crRows = Db::name('commission_records')
->where('orderId', 'in', $orderIds)
->field('id, orderId, inviterId, commissionFen, rate, status, paidAt, createdAt')
->order('createdAt', 'asc')
->select()
->toArray();
$inviterIds = array_values(array_unique(array_filter(array_column($crRows, 'inviterId'))));
$inviterMap = [];
if (!empty($inviterIds)) {
$inviters = Db::name('wechat_users')
->where('id', 'in', $inviterIds)
->field('id, nickname, phone')
->select()
->toArray();
foreach ($inviters as $iv) {
$inviterMap[(int) $iv['id']] = $iv;
}
}
foreach ($crRows as $cr) {
$oid = (int) ($cr['orderId'] ?? 0);
if ($oid <= 0) {
continue;
}
$iv = $inviterMap[(int) ($cr['inviterId'] ?? 0)] ?? null;
$commissionsByOrder[$oid][] = [
'id' => (int) $cr['id'],
'inviterId' => (int) ($cr['inviterId'] ?? 0),
'inviterName' => $iv['nickname'] ?? null,
'inviterPhone' => $iv['phone'] ?? null,
'commissionFen' => (int) ($cr['commissionFen'] ?? 0),
'rate' => isset($cr['rate']) ? (float) $cr['rate'] : null,
'status' => (string) ($cr['status'] ?? ''),
'paidAt' => isset($cr['paidAt']) ? (int) $cr['paidAt'] : null,
'createdAt' => isset($cr['createdAt']) ? (int) $cr['createdAt'] : null,
];
}
} catch (\Throwable $e) {
$commissionsByOrder = [];
}
}
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']] ?? [];
$row['commissions'] = $commissionsByOrder[$row['id']] ?? [];
}
return success([

View File

@@ -2,12 +2,115 @@
namespace app\controller\admin\concern;
use app\common\PdpDiscResultText;
use think\facade\Db;
/**
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
*/
trait ExtractsTestResults
{
/**
* 基于测试记录推算冷脸分值0-100
* - 面相 emotionScore/emotionNeutrality/microExpression 作为主输入
* - MBTI I/E 作为补充I 更冷E 更暖)
* 返回 ['score'=>int, 'level'=>'cold|neutral|warm'];如无任何线索返回 null
*/
public function calcColdFace(array $tests): ?array
{
$score = null; // 0-100
$mbtiType = '';
foreach ($tests as $t) {
$type = strtolower($t['testType'] ?? '');
$raw = $t['result'] ?? ($t['resultData'] ?? '');
if (!is_string($raw) || $raw === '') {
continue;
}
$dec = json_decode($raw, true);
if (!is_array($dec)) {
continue;
}
if ($type === 'face' && $score === null) {
$face = is_array($dec['face'] ?? null) ? $dec['face'] : $dec;
$emotion = null;
foreach (['coldFaceScore', 'coldScore', 'emotionNeutrality', 'neutrality'] as $k) {
if (isset($face[$k]) && is_numeric($face[$k])) {
$emotion = (float) $face[$k];
break;
}
}
if ($emotion === null && isset($face['emotionScore']) && is_numeric($face['emotionScore'])) {
// emotionScore 越高越暖 → 冷脸分取反
$emotion = 100 - (float) $face['emotionScore'];
}
if ($emotion === null && isset($face['microExpression']) && is_array($face['microExpression'])) {
$mx = $face['microExpression'];
$happy = is_numeric($mx['happy'] ?? null) ? (float) $mx['happy'] : 0;
$neutral = is_numeric($mx['neutral'] ?? null) ? (float) $mx['neutral'] : 0;
$total = $happy + $neutral + 1;
$emotion = ($neutral / $total) * 100;
}
if ($emotion !== null) {
if ($emotion <= 1) {
$emotion = $emotion * 100;
}
$score = max(0, min(100, (int) round($emotion)));
}
}
if ($type === 'mbti' && $mbtiType === '') {
foreach (['mbtiType', 'type', 'result'] as $k) {
$s = $this->coerceResultLabel($dec[$k] ?? null);
if ($s !== '') {
$mbtiType = strtoupper($s);
break;
}
}
}
}
if ($score === null && $mbtiType !== '') {
$score = strpos($mbtiType, 'I') === 0 ? 62 : 42;
}
if ($score === null) {
return null;
}
$level = 'neutral';
if ($score > 65) {
$level = 'cold';
} elseif ($score < 35) {
$level = 'warm';
}
return ['score' => (int) $score, 'level' => $level];
}
/**
* 写回 user_profile 的冷脸字段(存在才更新,否则跳过;失败静默)
*/
protected function writeColdFace(int $userId, ?int $enterpriseId, array $cold): void
{
if (!isset($cold['score'], $cold['level'])) {
return;
}
try {
$query = Db::name('user_profile')->where('userId', $userId);
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->update([
'coldFaceScore' => (int) $cold['score'],
'coldFaceLevel' => (string) $cold['level'],
'coldFaceUpdatedAt' => time(),
'updatedAt' => time(),
]);
} catch (\Throwable $e) {
// 字段不存在(未执行迁移)时直接忽略
}
}
/**
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
*/