Files
cunkebao_v3/Server/application/common/controller/OpenPlatformController.php
Manus AI 5182529a58 sync(本地→GitHub): 2026-05-30 21:41 全量单向同步
## 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>
2026-05-30 21:41:40 +08:00

716 lines
25 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 开放对接平台 · 领域只读 API
*
* 5 大领域,均要求 Authorization: Bearer <openToken>
* GET /v1/open/scenes ← 场景类型字典(含计划下的统计)
* GET /v1/open/leads ← 客资明细列表(当前计划下)
* GET /v1/open/wechats ← 微信号列表companyId 隔离)
* GET /v1/open/devices ← 设备列表companyId 隔离)
* GET /v1/open/stats ← 平台关键指标
*
* 设计原则:
* - 严格按 JWT.companyId 过滤数据,禁止跨租户读取
* - 默认分页page/limitlimit 上限 200
* - 字段精简,只暴露对接方需要的关键列
*
* 文档:开发文档/5、接口/08-存客宝开放对接平台/03-领域接口表.md
*
* @package app\common\controller
*/
namespace app\common\controller;
use app\common\service\DeviceTaskConfigService;
use app\common\service\OpenPlatformService;
use app\cunkebao\service\ContentItemMediaService;
use library\ResponseHelper;
use think\Controller;
use think\Db;
use think\facade\Request;
class OpenPlatformController extends Controller
{
/**
* 统一鉴权 + 返回上下文,失败抛 401
*/
private function ctx(): array
{
$ctx = OpenPlatformService::resolveContext();
if (!$ctx) {
ResponseHelper::unauthorized('未授权或 Token 已过期')->send();
exit;
}
return $ctx;
}
/**
* 平台健康检查(不鉴权,公开)
* GET /v1/open/health
*/
public function health()
{
return ResponseHelper::success([
'service' => 'cunkebao-open-platform',
'version' => '1.0',
'time' => time(),
'datetime' => date('Y-m-d H:i:s'),
], 'ok');
}
/**
* 当前 Token 解析(调试用,等价 whoami
* GET /v1/open/me
*/
public function me()
{
$ctx = $this->ctx();
return ResponseHelper::success([
'planId' => $ctx['planId'],
'planName' => $ctx['planName'],
'companyId' => $ctx['companyId'],
'apiKey' => substr((string) $ctx['apiKey'], 0, 6) . '****',
], 'ok');
}
/**
* 场景类型字典(含本计划下的统计)
* GET /v1/open/scenes
*/
public function scenes()
{
$ctx = $this->ctx();
$scenes = Db::name('plan_scene')
->field('id,name,image,sort')
->where(['status' => 1, 'deleteTime' => 0])
->order('sort DESC, id ASC')
->select();
if (empty($scenes)) {
return ResponseHelper::success([], 'ok');
}
$sceneIds = array_column($scenes, 'id');
$stats = Db::name('customer_acquisition_task')->alias('ac')
->join('task_customer tc', 'tc.task_id = ac.id')
->where([
['ac.companyId', '=', $ctx['companyId']],
['ac.deleteTime', '=', 0],
['ac.sceneId', 'in', $sceneIds],
])
->field([
'ac.sceneId',
Db::raw('COUNT(1) as allNum'),
Db::raw("SUM(CASE WHEN tc.status IN (1,2,3,4) THEN 1 ELSE 0 END) as addNum"),
Db::raw("SUM(CASE WHEN tc.status = 4 THEN 1 ELSE 0 END) as passNum"),
])
->group('ac.sceneId')
->select();
$statsMap = [];
foreach ($stats as $r) {
$sid = is_array($r) ? ($r['sceneId'] ?? 0) : ($r->sceneId ?? 0);
$statsMap[(int) $sid] = [
'allNum' => (int) (is_array($r) ? ($r['allNum'] ?? 0) : ($r->allNum ?? 0)),
'addNum' => (int) (is_array($r) ? ($r['addNum'] ?? 0) : ($r->addNum ?? 0)),
'passNum' => (int) (is_array($r) ? ($r['passNum'] ?? 0) : ($r->passNum ?? 0)),
];
}
foreach ($scenes as &$row) {
$s = $statsMap[(int) $row['id']] ?? ['allNum' => 0, 'addNum' => 0, 'passNum' => 0];
$row['allNum'] = $s['allNum'];
$row['addNum'] = $s['addNum'];
$row['passNum'] = $s['passNum'];
}
unset($row);
return ResponseHelper::success($scenes, 'ok');
}
/**
* 当前 Token 所属计划下的客资列表
* GET /v1/open/leads?page=1&limit=50&status=&startTime=&endTime=
*/
public function leads()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$status = Request::param('status');
$startTime = intval(Request::param('startTime', 0));
$endTime = intval(Request::param('endTime', 0));
$where = [
['task_id', '=', $ctx['planId']],
];
if ($status !== null && $status !== '') {
$where[] = ['status', '=', intval($status)];
}
if ($startTime > 0 && $endTime > 0) {
$where[] = ['createTime', 'between', [$startTime, $endTime]];
}
$query = Db::name('task_customer')->where($where);
$total = (clone $query)->count();
$rows = $query
->field('id,task_id,channelId,phone,name,source,remark,tags,siteTags,status,createTime,updateTime')
->order('createTime DESC')
->page($pager['page'], $pager['limit'])
->select();
$list = array_map(function ($r) {
$r['tags'] = json_decode($r['tags'] ?? '[]', true) ?: [];
$r['siteTags'] = json_decode($r['siteTags'] ?? '[]', true) ?: [];
return $r;
}, $rows);
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $list,
], 'ok');
}
/**
* 当前公司下的微信号
* GET /v1/open/wechats?page=1&limit=50&alive=1
*/
public function wechats()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$alive = Request::param('alive');
// 先按公司过滤设备账号(沿用 StatsController 的 join 路径)
$accountIds = Db::table('s2_company_account')
->where('departmentId', $ctx['companyId'])
->column('id');
if (empty($accountIds)) {
return ResponseHelper::success(['total' => 0, 'list' => []], 'ok');
}
$query = Db::table('s2_wechat_account')->whereIn('deviceAccountId', $accountIds);
if ($alive !== null && $alive !== '') {
$query->where('wechatAlive', intval($alive));
}
$total = (clone $query)->count();
$rows = $query
->field('id,wechatId,nickname,avatar as headImage,wechatAlive,deviceAccountId,createTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $rows,
], 'ok');
}
/**
* 当前公司下的设备列表(轻量版)
* GET /v1/open/devices?page=1&limit=50
*/
public function devices()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$query = Db::table('s2_company_account')
->where('departmentId', $ctx['companyId']);
$total = (clone $query)->count();
$rows = $query
->field('id,userName,realName,memo,alive,lastLoginTime,createTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $rows,
], 'ok');
}
/**
* 按 IMEI / 设备序列号 / accountId 解析绑定用手机 accountId
* GET /v1/open/devices/resolve?imei=bc8bf95f...
*/
public function resolveDevice()
{
$ctx = $this->ctx();
// §7.4 支持按 accountId / imei / deviceIdMd5 解析(白名单:必须命中已登记设备)
$imei = trim((string) Request::param('imei', ''));
if ($imei === '') {
$imei = trim((string) Request::param('accountId', ''));
}
if ($imei === '') {
$imei = trim((string) Request::param('keyword', ''));
}
if ($imei === '') {
return ResponseHelper::error('accountId / imei 必填', 400);
}
$resolved = OpenPlatformService::resolveAccountByIdentifier((int) $ctx['companyId'], $imei);
if (!$resolved || empty($resolved['accountId'])) {
// 未登记设备:禁止绑定,统一文案,供扫码端直接展示
return ResponseHelper::error('无法绑定,请联系管理员', 403, [
'bindable' => false,
'reason' => 'NOT_REGISTERED',
]);
}
$resolved['bindable'] = true;
return ResponseHelper::success($resolved, 'ok');
}
/**
* 按存客宝手机号列出该公司下可绑定的工作手机
* GET /v1/open/terminal/devices-by-phone?phone=13800138000
*/
public function devicesByPhone()
{
$ctx = $this->ctx();
$phone = trim((string) Request::param('phone', ''));
if ($phone === '') {
return ResponseHelper::error('phone 必填', 400);
}
$list = OpenPlatformService::listDevicesByPhone((int) $ctx['companyId'], $phone);
return ResponseHelper::success([
'total' => count($list),
'list' => $list,
], 'ok');
}
/**
* 登记 AI数智员工终端 IMEIdeviceIdMd5供 VIP 自动登录
* POST /v1/open/terminal/register-imei { accountId, deviceIdMd5 }
*/
public function registerTerminalImei()
{
$ctx = $this->ctx();
$deviceIdMd5 = trim((string) Request::param('deviceIdMd5', ''));
$accountId = (int) Request::param('accountId', 0);
if ($deviceIdMd5 === '' || $accountId <= 0) {
return ResponseHelper::error('accountId 与 deviceIdMd5 必填', 400);
}
// §7 白名单铁律accountId 必须对应已登记的 ck_device否则禁止绑定不得扫即注册
$resolved = OpenPlatformService::resolveAccountByIdentifier((int) $ctx['companyId'], (string) $accountId);
if (!$resolved || (int) ($resolved['accountId'] ?? 0) !== $accountId) {
return ResponseHelper::error('无法绑定,请联系管理员', 403, [
'bindable' => false,
'reason' => 'NOT_REGISTERED',
]);
}
$ok = OpenPlatformService::registerTerminalImei((int) $ctx['companyId'], $accountId, $deviceIdMd5);
if (!$ok) {
return ResponseHelper::error('无法绑定,请联系管理员', 403, [
'bindable' => false,
'reason' => 'NOT_REGISTERED',
]);
}
return ResponseHelper::success([
'accountId' => $accountId,
'deviceIdMd5' => strtolower($deviceIdMd5),
'imei' => strtolower($deviceIdMd5),
'bindable' => true,
], 'ok');
}
/**
* 校验终端 deviceIdMd5 与 accountId 是否已绑定(自动登录前置)
* GET /v1/open/terminal/verify?deviceIdMd5=&accountId=
*/
public function verifyTerminal()
{
$ctx = $this->ctx();
$deviceIdMd5 = trim((string) Request::param('deviceIdMd5', ''));
$accountId = (int) Request::param('accountId', 0);
if ($deviceIdMd5 === '' || $accountId <= 0) {
return ResponseHelper::error('deviceIdMd5 与 accountId 必填', 400);
}
$matched = OpenPlatformService::verifyTerminalDeviceMatch(
(int) $ctx['companyId'],
$deviceIdMd5,
$accountId
);
if (!$matched) {
// 也尝试直接用 deviceIdMd5 作为 IMEI 解析
$resolved = OpenPlatformService::resolveAccountByIdentifier((int) $ctx['companyId'], $deviceIdMd5);
$matched = $resolved && (int) ($resolved['accountId'] ?? 0) === $accountId;
}
return ResponseHelper::success([
'matched' => $matched,
'accountId' => $accountId,
'deviceIdMd5' => strtolower($deviceIdMd5),
], $matched ? 'ok' : '未匹配');
}
// =========================================================
// v1.1 新增领域流量池V2
// =========================================================
/**
* 流量池成员列表V2
* GET /v1/open/pool/list?page=&limit=&keyword=
*
* 字段口径与 TrafficPoolV2Controller.getPoolList 保持一致companyId 过滤 + 软删除排除)
*/
public function poolList()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$keyword = trim((string) Request::param('keyword', ''));
$where = [
['companyId', '=', $ctx['companyId']],
['deleteTime', '=', 0],
];
if ($keyword !== '') {
$where[] = ['mobile|wechatId|nickname', 'like', '%' . $keyword . '%'];
}
$query = Db::name('traffic_pool')->where($where);
$total = (clone $query)->count();
$rows = $query
->field('id,companyId,identifierType,mobile,wechatId,nickname,avatar,createTime,updateTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $rows,
], 'ok');
}
/**
* 流量池成员详情V2
* GET /v1/open/pool/detail?id=
*/
public function poolDetail()
{
$ctx = $this->ctx();
$id = intval(Request::param('id', 0));
if ($id <= 0) {
return ResponseHelper::error('id 不能为空', 400);
}
$row = Db::name('traffic_pool')
->where('id', $id)
->where('companyId', $ctx['companyId'])
->where('deleteTime', 0)
->find();
if (!$row) {
return ResponseHelper::error('未找到或不属于当前公司', 404);
}
return ResponseHelper::success($row, 'ok');
}
// =========================================================
// v1.1 新增领域:内容库
// =========================================================
/**
* 内容库列表
* GET /v1/open/content/library?page=&limit=
*
* 字段口径与 ContentLibraryController.getList 一致companyId 过滤 + 未删除)
*/
public function contentLibrary()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$where = [
['companyId', '=', $ctx['companyId']],
['isDel', '=', 0],
];
$query = Db::name('content_library')->where($where);
$total = (clone $query)->count();
$rows = $query
->field('id,companyId,name,type,description,createTime,updateTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $rows,
], 'ok');
}
/**
* 内容库素材列表
* GET /v1/open/content/items?libraryId=&page=&limit=
*/
public function contentItems()
{
$ctx = $this->ctx();
$libraryId = intval(Request::param('libraryId', 0));
if ($libraryId <= 0) {
return ResponseHelper::error('libraryId 不能为空', 400);
}
// 校验内容库归属
$lib = Db::name('content_library')
->where('id', $libraryId)
->where('companyId', $ctx['companyId'])
->where('isDel', 0)
->find();
if (!$lib) {
return ResponseHelper::error('内容库不存在或不属于当前公司', 404);
}
$pager = OpenPlatformService::resolvePager();
$contentType = Request::param('contentType', '');
$where = [
['libraryId', '=', $libraryId],
['isDel', '=', 0],
];
if ($contentType !== '' && $contentType !== null) {
$where[] = ['contentType', '=', intval($contentType)];
}
$query = Db::name('content_item')->where($where);
$total = (clone $query)->count();
$rows = $query
->field('id,libraryId,contentType,title,content,type,resUrls,ossUrls,urls,coverImage,createTime,updateTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
$list = array_map(function ($row) {
return ContentItemMediaService::formatForOpenApi(is_array($row) ? $row : $row->toArray());
}, $rows ?: []);
return ResponseHelper::success([
'libraryId' => $libraryId,
'libraryName' => $lib['name'] ?? '',
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $list,
], 'ok');
}
/**
* 设备任务开关(工作手机 Agent / 外部系统只读拉取)
* GET /v1/open/devices/task-config?deviceId=&imei=
*/
public function deviceTaskConfig()
{
$ctx = $this->ctx();
$companyId = (int)$ctx['companyId'];
$deviceId = intval(Request::param('deviceId', 0));
if ($deviceId <= 0) {
$imei = trim((string)Request::param('imei', ''));
if ($imei === '') {
$imei = trim((string)Request::param('keyword', ''));
}
if ($imei !== '') {
$resolved = OpenPlatformService::resolveAccountByIdentifier($companyId, $imei);
$deviceId = (int)($resolved['deviceId'] ?? 0);
}
}
if ($deviceId <= 0) {
return ResponseHelper::error('deviceId 或 imei 必填', 400);
}
try {
DeviceTaskConfigService::assertDeviceExists($deviceId, $companyId);
$row = DeviceTaskConfigService::getOrCreate($deviceId, $companyId);
return ResponseHelper::success([
'deviceId' => $deviceId,
'companyId' => $companyId,
'features' => DeviceTaskConfigService::toFeatures($row),
'switches' => [
'autoAddFriend' => (int)($row['autoAddFriend'] ?? 0),
'autoCustomerDev' => (int)($row['autoCustomerDev'] ?? 0),
'autoReply' => (int)($row['autoReply'] ?? 0),
'momentsSync' => (int)($row['momentsSync'] ?? 0),
'aiChat' => (int)($row['aiChat'] ?? 0),
],
], 'ok');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
// =========================================================
// v1.1 新增领域算力tokens
// =========================================================
/**
* 算力余额
* GET /v1/open/tokens/balance
*
* 注意:现有 tokens_company 是 (companyId, userId) 双键。
* 开放平台没有"当前用户"概念,按 companyId 汇总余额。
*/
public function tokensBalance()
{
$ctx = $this->ctx();
$balance = (int) Db::name('tokens_company')
->where('companyId', $ctx['companyId'])
->sum('tokens');
// 今日 / 本月消耗type=0 减少)
$start = strtotime(date('Y-m-d 00:00:00'));
$end = strtotime(date('Y-m-d 23:59:59'));
$todayUsed = (int) Db::name('tokens_record')
->where('companyId', $ctx['companyId'])
->where('type', 0)
->whereBetween('createTime', [$start, $end])
->sum('tokens');
$monthStart = strtotime(date('Y-m-01 00:00:00'));
$monthEnd = strtotime(date('Y-m-t 23:59:59'));
$monthUsed = (int) Db::name('tokens_record')
->where('companyId', $ctx['companyId'])
->where('type', 0)
->whereBetween('createTime', [$monthStart, $monthEnd])
->sum('tokens');
$totalRecharged = (int) Db::name('tokens_record')
->where('companyId', $ctx['companyId'])
->where('type', 1)
->sum('tokens');
return ResponseHelper::success([
'companyId' => $ctx['companyId'],
'remainingTokens' => $balance,
'todayUsed' => $todayUsed,
'monthUsed' => $monthUsed,
'totalRecharged' => $totalRecharged,
'timestamp' => time(),
], 'ok');
}
/**
* 算力使用明细(支持类型/时间过滤)
* GET /v1/open/tokens/usage?page=&limit=&type=&startTime=&endTime=
*
* type: 0=消费 1=充值/分配(来自 tokens_record.type
*/
public function tokensUsage()
{
$ctx = $this->ctx();
$pager = OpenPlatformService::resolvePager();
$type = Request::param('type');
$startTime = intval(Request::param('startTime', 0));
$endTime = intval(Request::param('endTime', 0));
$where = [['companyId', '=', $ctx['companyId']]];
if ($type !== null && $type !== '') {
$where[] = ['type', '=', intval($type)];
}
if ($startTime > 0 && $endTime > 0) {
$where[] = ['createTime', 'between', [$startTime, $endTime]];
}
$query = Db::name('tokens_record')->where($where);
$total = (clone $query)->count();
$rows = $query
->field('id,companyId,userId,type,form,tokens,balanceTokens,remarks,createTime')
->order('id DESC')
->page($pager['page'], $pager['limit'])
->select();
return ResponseHelper::success([
'total' => $total,
'page' => $pager['page'],
'limit' => $pager['limit'],
'list' => $rows,
], 'ok');
}
/**
* 平台关键指标(设备 / 微信 / 客资 / 计划 / 今日新增 / + v1.1 算力)
* GET /v1/open/stats
*/
public function stats()
{
$ctx = $this->ctx();
$companyId = $ctx['companyId'];
$accountIds = Db::table('s2_company_account')
->where('departmentId', $companyId)
->column('id');
$deviceNum = count($accountIds);
$wechatNum = 0;
$aliveWechatNum = 0;
if (!empty($accountIds)) {
$wechatNum = (int) Db::table('s2_wechat_account')->whereIn('deviceAccountId', $accountIds)->count();
$aliveWechatNum = (int) Db::table('s2_wechat_account')
->whereIn('deviceAccountId', $accountIds)
->where('wechatAlive', 1)
->count();
}
$planNum = (int) Db::name('customer_acquisition_task')
->where(['companyId' => $companyId, 'deleteTime' => 0, 'status' => 1])
->count();
$start = strtotime(date('Y-m-d 00:00:00'));
$end = strtotime(date('Y-m-d 23:59:59'));
$todayNewCustomers = (int) Db::name('customer_acquisition_task')->alias('ac')
->join('task_customer tc', 'tc.task_id = ac.id')
->where(['ac.companyId' => $companyId, 'ac.deleteTime' => 0])
->whereBetween('tc.createTime', [$start, $end])
->count();
$totalCustomers = (int) Db::name('customer_acquisition_task')->alias('ac')
->join('task_customer tc', 'tc.task_id = ac.id')
->where(['ac.companyId' => $companyId, 'ac.deleteTime' => 0])
->count();
$onlineRate = $wechatNum > 0
? round($aliveWechatNum / $wechatNum * 100, 1) . '%'
: '0%';
// v1.1:算力概览(不动 TokensController复用 tokens_company / tokens_record 表)
$tokensRemaining = (int) Db::name('tokens_company')->where('companyId', $companyId)->sum('tokens');
$tokensTodayUsed = (int) Db::name('tokens_record')
->where('companyId', $companyId)
->where('type', 0)
->whereBetween('createTime', [$start, $end])
->sum('tokens');
return ResponseHelper::success([
'companyId' => $companyId,
'deviceNum' => $deviceNum,
'wechatNum' => $wechatNum,
'aliveWechatNum' => $aliveWechatNum,
'wechatOnlineRate' => $onlineRate,
'planNum' => $planNum,
'totalCustomers' => $totalCustomers,
'todayNewCustomers' => $todayNewCustomers,
'tokensRemaining' => $tokensRemaining,
'tokensTodayUsed' => $tokensTodayUsed,
'timestamp' => time(),
], 'ok');
}
}