## GitHub 同步说明
| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地文件 | 否(仅 git add/commit,未编辑任何源文件正文) |
| 基准 commit | 0643e78a |
| 变更规模 | 709 files, +26436 / -3407 lines |
## 目录变更统计
| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 404 | 四端进行中/已完成需求、接口文档、官网需求、部署脚本 |
| Server/ | 95 | 后端 API、迁移脚本、触客宝/超管/存客宝服务 |
| Cunkebao/ | 85 | 移动端 H5、设备绑定、客服、转号审批等 |
| Touchkebao/ | 68 | PC 工作台、AI 获客、算力中心、Glass UI |
| SuperAdmin/ | 33 | 超管项目中心、算力计费、内容库 |
| 官网/ | 19 | 新增存客宝官网静态页(index/pricing/solutions 等) |
| .cursor/ | 3 | Cursor 规则与 Skill |
| .obsidian/ | 2 | Obsidian 工作区配置 |
## 重点新增/更新(本地为准)
- 官网/: 完整静态站点(index、pricing、manual、solutions、api)
- 开发文档/1、需求/修改/*_进行中_20260530.md(存客宝/触客宝/AI数智员工)
- 开发文档/1、需求/官网需求.md
- 开发文档/5、接口/开发进度_接口文档.md 等接口文档迭代
- Touchkebao Glass UI 与 P0 收尾、通道获客算力
- SuperAdmin 算力计费规则、项目工作台
## 刻意未上传(本地保留)
- node_modules/
- .DS_Store、.smart-env/
- .开发文档_nested_git_backup/(旧嵌套 git 备份)
- **/__pycache__/
## 验收
GitHub 浏览: https://github.com/fnvtk/cunkebao_v3/tree/develop
Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
8.4 KiB
PHP
243 lines
8.4 KiB
PHP
<?php
|
||
|
||
namespace app\chukebao\controller;
|
||
|
||
use app\cunkebao\service\WechatAccountValuationService;
|
||
use library\ResponseHelper;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 模块 G2 · 微信号用户管理 & 账号估值(需求九)
|
||
* 账号估值优先取存客宝真源 WechatAccountValuationService;失败回退本地聚合。
|
||
*/
|
||
class WechatAccountController extends BaseController
|
||
{
|
||
private function accountId()
|
||
{
|
||
return (int)$this->request->param('id', 0);
|
||
}
|
||
|
||
/** 数字微信号账号 id → wechatId 字符串(估值真源以 wechatId 为口径) */
|
||
private function resolveWechatId(int $accountId): string
|
||
{
|
||
return (string)Db::table('s2_wechat_account')->where('id', $accountId)->value('wechatId');
|
||
}
|
||
|
||
/**
|
||
* 取账号估值:优先存客宝真源,失败回退本地聚合
|
||
*/
|
||
private function getValuation(int $accountId, int $friendCount): array
|
||
{
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$wechatId = $this->resolveWechatId($accountId);
|
||
if ($wechatId !== '') {
|
||
try {
|
||
$data = (new WechatAccountValuationService())->getValuation($wechatId, $companyId, false);
|
||
if (!empty($data)) {
|
||
$data['_source'] = 'cunkebao';
|
||
return $data;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 回退本地聚合
|
||
}
|
||
}
|
||
return $this->buildValuation($accountId, $friendCount);
|
||
}
|
||
|
||
/**
|
||
* 管理页聚合
|
||
* GET /v1/kefu/wechat-account/<id>/manage
|
||
*/
|
||
public function manage()
|
||
{
|
||
$id = $this->accountId();
|
||
if (!$id) {
|
||
return ResponseHelper::error('id 缺失', 400);
|
||
}
|
||
$acc = Db::table('s2_wechat_account')->alias('wa')
|
||
->join(['s2_device' => 'd'], 'wa.currentDeviceId = d.id', 'LEFT')
|
||
->where('wa.id', $id)
|
||
->field(['wa.id', 'wa.nickname', 'wa.alias', 'wa.wechatId as wxid', 'wa.avatar', 'wa.wechatAliveTime', 'd.imei'])
|
||
->find();
|
||
if (!$acc) {
|
||
return ResponseHelper::error('微信号不存在', 404);
|
||
}
|
||
$friendCount = Db::table('s2_wechat_friend')
|
||
->where('wechatAccountId', $id)
|
||
->where('isDeleted', 0)
|
||
->count();
|
||
|
||
$acc['online'] = !empty($acc['wechatAliveTime']) && $acc['wechatAliveTime'] > (time() - 600);
|
||
$acc['deviceName'] = $acc['imei'] ?? '';
|
||
$acc['friendCount'] = $friendCount;
|
||
$acc['valuation'] = $this->getValuation($id, $friendCount);
|
||
|
||
return ResponseHelper::success($acc);
|
||
}
|
||
|
||
/**
|
||
* 账号估值(best-effort 聚合;真源在存客宝)
|
||
* GET /v1/kefu/wechat-account/<id>/valuation
|
||
*/
|
||
public function valuation()
|
||
{
|
||
$id = $this->accountId();
|
||
if (!$id) {
|
||
return ResponseHelper::error('id 缺失', 400);
|
||
}
|
||
$friendCount = Db::table('s2_wechat_friend')
|
||
->where('wechatAccountId', $id)
|
||
->where('isDeleted', 0)
|
||
->count();
|
||
return ResponseHelper::success($this->getValuation($id, $friendCount));
|
||
}
|
||
|
||
private function buildValuation($id, $friendCount)
|
||
{
|
||
// 活跃好友:30 天内有互动(passTime 近似);高价值:暂以有手机号近似
|
||
$active = Db::table('s2_wechat_friend')
|
||
->where('wechatAccountId', $id)
|
||
->where('isDeleted', 0)
|
||
->where('updateTime', '>', time() - 86400 * 30)
|
||
->count();
|
||
$highValue = Db::table('s2_wechat_friend')
|
||
->where('wechatAccountId', $id)
|
||
->where('isDeleted', 0)
|
||
->where('phone', '<>', '')
|
||
->count();
|
||
|
||
$score = $friendCount > 0
|
||
? min(100, (int)round(($active / max(1, $friendCount)) * 60 + min(40, $friendCount / 10)))
|
||
: 0;
|
||
$level = $score >= 80 ? 'S' : ($score >= 60 ? 'A' : ($score >= 40 ? 'B' : ($score > 0 ? 'C' : '-')));
|
||
$position = $friendCount >= 300 ? '主力号' : ($friendCount >= 50 ? '养号' : '测试号');
|
||
|
||
return [
|
||
'accountValueScore' => $score,
|
||
'accountLevel' => $level,
|
||
'highValueFriendCount' => $highValue,
|
||
'activeFriendCount' => $active,
|
||
'projectPosition' => $position,
|
||
'sourceSummary' => '',
|
||
'evaluatedAt' => date('Y-m-d H:i:s'),
|
||
'planLineId' => 0,
|
||
'_estimated' => true,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 本号好友分页
|
||
* GET /v1/kefu/wechat-account/<id>/friends
|
||
*/
|
||
public function friends()
|
||
{
|
||
$id = $this->accountId();
|
||
if (!$id) {
|
||
return ResponseHelper::error('id 缺失', 400);
|
||
}
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$page = (int)$this->request->param('page', 1);
|
||
$limit = (int)$this->request->param('limit', 20);
|
||
$keyword = $this->request->param('keyword', '');
|
||
|
||
$query = Db::table('s2_wechat_friend')->alias('f')
|
||
->leftJoin(
|
||
'ck_traffic_pool_company t',
|
||
't.wechatFriendId=f.id AND t.companyId=' . $companyId . ' AND t.isDel=0'
|
||
)
|
||
->leftJoin(['s2_company_account' => 'ca'], 'ca.id=f.accountId', 'LEFT')
|
||
->where('f.wechatAccountId', $id)
|
||
->where('f.isDeleted', 0);
|
||
if ($keyword !== '') {
|
||
$query->where('f.nickname|f.conRemark|f.alias', 'like', '%' . $keyword . '%');
|
||
}
|
||
$total = (clone $query)->count();
|
||
$list = $query->field([
|
||
'f.id', 'f.nickname', 'f.conRemark', 'f.alias', 'f.wechatId', 'f.avatar',
|
||
'f.labels', 'f.phone', 'f.createTime', 'f.updateTime', 'f.accountId',
|
||
't.rfmScore', 't.lastInteractTime', 't.lastMsgTime', 't.id as poolCompanyId',
|
||
'ca.realName as kefuName', 'ca.userName as kefuUserName',
|
||
])
|
||
->page($page, $limit)
|
||
->orderRaw(
|
||
'COALESCE(t.lastMsgTime,t.lastInteractTime,f.updateTime,0) DESC, COALESCE(t.rfmScore,0) DESC'
|
||
)
|
||
->select();
|
||
|
||
$list = $this->enrichFriendsWithPoolTags($list ?: [], $companyId);
|
||
|
||
return ResponseHelper::success(['list' => $list, 'total' => $total]);
|
||
}
|
||
|
||
/**
|
||
* 合并微信 labels + 流量池标签,补齐 RFM 等级
|
||
*/
|
||
private function enrichFriendsWithPoolTags(array $list, int $companyId): array
|
||
{
|
||
if (empty($list)) {
|
||
return [];
|
||
}
|
||
|
||
$poolIds = [];
|
||
foreach ($list as $row) {
|
||
if (!empty($row['poolCompanyId'])) {
|
||
$poolIds[] = (int)$row['poolCompanyId'];
|
||
}
|
||
}
|
||
$poolIds = array_values(array_unique($poolIds));
|
||
|
||
$tagsByPool = [];
|
||
if (!empty($poolIds)) {
|
||
$tagRows = Db::name('traffic_pool_tag')
|
||
->where('poolCompanyId', 'in', $poolIds)
|
||
->where('isDel', 0)
|
||
->field('poolCompanyId, tagName')
|
||
->select();
|
||
foreach ($tagRows ?: [] as $tr) {
|
||
$pid = (int)$tr['poolCompanyId'];
|
||
$name = trim((string)($tr['tagName'] ?? ''));
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
$tagsByPool[$pid][] = $name;
|
||
}
|
||
}
|
||
|
||
foreach ($list as &$v) {
|
||
$wxLabels = !empty($v['labels']) ? (json_decode($v['labels'], true) ?: []) : [];
|
||
if (!is_array($wxLabels)) {
|
||
$wxLabels = [];
|
||
}
|
||
$poolTags = $tagsByPool[(int)($v['poolCompanyId'] ?? 0)] ?? [];
|
||
$v['labels'] = array_values(array_unique(array_merge($wxLabels, $poolTags)));
|
||
$v['remark'] = $v['conRemark'];
|
||
$score = (int)($v['rfmScore'] ?? 0);
|
||
$v['rfmScore'] = $score;
|
||
$v['rfmLevel'] = $this->rfmLevelFromScore($score);
|
||
$kefu = trim((string)($v['kefuName'] ?? ''));
|
||
if ($kefu === '') {
|
||
$kefu = trim((string)($v['kefuUserName'] ?? ''));
|
||
}
|
||
$v['kefuName'] = $kefu;
|
||
unset($v['kefuUserName'], $v['poolCompanyId']);
|
||
}
|
||
unset($v);
|
||
|
||
return $list;
|
||
}
|
||
|
||
private function rfmLevelFromScore(int $score): string
|
||
{
|
||
if ($score >= 80) {
|
||
return 'S';
|
||
}
|
||
if ($score >= 60) {
|
||
return 'A';
|
||
}
|
||
if ($score >= 40) {
|
||
return 'B';
|
||
}
|
||
return $score > 0 ? 'C' : '-';
|
||
}
|
||
}
|