## GitHub 同步说明 | 项 | 内容 | |:---|:---| | 仓库 | https://github.com/fnvtk/cunkebao_v3 | | 分支 | develop | | 方向 | 本地 → GitHub(单向 push) | | 是否拉取远程 | 否(未 fetch / pull / merge) | | 是否改本地源文件 | 否(仅 git add/commit,未编辑业务/文档正文) | | 远程基准 | origin/develop @b5b40e8b| | 本批工作区变更 | 999 files, +47622 / -4007 lines | ## 一并推送的本地已有 commit(此前未 push) 1.3aa7ecf8deploy: 官网 ckb.quwanzhi.com 宝塔上线 DNS+SSL+验收文档 2.16a70045fix(官网): 桌面导航仅保留一个留资 CTA 并重新上线 3.dbe7b7aafix(官网): Hero 改为主 CTA 按钮并去掉重复 1200+ 数据条 4.ac82726echore(部署): 五端上线验收46项并统一留资与缓存版本 ## 本 commit 目录统计 | 目录 | 文件数 | 说明 | |:---|---:|:---| | 开发文档/ | 639 | 10、项目管理、未完成项跨仓汇总、五端需求/部署/接口 | | Server/ | 129 | 后端 API、迁移、部署相关 | | Touchkebao/ | 80 | PC 工作台、获客、算力 | | 官网/ | 61 | ckb.quwanzhi.com 静态站与 CTA 迭代 | | Cunkebao/ | 49 | 移动端场景/设置/流量池 | | SuperAdmin/ | 26 | 超管后台 | | .cursor/ | 11 | 规则与 Skill | | 其他 | 14 | docker-compose、.obsidian、.codegraph 等 | ## 重点文件(本地为准) - 开发文档/1、需求/修改/未完成项与跨仓验收汇总.md - 开发文档/10、项目管理/账号密码与登录环境一览.md - 开发文档/10、项目管理/(新建项目管理目录与截图) - 官网/ 全站 + 宝塔上线文档 - 五端上线验收 46 项(见 ac82726e) ## 刻意未上传 - node_modules/、.DS_Store、.smart-env/ - .开发文档_nested_git_backup/ - **/__pycache__/ ## 验收 - 提交页(含本说明): https://github.com/fnvtk/cunkebao_v3/commit/HEAD - 分支树: https://github.com/fnvtk/cunkebao_v3/tree/develop Co-authored-by: Cursor <cursoragent@cursor.com>
396 lines
14 KiB
PHP
396 lines
14 KiB
PHP
<?php
|
||
|
||
namespace app\superadmin\service;
|
||
|
||
use app\common\model\User as UserModel;
|
||
use app\cunkebao\service\TokensService;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 超管 · 项目算力管理(读写 ck_tokens_company / ck_tokens_record,不改 cunkebao 代码)
|
||
*/
|
||
class ProjectTokensService
|
||
{
|
||
/** 超管充值/调整流水 form */
|
||
const FORM_ADMIN_ADJUST = 1001;
|
||
|
||
/** AI 相关扣费 form(B 类) */
|
||
const AI_FORMS = [
|
||
TokensService::ACTION_AI_REWRITE,
|
||
TokensService::ACTION_AI_KEFU,
|
||
TokensService::ACTION_AI_CHAT,
|
||
];
|
||
|
||
public function getOverview(int $projectId): array
|
||
{
|
||
$ctx = ProjectContextService::resolveByProjectId($projectId);
|
||
$companyId = $ctx['companyId'];
|
||
|
||
$master = Db::name('users')
|
||
->where('companyId', $companyId)
|
||
->where('isAdmin', UserModel::ADMIN_STP)
|
||
->where('deleteTime', 0)
|
||
->field('id,account,username,phone,isAdmin')
|
||
->order('id asc')
|
||
->find();
|
||
|
||
$masterUserId = $master ? (int) $master['id'] : 0;
|
||
$masterBalance = $this->userBalance($companyId, $masterUserId, true);
|
||
|
||
$subQuery = Db::name('users')->alias('u')
|
||
->leftJoin('tokens_company tc', 'tc.userId = u.id AND tc.companyId = u.companyId')
|
||
->where('u.companyId', $companyId)
|
||
->where('u.deleteTime', 0);
|
||
if ($masterUserId > 0) {
|
||
$subQuery->where('u.id', '<>', $masterUserId);
|
||
}
|
||
$subRows = $subQuery
|
||
->field('u.id,u.account,u.username,u.phone,u.isAdmin,COALESCE(tc.tokens,0) as tokens')
|
||
->order('u.id asc')
|
||
->select();
|
||
|
||
$subAccounts = [];
|
||
foreach ($subRows ?: [] as $row) {
|
||
$subAccounts[] = [
|
||
'userId' => (int) $row['id'],
|
||
'account' => $row['account'] ?: '',
|
||
'username' => $row['username'] ?: '',
|
||
'phone' => $row['phone'] ?: '',
|
||
'isAdmin' => (int) $row['isAdmin'],
|
||
'roleLabel'=> (int) $row['isAdmin'] === UserModel::ADMIN_STP ? '操盘手' : '客服',
|
||
'tokens' => (int) $row['tokens'],
|
||
];
|
||
}
|
||
|
||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||
|
||
$companyTodayUsed = $this->sumConsumed($companyId, 0, $todayStart);
|
||
$companyMonthUsed = $this->sumConsumed($companyId, 0, $monthStart);
|
||
$aiTodayCount = $this->countAiUsage($companyId, $todayStart);
|
||
$aiMonthCount = $this->countAiUsage($companyId, $monthStart);
|
||
|
||
$usageBreakdown = $this->usageBreakdown($companyId, $monthStart);
|
||
|
||
return [
|
||
'projectId' => $projectId,
|
||
'companyId' => $companyId,
|
||
'projectName' => $ctx['name'],
|
||
'tokensPerYuan' => TokensService::TOKENS_PER_YUAN,
|
||
'master' => $master ? [
|
||
'userId' => $masterUserId,
|
||
'account' => $master['account'] ?: '',
|
||
'username' => $master['username'] ?: '',
|
||
'phone' => $master['phone'] ?: '',
|
||
'tokens' => $masterBalance,
|
||
] : null,
|
||
'subAccounts' => $subAccounts,
|
||
'stats' => [
|
||
'masterBalance' => $masterBalance,
|
||
'subTotalBalance'=> array_sum(array_column($subAccounts, 'tokens')),
|
||
'todayUsed' => $companyTodayUsed,
|
||
'monthUsed' => $companyMonthUsed,
|
||
'aiTodayCount' => $aiTodayCount,
|
||
'aiMonthCount' => $aiMonthCount,
|
||
],
|
||
'usageBreakdown' => $usageBreakdown,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 超管调整算力:add / subtract / set / allocate(主账号→子账号)
|
||
*/
|
||
public function adjust(int $projectId, array $params): array
|
||
{
|
||
$companyId = ProjectContextService::resolveCompanyId($projectId);
|
||
$targetUserId = (int) ($params['userId'] ?? 0);
|
||
$mode = trim((string) ($params['mode'] ?? 'add'));
|
||
$amount = (int) ($params['amount'] ?? 0);
|
||
$remarks = trim((string) ($params['remarks'] ?? ''));
|
||
|
||
if ($targetUserId <= 0) {
|
||
throw new \Exception('请选择账号', 400);
|
||
}
|
||
|
||
$user = Db::name('users')
|
||
->where('id', $targetUserId)
|
||
->where('companyId', $companyId)
|
||
->where('deleteTime', 0)
|
||
->find();
|
||
if (!$user) {
|
||
throw new \Exception('账号不存在或不属于该项目', 404);
|
||
}
|
||
|
||
$isMaster = (int) $user['isAdmin'] === UserModel::ADMIN_STP;
|
||
|
||
if ($mode === 'allocate') {
|
||
if ($isMaster) {
|
||
throw new \Exception('不能向主账号分配', 400);
|
||
}
|
||
if ($amount <= 0) {
|
||
throw new \Exception('分配数量须大于 0', 400);
|
||
}
|
||
return $this->allocateFromMaster($companyId, $targetUserId, $amount, $remarks);
|
||
}
|
||
|
||
if (!in_array($mode, ['add', 'subtract', 'set'], true)) {
|
||
throw new \Exception('无效的调整方式', 400);
|
||
}
|
||
|
||
if ($mode !== 'set' && $amount <= 0) {
|
||
throw new \Exception('数量须大于 0', 400);
|
||
}
|
||
if ($mode === 'set' && $amount < 0) {
|
||
throw new \Exception('余额不能为负数', 400);
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$row = $this->lockUserTokenRow($companyId, $targetUserId, $isMaster);
|
||
$current = (int) $row['tokens'];
|
||
$delta = 0;
|
||
$newBalance = $current;
|
||
|
||
if ($mode === 'add') {
|
||
$delta = $amount;
|
||
$newBalance = $current + $amount;
|
||
} elseif ($mode === 'subtract') {
|
||
if ($current < $amount) {
|
||
throw new \Exception('余额不足,当前:' . $current, 400);
|
||
}
|
||
$delta = -$amount;
|
||
$newBalance = $current - $amount;
|
||
} else {
|
||
$delta = $amount - $current;
|
||
$newBalance = $amount;
|
||
}
|
||
|
||
Db::name('tokens_company')->where('id', $row['id'])->update([
|
||
'tokens' => $newBalance,
|
||
'updateTime' => time(),
|
||
]);
|
||
|
||
if ($delta !== 0) {
|
||
Db::name('tokens_record')->insert([
|
||
'companyId' => $companyId,
|
||
'userId' => $targetUserId,
|
||
'form' => self::FORM_ADMIN_ADJUST,
|
||
'type' => $delta > 0 ? 1 : 0,
|
||
'tokens' => abs($delta),
|
||
'balanceTokens' => $newBalance,
|
||
'remarks' => $remarks ?: ('超管' . ($delta > 0 ? '增加' : '扣减') . '算力'),
|
||
'createTime' => time(),
|
||
'friendIdOrGroupId' => 0,
|
||
]);
|
||
}
|
||
|
||
Db::commit();
|
||
return [
|
||
'userId' => $targetUserId,
|
||
'balance' => $newBalance,
|
||
'delta' => $delta,
|
||
];
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/** 项目列表用:主账号算力余额 + 本月 AI 调用次数 */
|
||
public static function statsForCompanies(array $companyIds): array
|
||
{
|
||
if (empty($companyIds)) {
|
||
return [];
|
||
}
|
||
|
||
$monthStart = strtotime(date('Y-m-01 00:00:00'));
|
||
$map = [];
|
||
|
||
foreach ($companyIds as $cid) {
|
||
$map[(int) $cid] = ['tokenBalance' => 0, 'aiMonthCount' => 0];
|
||
}
|
||
|
||
$balanceRows = Db::name('tokens_company')
|
||
->whereIn('companyId', $companyIds)
|
||
->where('isAdmin', 1)
|
||
->field('companyId, MAX(tokens) as tokenBalance')
|
||
->group('companyId')
|
||
->select();
|
||
|
||
foreach ($balanceRows ?: [] as $row) {
|
||
$cid = (int) $row['companyId'];
|
||
if (isset($map[$cid])) {
|
||
$map[$cid]['tokenBalance'] = (int) $row['tokenBalance'];
|
||
}
|
||
}
|
||
|
||
$aiRows = Db::name('tokens_record')
|
||
->whereIn('companyId', $companyIds)
|
||
->where('type', 0)
|
||
->whereIn('form', self::AI_FORMS)
|
||
->where('createTime', '>=', $monthStart)
|
||
->field('companyId, COUNT(*) as aiMonthCount')
|
||
->group('companyId')
|
||
->select();
|
||
|
||
foreach ($aiRows ?: [] as $row) {
|
||
$cid = (int) $row['companyId'];
|
||
if (isset($map[$cid])) {
|
||
$map[$cid]['aiMonthCount'] = (int) $row['aiMonthCount'];
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
protected function userBalance(int $companyId, int $userId, bool $preferAdmin): int
|
||
{
|
||
if ($userId <= 0) {
|
||
return 0;
|
||
}
|
||
$query = Db::name('tokens_company')->where('companyId', $companyId)->where('userId', $userId);
|
||
if ($preferAdmin) {
|
||
$query->where('isAdmin', 1);
|
||
}
|
||
$row = $query->find();
|
||
if (!$row && $preferAdmin) {
|
||
$row = Db::name('tokens_company')->where('companyId', $companyId)->where('userId', $userId)->find();
|
||
}
|
||
return $row ? (int) $row['tokens'] : 0;
|
||
}
|
||
|
||
protected function sumConsumed(int $companyId, int $userId, int $since): int
|
||
{
|
||
$query = Db::name('tokens_record')
|
||
->where('companyId', $companyId)
|
||
->where('type', 0)
|
||
->where('createTime', '>=', $since);
|
||
if ($userId > 0) {
|
||
$query->where('userId', $userId);
|
||
}
|
||
return (int) $query->sum('tokens');
|
||
}
|
||
|
||
protected function countAiUsage(int $companyId, int $since): int
|
||
{
|
||
return (int) Db::name('tokens_record')
|
||
->where('companyId', $companyId)
|
||
->where('type', 0)
|
||
->whereIn('form', self::AI_FORMS)
|
||
->where('createTime', '>=', $since)
|
||
->count();
|
||
}
|
||
|
||
protected function usageBreakdown(int $companyId, int $since): array
|
||
{
|
||
$rows = Db::name('tokens_record')
|
||
->where('companyId', $companyId)
|
||
->where('type', 0)
|
||
->where('createTime', '>=', $since)
|
||
->field('form, COUNT(*) as useCount, SUM(tokens) as tokensUsed')
|
||
->group('form')
|
||
->order('useCount', 'desc')
|
||
->limit(20)
|
||
->select();
|
||
|
||
$rules = TokensService::rules();
|
||
$list = [];
|
||
foreach ($rows ?: [] as $row) {
|
||
$form = (int) $row['form'];
|
||
$rule = $rules[$form] ?? null;
|
||
$list[] = [
|
||
'form' => $form,
|
||
'name' => $rule['name'] ?? ('行为#' . $form),
|
||
'group' => $rule['group'] ?? '',
|
||
'isAi' => in_array($form, self::AI_FORMS, true),
|
||
'useCount' => (int) $row['useCount'],
|
||
'tokensUsed' => (int) $row['tokensUsed'],
|
||
];
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
protected function lockUserTokenRow(int $companyId, int $userId, bool $isMaster): array
|
||
{
|
||
$row = Db::name('tokens_company')
|
||
->where('companyId', $companyId)
|
||
->where('userId', $userId)
|
||
->lock(true)
|
||
->find();
|
||
|
||
if ($row) {
|
||
return $row;
|
||
}
|
||
|
||
$id = Db::name('tokens_company')->insertGetId([
|
||
'userId' => $userId,
|
||
'companyId' => $companyId,
|
||
'tokens' => 0,
|
||
'isAdmin' => $isMaster ? 1 : 0,
|
||
'createTime' => time(),
|
||
'updateTime' => time(),
|
||
]);
|
||
|
||
return Db::name('tokens_company')->where('id', $id)->find();
|
||
}
|
||
|
||
protected function allocateFromMaster(int $companyId, int $targetUserId, int $amount, string $remarks): array
|
||
{
|
||
$master = Db::name('users')
|
||
->where('companyId', $companyId)
|
||
->where('isAdmin', UserModel::ADMIN_STP)
|
||
->where('deleteTime', 0)
|
||
->order('id asc')
|
||
->find();
|
||
if (!$master) {
|
||
throw new \Exception('项目主账号不存在', 404);
|
||
}
|
||
|
||
$masterUserId = (int) $master['id'];
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$masterRow = $this->lockUserTokenRow($companyId, $masterUserId, true);
|
||
$masterBalance = (int) $masterRow['tokens'];
|
||
if ($masterBalance < $amount) {
|
||
throw new \Exception('主账号算力不足,当前:' . $masterBalance, 400);
|
||
}
|
||
|
||
$targetRow = $this->lockUserTokenRow($companyId, $targetUserId, false);
|
||
$masterNew = $masterBalance - $amount;
|
||
$targetNew = (int) $targetRow['tokens'] + $amount;
|
||
|
||
Db::name('tokens_company')->where('id', $masterRow['id'])->update([
|
||
'tokens' => $masterNew, 'updateTime' => time(),
|
||
]);
|
||
Db::name('tokens_company')->where('id', $targetRow['id'])->update([
|
||
'tokens' => $targetNew, 'updateTime' => time(),
|
||
]);
|
||
|
||
$note = $remarks ?: '超管分配至子账号';
|
||
Db::name('tokens_record')->insert([
|
||
'companyId' => $companyId, 'userId' => $masterUserId,
|
||
'form' => self::FORM_ADMIN_ADJUST, 'type' => 0, 'tokens' => $amount,
|
||
'balanceTokens' => $masterNew, 'remarks' => $note,
|
||
'friendIdOrGroupId' => $targetUserId, 'createTime' => time(),
|
||
]);
|
||
Db::name('tokens_record')->insert([
|
||
'companyId' => $companyId, 'userId' => $targetUserId,
|
||
'form' => self::FORM_ADMIN_ADJUST, 'type' => 1, 'tokens' => $amount,
|
||
'balanceTokens' => $targetNew, 'remarks' => $note,
|
||
'friendIdOrGroupId' => $masterUserId, 'createTime' => time(),
|
||
]);
|
||
|
||
Db::commit();
|
||
return [
|
||
'masterBalance' => $masterNew,
|
||
'receiverBalance'=> $targetNew,
|
||
'allocated' => $amount,
|
||
];
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
}
|