服务端-部分代码

This commit is contained in:
Ghost
2026-03-17 11:53:41 +08:00
parent 90243e7180
commit 28b478dab5
33 changed files with 9689 additions and 0 deletions

View File

@@ -0,0 +1,525 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\model\User as UserModel;
use app\model\WechatUser;
use app\common\service\JwtService;
use app\common\service\WechatService;
use think\facade\Request;
use think\facade\Db;
/**
* 前端用户认证API控制器
*/
class Auth extends BaseController
{
/**
* 用户登录(前端)
* @return \think\response\Json
*/
public function login()
{
$username = Request::param('username', '');
$password = Request::param('password', '');
if (empty($username) || empty($password)) {
return error('用户名和密码不能为空', 400);
}
// 注意mbti_users表只存储管理员和超管前端用户需要存储在单独的表中
// 这里暂时返回错误,需要创建前端用户表后再实现
return error('前端用户登录功能暂未实现,请联系管理员', 501);
// 查找用户(如果将来有前端用户表,使用以下代码)
// $user = Db::name('frontend_users')
// ->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, '绑定企业成功');
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\JwtService;
use think\facade\Db;
use think\facade\Log;
/**
* 存客宝获客线索上报
* 将小程序用户行为(申请咨询/完成付款)上报给存客宝系统
*/
class CrmReport extends BaseController
{
/**
* POST api/crm/report
* 接收前端上报请求,向存客宝发送线索数据
*
* @param string apiKey 类目配置中的存客宝KEYconsultWechat字段
* @param string source 线索来源描述,如"个人深度服务-1v1深度解读"
* @param string remark 备注,如"申请咨询"/"完成付款"
* @param string tags 可选,逗号分隔的微信标签
* @param string siteTags 可选,逗号分隔的站内标签
*/
public function report()
{
// 获取当前用户(支持中间件注入和手动解析两种方式)
$user = $this->request->user ?? null;
if (!$user) {
$token = JwtService::getTokenFromRequest($this->request);
if ($token) {
$payload = JwtService::verifyToken($token);
if ($payload) {
$user = [
'source' => $payload['source'] ?? '',
'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null,
];
}
}
}
$userId = (int) ($user['user_id'] ?? 0);
// 接收参数
$apiKey = trim((string) ($this->request->param('apiKey', '') ?? ''));
$source = trim((string) ($this->request->param('source', '') ?? ''));
$remark = trim((string) ($this->request->param('remark', '') ?? ''));
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
// apiKey 为空则跳过,不影响主流程
if (empty($apiKey)) {
return success(['reported' => false, 'reason' => 'no_api_key']);
}
// 从数据库获取用户信息手机号、openid、昵称
$phone = '';
$openid = '';
$nickname = '';
if ($userId > 0) {
$wechatUser = Db::name('wechat_users')
->where('id', $userId)
->field('phone, openid, nickname')
->find();
if ($wechatUser) {
$phone = (string) ($wechatUser['phone'] ?? '');
$openid = (string) ($wechatUser['openid'] ?? '');
$nickname = (string) ($wechatUser['nickname'] ?? '');
}
}
// 至少需要手机号或微信号,否则没有意义
if (empty($phone) && empty($openid)) {
return success(['reported' => false, 'reason' => 'no_identifier']);
}
// 读取接口地址(从 .env 的 API_URL
$apiUrl = env('API_URL', 'https://ckbapi.quwanzhi.com/v1/api/scenarios');
$timestamp = time();
// 构建请求参数(只加非空字段)
$params = ['apiKey' => $apiKey, 'timestamp' => $timestamp];
if ($phone !== '') $params['phone'] = $phone;
if ($nickname !== '') $params['name'] = $nickname;
if ($source !== '') $params['source'] = $source;
if ($remark !== '') $params['remark'] = $remark;
if ($tags !== '') $params['tags'] = $tags;
if ($siteTags !== '') $params['siteTags'] = $siteTags;
// 生成签名portrait 不参与签名,需在签名后单独附加)
$params['sign'] = self::generateSign($params, $apiKey);
// 附加用户画像(从最近测试结果构建,不参与签名)
$portrait = self::buildPortrait($userId);
if ($portrait !== null) {
$params['portrait'] = $portrait;
}
// 发起请求
$result = self::callApi($apiUrl, $params);
if ($result['success']) {
return success(['reported' => true]);
}
Log::warning('[CrmReport] 上报失败 userId=' . $userId . ' reason=' . json_encode($result, JSON_UNESCAPED_UNICODE));
// 上报失败不影响主业务,始终返回成功
return success(['reported' => false, 'reason' => $result['error'] ?? 'api_error']);
}
/**
* 从数据库读取用户最近一次 MBTI / DISC / PDP 测试结果,构建 portrait 对象
* portrait 整体不参与签名,直接附加到请求体中(见接口文档 §2.3
*/
private static function buildPortrait(int $userId): ?array
{
if ($userId <= 0) {
return null;
}
// 一次查出所有相关类型的最新记录(按时间倒序)
$rows = Db::name('test_results')
->where('userId', $userId)
->whereIn('testType', ['mbti', 'disc', 'pdp'])
->field('testType, resultData, createdAt')
->order('createdAt', 'desc')
->select()
->toArray();
$found = [];
foreach ($rows as $row) {
$type = $row['testType'];
if (isset($found[$type])) continue; // 只取每种类型的最新一条
$data = [];
if (!empty($row['resultData'])) {
$decoded = json_decode($row['resultData'], true);
$data = is_array($decoded) ? $decoded : [];
}
switch ($type) {
case 'mbti':
$val = $data['mbtiType'] ?? $data['mbti'] ?? '';
if ($val !== '') $found['mbti'] = (string) $val;
break;
case 'disc':
$val = $data['dominantType'] ?? $data['disc'] ?? '';
if ($val !== '') $found['disc'] = $val . '型';
break;
case 'pdp':
$val = $data['description']['type'] ?? $data['pdp'] ?? '';
if ($val !== '') $found['pdp'] = (string) $val;
break;
}
}
if (empty($found)) {
return null;
}
return [
'type' => 4, // 互动(咨询/购买行为)
'source' => 0, // 本站
'sourceData' => $found,
'remark' => '性格测试画像',
'uniqueId' => 'wxmp_' . $userId . '_' . date('YmdH'), // 同一小时内去重
];
}
/**
* 生成存客宝签名
* 规则(来自接口文档 §2.3
* 1. 移除 sign / apiKey / portrait
* 2. 移除值为 null 或空字符串的字段
* 3. 按参数名 ASCII 升序排序
* 4. 只取"值"按顺序拼接
* 5. 第一次 MD5
* 6. 拼接 apiKey 后第二次 MD5得到最终签名
*/
private static function generateSign(array $params, string $apiKey): string
{
unset($params['sign'], $params['apiKey'], $params['portrait']);
$params = array_filter($params, static function ($value) {
return !is_null($value) && $value !== '';
});
ksort($params);
$stringToSign = implode('', array_values($params));
$firstMd5 = md5($stringToSign);
return md5($firstMd5 . $apiKey);
}
/**
* 通过 cURL 调用存客宝接口
*/
private static function callApi(string $url, array $params): array
{
$payload = json_encode($params, JSON_UNESCAPED_UNICODE);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Accept: application/json',
'Content-Length: ' . strlen($payload),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
return ['success' => false, 'error' => 'curl:' . $curlError];
}
$data = json_decode($response, true);
if (is_array($data) && isset($data['code']) && (int) $data['code'] === 200) {
return ['success' => true, 'data' => $data];
}
return [
'success' => false,
'error' => $data['message'] ?? 'unknown',
'response' => $response,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\model\EnterpriseResumeUpload;
use think\facade\Db;
use think\facade\Request;
/**
* 企业版简历上传记录 API仅记录与列表支持预览用 URL
*/
class EnterpriseResume extends BaseController
{
/**
* 获取当前用户的简历上传记录列表
* GET /api/enterprise/resume-uploads
*/
public function list()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$enterpriseId = Request::param('enterpriseId');
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 50)));
$query = EnterpriseResumeUpload::where('userId', $userId)
->field('id, userId, enterpriseId, fileUrl, fileName, is_default, createdAt as created_at_ts')
->order('createdAt', 'desc');
if ($enterpriseId !== null && $enterpriseId !== '') {
$eid = (int) $enterpriseId;
if ($eid > 0) {
$query->where('enterpriseId', $eid);
} else {
$query->whereNull('enterpriseId');
}
}
$total = $query->count();
$rows = $query->page($page, $pageSize)->select()->toArray();
$list = [];
foreach ($rows as $row) {
$ts = $this->pickCreatedAt($row);
$list[] = [
'id' => (int) ($row['id'] ?? 0),
'url' => (string) ($row['fileUrl'] ?? ''),
'fileName' => (string) ($row['fileName'] ?? ''),
'uploadedAt' => $ts,
'uploadedAtStr' => $this->formatTime($ts),
'isDefault' => (int) ($row['is_default'] ?? 0) === 1,
];
}
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
]);
}
/**
* 新增一条简历上传记录(上传文件后由前端调用)
* POST /api/enterprise/resume-uploads
* body: { "url": "文件URL", "fileName": "原始文件名", "enterpriseId": 可选 }
*/
public function add()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$url = Request::param('url');
$fileName = Request::param('fileName', '');
$enterpriseId = Request::param('enterpriseId');
if (empty($url) || !is_string($url)) {
return error('缺少文件地址 url', 400);
}
$url = trim($url);
if ($url === '') {
return error('url 不能为空', 400);
}
$fileName = is_string($fileName) ? trim($fileName) : '';
if ($fileName === '') {
$fileName = '简历文件';
}
$eid = null;
if ($enterpriseId !== null && $enterpriseId !== '') {
$eid = (int) $enterpriseId;
if ($eid <= 0) {
$eid = null;
}
}
// 前端未传或为 0 时用当前用户绑定企业wechat_users.enterpriseId补全
if ($eid === null) {
$wu = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
if (!empty($wu['enterpriseId']) && (int) $wu['enterpriseId'] > 0) {
$eid = (int) $wu['enterpriseId'];
}
}
$record = new EnterpriseResumeUpload();
$record->userId = $userId;
$record->enterpriseId = $eid;
$record->fileUrl = $url;
$record->fileName = $fileName;
$record->createdAt = time();
$record->save();
return success([
'id' => (int) $record->id,
'url' => $record->fileUrl,
'fileName' => $record->fileName,
'uploadedAt' => (int) $record->createdAt,
'uploadedAtStr' => $this->formatTime($record->createdAt),
]);
}
/**
* 设为默认简历(同用户同企业仅一条为默认)
* POST /api/enterprise/resume-uploads/set-default body: { "id": 记录ID }
*/
public function setDefault()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$id = (int) Request::param('id', 0);
if ($id <= 0) {
return error('缺少或无效的记录 id', 400);
}
$record = EnterpriseResumeUpload::where('id', $id)->where('userId', $userId)->find();
if (!$record) {
return error('记录不存在或无权操作', 404);
}
$eid = isset($record->enterpriseId) && (int) $record->enterpriseId > 0 ? (int) $record->enterpriseId : null;
Db::name('enterprise_resume_uploads')
->where('userId', $userId)
->where(function ($q) use ($eid) {
if ($eid !== null) {
$q->where('enterpriseId', $eid);
} else {
$q->whereNull('enterpriseId');
}
})
->update(['is_default' => 0]);
$record->is_default = 1;
$record->save();
return success(['id' => (int) $record->id, 'isDefault' => true]);
}
/**
* 删除一条简历上传记录(仅本人可删)
* POST /api/enterprise/resume-uploads/delete body: { "id": 记录ID }
*/
public function delete()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$id = (int) Request::param('id', 0);
if ($id <= 0) {
return error('缺少或无效的记录 id', 400);
}
$record = EnterpriseResumeUpload::where('id', $id)->where('userId', $userId)->find();
if (!$record) {
return error('记录不存在或无权操作', 404);
}
$record->delete();
return success(['id' => $id]);
}
/**
* 从查询行中取出时间戳(优先用 SQL 别名 created_at_ts再兼容 createdAt/created_at
* 若值为 4 位数(如年份 2026则视为无效返回 0。
*/
private function pickCreatedAt(array $row): int
{
$v = $row['created_at_ts'] ?? $row['createdAt'] ?? $row['created_at'] ?? $row['createdat'] ?? null;
if ($v === null) {
return 0;
}
$ts = (int) $v;
if ($ts <= 0) {
return 0;
}
// 小于约 1971 年的秒数视为无效(避免误存为年份 2026 等)
if ($ts < 86400 * 365) {
return 0;
}
return $ts;
}
private function formatTime($ts)
{
$ts = (int) $ts;
if ($ts <= 0 || $ts < 86400 * 365) {
return '';
}
$d = getdate($ts);
return sprintf(
'%04d-%02d-%02d %02d:%02d',
$d['year'],
$d['mon'],
$d['mday'],
$d['hours'],
$d['minutes']
);
}
}

View File

@@ -0,0 +1,909 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
use think\facade\Request;
use think\facade\Db;
/**
* 支付与订单控制器(小程序/前端)
*
* 目标:
* - 统一创建本地订单mbti_orders记录用户、企业、产品类型与金额
* - 对接小程序 payment.js 的 create/notify/query 三个接口
* - 支持人脸/MBTI/DISC/PDP/完整报告/团队分析/充值/深度服务等多种产品类型
* - 系统所有金额均以「分」为单位:入参、落库、出参均为分。
*/
class Payment extends BaseController
{
/**
* POST /api/payment/create
* 小程序发起支付前调用:创建本地订单并返回调起微信支付所需参数
* 入参 amount 为分;订单表 amount 存分;返回 amount 为分。
*/
public function create()
{
try {
$user = $this->resolveUser();
if (!$user) {
return error('未登录', 401);
}
$orderId = Request::param('orderId', '');
$amountFen = (int) Request::param('amount', 0); // 单位:分
$description = Request::param('description', '');
$productType = Request::param('productType', '');
$paymentMethod = Request::param('paymentMethod', 'wechat');
$openId = Request::param('openId', '');
$quantity = (int) Request::param('quantity', 1);
$testResultId = (int) Request::param('testResultId', 0); // 可选,关联 mbti_test_results.id
$deepProductId = (string) Request::param('deepProductId', ''); // 深度服务套餐ID/产品Key来自 deep-pricing.categories
$enterpriseIdParam = (int) Request::param('enterpriseId', 0);
if (empty($orderId)) {
return error('订单ID不能为空', 400);
}
if (empty($productType)) {
return error('产品类型不能为空', 400);
}
if ($quantity <= 0) {
$quantity = 1;
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('用户信息异常', 400);
}
// 企业ID 与金额优先从 test_results 读取历史记录进入enterpriseId 为空则按个人价;金额用 paidAmount
$enterpriseId = null;
$fixedAmountFen = null;
// 未显式传 testResultId 时:优先绑定到“最近一条同类型测试”,并以该记录的 enterpriseId/paidAmount 定价
if ($testResultId <= 0) {
$testTypeMap = [
'face' => 'face',
'mbti' => 'mbti',
'disc' => 'disc',
'pdp' => 'pdp',
'resume' => 'resume',
];
if (isset($testTypeMap[$productType])) {
$latestTest = Db::name('test_results')
->where('userId', $userId)
->where('testType', $testTypeMap[$productType])
->order('createdAt', 'desc')
->find();
if ($latestTest && !empty($latestTest['id'])) {
$testResultId = (int) $latestTest['id'];
}
}
}
if ($testResultId > 0) {
$tr = Db::name('test_results')
->where('id', $testResultId)
->where('userId', $userId)
->field('enterpriseId,paidAmount,requiresPayment,testType')
->find();
if ($tr) {
$enterpriseId = !empty($tr['enterpriseId']) ? (int) $tr['enterpriseId'] : null;
$paidAmount = isset($tr['paidAmount']) ? (int) $tr['paidAmount'] : 0;
if ($paidAmount > 0) {
$fixedAmountFen = $paidAmount;
}
}
}
// 充值场景优先使用显式传入的企业ID否则回退到当前用户已绑定企业
if ($productType === 'recharge') {
if ($enterpriseIdParam > 0) {
$enterpriseId = $enterpriseIdParam;
} elseif (empty($enterpriseId)) {
$enterpriseId = $this->resolveEnterpriseId($userId);
}
if (empty($enterpriseId)) {
return error('充值必须指定企业', 400);
}
}
// 计算订单金额与定价类型personal/enterprise
if ($fixedAmountFen !== null) {
$pricingType = $enterpriseId ? 'enterprise' : 'personal';
$amountFenCalculated = $fixedAmountFen;
} else {
[$amountFenCalculated, $pricingType] = $this->calculateAmount(
$productType,
$quantity,
$amountFen,
$user,
$enterpriseId,
$deepProductId
);
}
if ($amountFenCalculated <= 0) {
return error('订单金额无效,请检查定价配置或请求参数', 400);
}
// 检查订单是否已存在,避免重复创建
$existing = Db::name('orders')
->where('orderNo', $orderId)
->find();
$now = time();
if ($existing) {
// 若已存在且已支付/关闭,则不允许重新创建
if (in_array($existing['status'], ['paid', 'completed', 'cancelled', 'refunded', 'failed'])) {
return error('订单已存在且状态为 ' . $existing['status'], 400);
}
// 待支付订单允许覆盖部分字段(金额/描述/支付方式),金额为分
Db::name('orders')
->where('id', $existing['id'])
->update([
'amount' => $amountFenCalculated,
'productType' => $productType,
'productTitle' => $description,
'payMethod' => $paymentMethod,
'updatedAt' => $now,
]);
$orderIdDb = (int) $existing['id'];
} else {
$orderIdDb = Db::name('orders')->insertGetId([
'orderNo' => $orderId,
'userId' => $userId,
'enterpriseId' => $enterpriseId,
'productType' => $productType,
'productTitle' => $description,
'amount' => $amountFenCalculated,
'status' => 'pending',
'payMethod' => $paymentMethod,
'payTime' => null,
'createdAt' => $now,
'updatedAt' => $now,
]);
}
// 若传入 testResultId关联该测试结果到本订单仅更新属于当前用户的记录
if ($testResultId > 0) {
Db::name('test_results')
->where('id', $testResultId)
->where('userId', $userId)
->update([
'orderId' => $orderIdDb,
'updatedAt' => $now,
]);
} else {
// 未显式传 testResultId 时:自动将当前用户最近一次相关测试记录绑定到本订单
// 例如:人脸报告 → 绑定最近一条 testType=face 的记录
$testTypeMap = [
'face' => 'face',
'mbti' => 'mbti',
'disc' => 'disc',
'pdp' => 'pdp',
'resume' => 'resume',
];
if (isset($testTypeMap[$productType])) {
$testType = $testTypeMap[$productType];
$latestTest = Db::name('test_results')
->where('userId', $userId)
->where('testType', $testType)
->order('createdAt', 'desc')
->find();
if ($latestTest) {
Db::name('test_results')
->where('id', $latestTest['id'])
->update([
'orderId' => $orderIdDb,
'updatedAt'=> $now,
]);
}
}
}
// 真实对接微信统一下单,生成 prepay_id 等参数
$wechatConfig = [
'appid' => env('WECHAT_APPID', ''), // 小程序 AppID
'mch_id' => env('MCH_ID', ''), // 商户号
'api_key' => env('API_KEY', ''), // API 密钥MD5
'notify_url' => env('NOTIFY_URL', ''), // 支付结果通知回调
];
if (
empty($wechatConfig['appid']) ||
empty($wechatConfig['mch_id']) ||
empty($wechatConfig['api_key']) ||
empty($wechatConfig['notify_url'])
) {
return error('微信支付配置缺失,请联系管理员检查 .env', 500);
}
if (empty($openId)) {
return error('缺少微信 openId无法发起支付', 400);
}
// 微信 out_trade_no 最长 32 字节,这里做一次截断适配
$outTradeNo = strlen($orderId) > 32 ? substr($orderId, 0, 32) : $orderId;
$unifiedOrder = $this->createWechatUnifiedOrder(
$wechatConfig,
$outTradeNo,
$amountFenCalculated,
$description ?: 'AI性格测试-' . $productType,
$openId
);
if (empty($unifiedOrder['prepay_id'])) {
$msg = $unifiedOrder['message'] ?? '微信统一下单失败';
return error($msg, 500);
}
// 组装前端 wx.requestPayment 所需参数
$timeStamp = (string) time();
$nonceStr = md5(uniqid('wxpay_', true));
$pkg = 'prepay_id=' . $unifiedOrder['prepay_id'];
$signType = 'MD5';
$payParams = [
'appId' => $wechatConfig['appid'],
'timeStamp' => $timeStamp,
'nonceStr' => $nonceStr,
'package' => $pkg,
'signType' => $signType,
];
$paySign = $this->buildWechatSign($payParams, $wechatConfig['api_key']);
$paymentData = [
'timeStamp' => $timeStamp,
'nonceStr' => $nonceStr,
'package' => $pkg,
'signType' => $signType,
'paySign' => $paySign,
'prepayId' => $unifiedOrder['prepay_id'],
];
// 与小程序 payment.js 兼容;系统统一:金额均为分
return success(array_merge($paymentData, [
'orderId' => $orderId,
'orderDbId' => $orderIdDb,
'amount' => $amountFenCalculated,
'productType' => $productType,
'pricingType' => $pricingType,
'description' => $description,
'enterpriseId' => $enterpriseId,
]), '订单创建成功');
} catch (\Exception $e) {
return error('创建订单失败:' . $e->getMessage(), 500);
}
}
/**
* POST /api/payment/notify
* 小程序在 wx.requestPayment 成功回调后调用,用于通知后端更新订单状态。
* 当前实现为“前端通知模式”,后续可扩展为接收微信服务端回调。
*/
public function notify()
{
try {
$orderId = Request::param('orderId', '');
$prepayId = Request::param('prepayId', '');
$status = Request::param('status', 'success'); // success/failed/cancelled 等
if (empty($orderId)) {
return error('订单ID不能为空', 400);
}
$order = Db::name('orders')
->where('orderNo', $orderId)
->find();
if (!$order) {
return error('订单不存在', 404);
}
// 仅允许从 pending → 其他状态,避免重复更新已完成订单
if ($order['status'] !== 'pending' && $order['status'] !== 'paid') {
return success(null, '订单状态已更新,无需重复通知');
}
$now = time();
$newStatus = $order['status'];
if ($status === 'success') {
$newStatus = 'paid';
} elseif ($status === 'cancelled') {
$newStatus = 'cancelled';
} elseif ($status === 'failed') {
$newStatus = 'failed';
}
Db::name('orders')
->where('id', $order['id'])
->update([
'status' => $newStatus,
'payTime' => $status === 'success' ? ($order['payTime'] ?: $now) : $order['payTime'],
'updatedAt'=> $now,
]);
// 支付成功时:将关联该订单的测试结果标记为已付款,并记录当时付款金额(分)
if ($status === 'success') {
$paidAmountFen = isset($order['amount']) ? (int) $order['amount'] : 0;
Db::name('test_results')
->where('orderId', $order['id'])
->update([
'isPaid' => 1,
'paidAmount'=> $paidAmountFen ?: null,
'paidAt' => $now,
'updatedAt' => $now,
]);
// 企业四项测试支付后,订单金额进入企业余额
$this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now);
if (($order['productType'] ?? '') !== 'recharge') {
// 触发分销佣金结算
try {
\app\controller\api\Distribution::settleCommission((int) $order['id']);
} catch (\Exception $e) {
// 佣金结算失败不影响主流程
}
}
}
return success([
'orderId' => $orderId,
'status' => $newStatus,
'prepayId' => $prepayId,
], '订单状态已更新');
} catch (\Exception $e) {
return error('更新订单状态失败:' . $e->getMessage(), 500);
}
}
/**
* GET /api/payment/query
* 小程序查询订单状态:实时通过商户订单号调用微信 v3 查询接口(不依赖本地状态)。
*/
public function query()
{
try {
$orderId = Request::param('orderId', '');
if (empty($orderId)) {
return error('订单ID不能为空', 400);
}
// 本地订单(可选,只用于补充非微信字段;真实支付状态以微信返回为准)
$localOrder = Db::name('orders')
->where('orderNo', $orderId)
->find();
$wechat = $this->queryWechatOrderByOutTradeNo($orderId);
if (!$wechat['success']) {
return error($wechat['message'] ?? '查询微信订单失败', 500);
}
$data = $wechat['data'] ?? [];
$tradeState = $data['trade_state'] ?? 'UNKNOWN';
$status = $this->mapTradeStateToStatus($tradeState);
// 若本地有订单,顺带同步一次状态(不作为查询前置条件)
$now = time();
if ($localOrder && in_array($status, ['paid', 'completed', 'cancelled', 'refunded', 'failed'], true)) {
$payTime = $localOrder['payTime'] ?? null;
if (isset($data['time_end'])) {
$dt = \DateTime::createFromFormat('YmdHis', $data['time_end']);
if ($dt) {
$payTime = $dt->getTimestamp();
}
}
// 记录旧状态,用于后续判断是否从未支付 -> 已支付,避免重复统计
$oldStatus = $localOrder['status'] ?? null;
Db::name('orders')
->where('id', $localOrder['id'])
->update([
'status' => $status,
'payTime' => $payTime,
'wechatTransactionId' => $data['transaction_id'] ?? ($localOrder['wechatTransactionId'] ?? null),
'updatedAt' => $now,
]);
// 同步更新关联的测试结果(按 orderId 关联),写入付款金额与时间
$amountFromWechat = null;
if (isset($data['total_fee'])) {
$amountFromWechat = (int) $data['total_fee'];
}
if (in_array($status, ['paid', 'completed', 'refunded'], true)) {
$finalAmount = $amountFromWechat ?? (int) $localOrder['amount'];
Db::name('test_results')
->where('orderId', $localOrder['id'])
->update([
'isPaid' => $status === 'refunded' ? 0 : 1,
'paidAmount' => $finalAmount,
'paidAt' => $payTime ?: $now,
'updatedAt' => $now,
]);
// 仅当本地原状态不是已支付/已完成/已退款时,才认为是「首次确认支付」,用于统计画像
$paidSet = ['paid', 'completed', 'refunded'];
if ($status !== 'refunded' && !in_array($oldStatus, $paidSet, true)) {
$userId = (int) ($localOrder['userId'] ?? 0);
$enterpriseId = isset($localOrder['enterpriseId']) ? (int) $localOrder['enterpriseId'] : null;
if (($localOrder['productType'] ?? '') !== 'recharge' && $userId > 0 && $finalAmount > 0) {
UserProfileModel::recordPayment($userId, $enterpriseId, $finalAmount);
}
// 企业四项测试支付后,订单金额进入企业余额
$this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now);
if (($localOrder['productType'] ?? '') !== 'recharge') {
// 触发分销佣金结算
try {
\app\controller\api\Distribution::settleCommission((int) $localOrder['id']);
} catch (\Exception $e) {
// 佣金结算失败不影响主流程
}
}
}
}
}
// V2: 优先使用 total_fee退回用本地金额
$amountTotal = null;
if (isset($data['total_fee'])) {
$amountTotal = (int) $data['total_fee'];
} elseif ($localOrder) {
$amountTotal = (int) $localOrder['amount'];
}
// V2: 支付完成时间 time_end格式 yyyyMMddHHmmss
$payTimeTs = null;
if (isset($data['time_end'])) {
$dt = \DateTime::createFromFormat('YmdHis', $data['time_end']);
if ($dt) {
$payTimeTs = $dt->getTimestamp();
}
} elseif ($localOrder) {
$payTimeTs = $localOrder['payTime'] ?? null;
}
return success([
'orderId' => $orderId,
'wechatTransactionId'=> $data['transaction_id'] ?? null,
'tradeState' => $tradeState,
'tradeStateDesc' => $data['trade_state_desc'] ?? null,
'amount' => $amountTotal,
'status' => $status,
'payMethod' => 'wechat',
'payTime' => $payTimeTs,
'userId' => $localOrder['userId'] ?? null,
'enterpriseId' => $localOrder['enterpriseId']?? null,
'productType' => $localOrder['productType'] ?? null,
'createdAt' => $localOrder['createdAt'] ?? null,
]);
} catch (\Exception $e) {
return error('查询订单失败:' . $e->getMessage(), 500);
}
}
/**
* 解析当前请求中的用户信息(优先使用中间件注入的 user其次从 JWT 中解析)
*/
protected function resolveUser(): ?array
{
$user = $this->request->user ?? null;
if ($user) {
return is_array($user) ? $user : (array) $user;
}
$token = JwtService::getTokenFromRequest($this->request);
if (!$token) {
return null;
}
$payload = JwtService::verifyToken($token);
if (!$payload) {
return null;
}
return [
'source' => $payload['source'] ?? '',
'user_id'=> $payload['user_id'] ?? $payload['userId'] ?? null,
'userId' => $payload['user_id'] ?? $payload['userId'] ?? null,
];
}
/**
* 根据用户最近一次测试记录推断企业ID若存在
*/
protected function resolveEnterpriseId(int $userId): ?int
{
if ($userId <= 0) {
return null;
}
$row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
if (empty($row['enterpriseId'])) {
return null;
}
return (int) $row['enterpriseId'];
}
/**
* 企业四项测试支付成功后,将订单金额计入企业余额。
* 使用 finance_records + orderId 做幂等,避免 notify/query 重复入账。
*/
protected function creditEnterpriseBalanceForOrder(array $order, int $amountFen, int $now): void
{
$enterpriseId = (int) ($order['enterpriseId'] ?? 0);
$productType = (string) ($order['productType'] ?? '');
$orderDbId = (int) ($order['id'] ?? 0);
if ($enterpriseId <= 0 || $orderDbId <= 0 || $amountFen <= 0) {
return;
}
if (!in_array($productType, ['face', 'mbti', 'disc', 'pdp', 'resume', 'recharge'], true)) {
return;
}
$exists = Db::name('finance_records')
->where('enterpriseId', $enterpriseId)
->where('orderId', $orderDbId)
->where('type', 'recharge')
->find();
if ($exists) {
return;
}
Db::startTrans();
try {
$enterprise = Db::name('enterprises')
->where('id', $enterpriseId)
->field('id, name, balance')
->lock(true)
->find();
if (!$enterprise) {
Db::rollback();
return;
}
$beforeFen = (int) ($enterprise['balance'] ?? 0);
$afterFen = $beforeFen + $amountFen;
Db::name('enterprises')
->where('id', $enterpriseId)
->update([
'balance' => $afterFen,
'updatedAt' => $now,
]);
Db::name('finance_records')->insert([
'enterpriseId' => $enterpriseId,
'type' => 'recharge',
'amount' => $amountFen,
'balanceBefore' => $beforeFen,
'balanceAfter' => $afterFen,
'description' => $productType === 'recharge'
? '企业余额充值'
: ('企业测试收入:' . strtoupper($productType)),
'orderId' => $orderDbId,
'createdAt' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
}
}
/**
* 计算订单金额(分)和定价类型
* 定价配置中单价为「元」时,在此处乘以 100 转为分;前端传入的 requestAmountFen 已是分。
*
* @param string $productType 产品类型
* @param int $quantity 购买数量
* @param int $requestAmountFen 前端传入金额(分),部分类型作为兜底
* @param array|null $user 当前用户信息
* @param int|null $enterpriseId 推断出的企业ID
* @param string $deepProductId 深度服务套餐ID/产品Keydeep-pricing.categories.id/productKey
* @return array [amountFen, pricingType]
*/
protected function calculateAmount(
string $productType,
int $quantity,
int $requestAmountFen,
?array $user,
?int $enterpriseId,
string $deepProductId = ''
): array {
$pricingType = 'personal';
$pricingEnterpriseId = null; // 定价用企业 ID个人测试但有归属企业时也传入
if ($user && ($user['source'] ?? '') === 'wechat') {
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId > 0 && !empty($enterpriseId)) {
$pricingType = 'enterprise';
$pricingEnterpriseId = $enterpriseId;
} elseif ($userId > 0 && empty($enterpriseId)) {
// 个人测试:查 wechat_users.enterpriseId若有则使用企业专属个人定价
$userEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if (!empty($userEid)) {
$pricingEnterpriseId = (int) $userEid;
}
}
}
$quantity = $quantity > 0 ? $quantity : 1;
// 1测试类产品定价配置中为元转为分企业用户按企业ID取价
$testProductTypes = ['face', 'mbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis'];
if (in_array($productType, $testProductTypes, true)) {
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId ?? $enterpriseId);
$config = [];
if ($pricingConfig && !empty($pricingConfig->config)) {
$raw = $pricingConfig->config;
$config = is_array($raw) ? $raw : (array) $raw;
}
$keyMap = ['team_analysis' => 'teamAnalysis'];
$key = $keyMap[$productType] ?? $productType;
$unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0;
$amountFen = (int) round($unitPriceYuan * 100 * $quantity);
return [$amountFen, $pricingType];
}
// 2深度服务定价配置为元转为分
// 与 AppConfig::deepPricing 使用同一套配置:
// - 个人版type=deep_personalconfig.categories[].price
// - 企业版type=deep_enterpriseconfig.categories[].price
if (in_array($productType, ['deep_personal', 'deep_team'], true)) {
$type = $productType === 'deep_team' ? 'deep_enterprise' : 'deep_personal';
$configModel = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
$unitPriceYuan = 0.0;
if ($configModel && !empty($configModel->config)) {
$raw = $configModel->config;
$data = is_array($raw) ? $raw : (array) $raw;
$categories = isset($data['categories']) && is_array($data['categories']) ? $data['categories'] : [];
if (!empty($categories)) {
// 若传入 deepProductId则优先根据 id 或 productKey 精确匹配对应套餐
if ($deepProductId !== '') {
foreach ($categories as $cat) {
$cid = (string) ($cat['id'] ?? '');
$ckey = (string) ($cat['productKey'] ?? '');
if ($deepProductId === $cid || $deepProductId === $ckey) {
$unitPriceYuan = isset($cat['price']) ? (float) $cat['price'] : 0.0;
break;
}
}
}
// 未指定或未匹配到时,回退到第一项价格
if ($unitPriceYuan <= 0.0) {
$first = $categories[0];
$unitPriceYuan = isset($first['price']) ? (float) $first['price'] : 0.0;
}
}
}
// 兼容旧版 deep 配置:若 categories 为空,则回退到 type=deep 的 personal/team 字段
if ($unitPriceYuan <= 0.0) {
$deepModel = PricingConfigModel::getByTypeAndEnterprise('deep', null);
if ($deepModel && !empty($deepModel->config)) {
$rawDeep = $deepModel->config;
$deepConfig = is_array($rawDeep) ? $rawDeep : (array) $rawDeep;
$key = $productType === 'deep_team' ? 'team' : 'personal';
if (isset($deepConfig[$key])) {
$unitPriceYuan = (float) $deepConfig[$key];
}
}
}
$amountFen = (int) round($unitPriceYuan * 100 * $quantity);
return [$amountFen, $pricingType];
}
// 3充值 / 4VIP 等 / 5未知直接使用前端传入的金额
$amountFen = $requestAmountFen > 0 ? $requestAmountFen : 0;
return [$amountFen, $pricingType];
}
/**
* 调用微信 V2根据商户订单号查询订单JSAPI/小程序支付)
* 文档https://pay.weixin.qq.com/doc/v2/merchant/4011941128
*/
protected function queryWechatOrderByOutTradeNo(string $outTradeNo): array
{
$appid = env('WECHAT_APPID', '');
$mchid = env('MCH_ID', '');
$apiKey = env('API_KEY', '');
if (!$appid || !$mchid || !$apiKey) {
return [
'success' => false,
'message' => '微信支付 V2 查询配置缺失,请检查 WECHAT_APPID / MCH_ID / API_KEY',
];
}
$url = 'https://api.mch.weixin.qq.com/pay/orderquery';
$params = [
'appid' => $appid,
'mch_id' => $mchid,
'nonce_str' => md5(uniqid('orderquery_', true)),
'out_trade_no' => $outTradeNo,
];
$params['sign'] = $this->buildWechatSign($params, $apiKey);
$xml = $this->arrayToXml($params);
$response = $this->postXml($url, $xml, 10);
if ($response === false) {
return ['success' => false, 'message' => '调用微信 V2 查询接口失败'];
}
$data = $this->xmlToArray($response);
if (!is_array($data) || ($data['return_code'] ?? '') !== 'SUCCESS') {
$msg = $data['return_msg'] ?? '微信 V2 返回失败';
return ['success' => false, 'message' => $msg, 'raw' => $data];
}
if (($data['result_code'] ?? '') !== 'SUCCESS') {
$err = $data['err_code_des'] ?? $data['err_code'] ?? '微信 V2 查询失败';
return ['success' => false, 'message' => $err, 'raw' => $data];
}
// V2 返回字段trade_state / trade_state_desc / total_fee / transaction_id / time_end 等
return ['success' => true, 'data' => $data];
}
/**
* 将微信 trade_state 映射为本地订单状态
*/
protected function mapTradeStateToStatus(string $tradeState): string
{
$tradeState = strtoupper($tradeState);
switch ($tradeState) {
case 'SUCCESS':
return 'paid';
case 'REFUND':
return 'refunded';
case 'NOTPAY':
case 'USERPAYING':
return 'pending';
case 'CLOSED':
case 'REVOKED':
return 'cancelled';
case 'PAYERROR':
return 'failed';
default:
return 'pending';
}
}
/**
* 调用微信统一下单接口JSAPI
* 文档https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1
*/
protected function createWechatUnifiedOrder(
array $config,
string $orderNo,
int $amountFen,
string $body,
string $openId
): array {
$url = 'https://api.mch.weixin.qq.com/pay/unifiedorder';
$params = [
'appid' => $config['appid'],
'mch_id' => $config['mch_id'],
'nonce_str' => md5(uniqid('wxpay_unified_', true)),
'body' => mb_substr($body, 0, 40),
'out_trade_no' => $orderNo,
'total_fee' => $amountFen,
'spbill_create_ip' => $this->request ? $this->request->ip() : '127.0.0.1',
'notify_url' => $config['notify_url'],
'trade_type' => 'JSAPI',
'openid' => $openId,
];
$params['sign'] = $this->buildWechatSign($params, $config['api_key']);
$xml = $this->arrayToXml($params);
$response = $this->postXml($url, $xml, 30);
if ($response === false) {
return ['success' => false, 'message' => '请求微信支付接口失败'];
}
$data = $this->xmlToArray($response);
if (!is_array($data)) {
return ['success' => false, 'message' => '解析微信支付返回失败'];
}
if (($data['return_code'] ?? '') !== 'SUCCESS') {
return ['success' => false, 'message' => ($data['return_msg'] ?? '微信返回失败')];
}
if (($data['result_code'] ?? '') !== 'SUCCESS') {
$err = ($data['err_code_des'] ?? $data['err_code'] ?? '微信下单失败');
return ['success' => false, 'message' => $err];
}
return [
'success' => true,
'prepay_id' => $data['prepay_id'] ?? '',
'raw' => $data,
];
}
/**
* 构造微信支付签名MD5参数 ASCII 排序后拼接 &key=API_KEY
*/
protected function buildWechatSign(array $params, string $apiKey): string
{
ksort($params);
$buff = [];
foreach ($params as $k => $v) {
if ($v === '' || $v === null || $k === 'sign') {
continue;
}
$buff[] = $k . '=' . $v;
}
$string = implode('&', $buff) . '&key=' . $apiKey;
return strtoupper(md5($string));
}
protected function arrayToXml(array $data): string
{
$xml = '<xml>';
foreach ($data as $key => $val) {
if (is_numeric($val)) {
$xml .= "<{$key}>{$val}</{$key}>";
} else {
$xml .= "<{$key}><![CDATA[{$val}]]></{$key}>";
}
}
$xml .= '</xml>';
return $xml;
}
protected function xmlToArray(string $xml)
{
$data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($data === false) {
return null;
}
return json_decode(json_encode($data), true);
}
protected function postXml(string $url, string $xml, int $timeout = 30)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return false;
}
curl_close($ch);
return $response;
}
}

View File

@@ -0,0 +1,625 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use think\facade\Db;
use think\facade\Request;
/**
* 前端测试记录相关 API
*/
class Test extends BaseController
{
/**
* 获取当前微信用户的测试历史记录(用于小程序「测试历史」页)
* GET /api/test/history
*/
public function history()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$type = Request::param('type', 'all'); // all|mbti|disc|pdp|face
$scope = Request::param('scope', 'all'); // all|personal|enterprise
$page = max(1, (int) Request::param('page', 1));
$pageSize = (int) Request::param('pageSize', 0);
if ($pageSize <= 0) {
$pageSize = 500;
}
$pageSize = min(500, max(1, $pageSize));
$base = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users wu', 'tr.userId = wu.id')
->leftJoin('enterprises e_tr', 'tr.enterpriseId = e_tr.id')
->leftJoin('enterprises e_wu', 'wu.enterpriseId = e_wu.id')
->where('tr.userId', $userId)
->field('tr.*, e_tr.name as enterpriseName, wu.enterpriseId as bindEnterpriseId, e_wu.name as bindEnterpriseName')
->order('tr.createdAt', 'desc');
if ($type !== 'all') {
if (in_array($type, ['face', 'ai'], true)) {
$base->whereIn('tr.testType', ['face', 'ai']);
} else {
$base->where('tr.testType', $type);
}
}
if ($scope === 'personal') {
$base->whereNull('tr.enterpriseId');
} elseif ($scope === 'enterprise') {
$base->whereNotNull('tr.enterpriseId');
}
$total = (clone $base)->count('tr.id');
$rows = (clone $base)->page($page, $pageSize)->select()->toArray();
$list = [];
foreach ($rows as $row) {
$id = $row['id'] ?? 0;
$testType = $row['testType'] ?? '';
$createdAt = $row['createdAt'] ?? null;
$timeLabel = $createdAt ? date('Y-m-d H:i', $createdAt) : '未知时间';
// enterpriseId 语义:仅代表“该次测试是否属于企业测试/企业分享链接”
// 个人测试时 enterpriseId 可能为空,但用户依然可能在 wechat_users.enterpriseId 有归属企业
$enterpriseName = '';
if (isset($row['enterpriseId']) && (int) $row['enterpriseId'] > 0) {
$enterpriseName = trim((string) ($row['enterpriseName'] ?? ''));
} elseif (isset($row['bindEnterpriseId']) && (int) $row['bindEnterpriseId'] > 0) {
$enterpriseName = trim((string) ($row['bindEnterpriseName'] ?? ''));
}
$requiresPayment = (int) ($row['requiresPayment'] ?? 0);
$isPaid = (int) ($row['isPaid'] ?? 0);
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
$raw = $row['resultData'] ?? ($row['result'] ?? null);
$data = null;
if ($raw !== null && $raw !== '') {
$decoded = json_decode($raw, true);
$data = is_array($decoded) ? $decoded : $raw;
}
if ($requiresPayment && !$isPaid && $data !== null) {
$data = $this->filterResultToPartial($testType, $data);
}
$paymentFields = [
'requiresPayment' => $requiresPayment,
'isPaid' => $isPaid,
'orderId' => $orderId,
'enterpriseName' => $enterpriseName,
];
// 映射为小程序 history 页需要的结构
switch ($testType) {
case 'mbti':
$mbtiType = $data['mbtiType'] ?? $data['mbti'] ?? '未知';
$list[] = array_merge([
'id' => $id,
'type' => 'mbti',
'key' => 'mbti_' . $id,
'emoji' => '🧠',
'typeName' => 'MBTI性格测试',
'resultText'=> $mbtiType,
'testTime' => $timeLabel,
'data' => $data,
], $paymentFields);
break;
case 'disc':
$discType = $data['dominantType'] ?? $data['disc'] ?? '未知';
$list[] = array_merge([
'id' => $id,
'type' => 'disc',
'key' => 'disc_' . $id,
'emoji' => '📊',
'typeName' => 'DISC性格测试',
'resultText'=> $discType . '型',
'testTime' => $timeLabel,
'data' => $data,
], $paymentFields);
break;
case 'pdp':
$primary = $data['description']['type'] ?? $data['pdp'] ?? '未知';
$emoji = $data['description']['emoji'] ?? '🦁';
$list[] = array_merge([
'id' => $id,
'type' => 'pdp',
'key' => 'pdp_' . $id,
'emoji' => $emoji,
'typeName' => 'PDP行为偏好测试',
'resultText'=> $primary,
'testTime' => $timeLabel,
'data' => $data,
], $paymentFields);
break;
case 'face':
case 'ai':
$mbtiShort = '';
if (is_array($data)) {
if (isset($data['mbti']['type'])) {
$mbtiShort = $data['mbti']['type'];
} elseif (isset($data['mbti'])) {
$mbtiShort = is_array($data['mbti']) ? ($data['mbti']['type'] ?? '') : $data['mbti'];
}
}
$list[] = array_merge([
'id' => $id,
'type' => 'ai',
'key' => 'ai_' . $id,
'emoji' => '👁️',
'typeName' => '面相分析',
'resultText'=> $mbtiShort ?: '未知',
'testTime' => $timeLabel,
'data' => $data,
], $paymentFields);
break;
case 'resume':
$summary = '';
if (is_array($data) && !empty($data['content'])) {
$summary = mb_substr(strip_tags((string) $data['content']), 0, 20, 'UTF-8');
if (mb_strlen((string) $data['content'], 'UTF-8') > 20) {
$summary .= '...';
}
}
$list[] = array_merge([
'id' => $id,
'type' => 'resume',
'key' => 'resume_' . $id,
'emoji' => '📋',
'typeName' => '简历综合分析',
'resultText'=> $summary ?: '简历综合分析',
'testTime' => $timeLabel,
'data' => $data,
], $paymentFields);
break;
default:
break;
}
}
return success([
'list' => $list,
'total' => (int) $total,
'page' => $page,
'pageSize' => $pageSize,
'hasMore' => ($page * $pageSize) < $total,
]);
}
/**
* 获取每种测试类型最新一条记录(用于小程序「我的」页)
* GET /api/test/recent
* 返回:{ records: { mbti, disc, pdp, ai }, totalCount }
*/
public function recent()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$scope = Request::param('scope', 'all'); // all|personal|enterprise
$records = [];
// 优化:一次性查询所有需要的最新记录,减少数据库连接和查询次数
$query = Db::name('test_results')
->where('userId', $userId);
if ($scope === 'personal') {
$query->whereNull('enterpriseId');
} elseif ($scope === 'enterprise') {
$query->whereNotNull('enterpriseId');
}
// 使用子查询或 Union 可能更复杂,这里采用分组取最新的优化思路
// 但 ThinkPHP 中最简单有效的优化是先查出所有类型,再处理
$allRows = $query->order('createdAt', 'desc')->select()->toArray();
$foundTypes = [];
$totalCount = count($allRows);
foreach ($allRows as $row) {
$type = $row['testType'];
// face 和 ai 视为同一种类型
$effectiveType = in_array($type, ['face', 'ai']) ? 'ai' : $type;
if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'disc', 'pdp', 'ai'])) {
$records[$effectiveType] = $this->_formatRecentRow($row);
$foundTypes[$effectiveType] = true;
}
// 如果四个类型都找到了,且不需要总数(或者已经有了),可以提前结束
if (count($foundTypes) >= 4) {
// 如果不需要精确的总数统计,这里可以 break
// 但为了保持接口兼容性,我们继续循环或者已经拿到了 count
}
}
return success([
'records' => $records,
'totalCount' => (int) $totalCount,
]);
}
/**
* 格式化单条记录为 recent 接口返回结构
*/
protected function _formatRecentRow(array $row): array
{
$testType = $row['testType'] ?? '';
$createdAt = $row['createdAt'] ?? null;
$raw = $row['resultData'] ?? ($row['result'] ?? null);
$data = [];
if ($raw !== null && $raw !== '') {
$decoded = json_decode($raw, true);
$data = is_array($decoded) ? $decoded : [];
}
$resultText = '';
$emoji = '';
$typeName = '';
switch ($testType) {
case 'mbti':
$resultText = $data['mbtiType'] ?? $data['mbti'] ?? '未知';
$emoji = '🧠';
$typeName = 'MBTI性格';
break;
case 'disc':
$dominantType = $data['dominantType'] ?? $data['disc'] ?? '未知';
$resultText = $dominantType . '型';
$emoji = '📊';
$typeName = 'DISC测评';
break;
case 'pdp':
$resultText = $data['description']['type'] ?? $data['pdp'] ?? '未知';
$emoji = $data['description']['emoji'] ?? '🦁';
$typeName = 'PDP行为';
break;
case 'face':
case 'ai':
$mbtiShort = '';
if (isset($data['mbti']['type'])) {
$mbtiShort = $data['mbti']['type'];
} elseif (isset($data['mbti']) && !is_array($data['mbti'])) {
$mbtiShort = (string) $data['mbti'];
}
$resultText = $mbtiShort ?: '面相分析';
$emoji = '👁️';
$typeName = '面相分析';
break;
}
return [
'id' => (int) $row['id'],
'testType' => ($testType === 'face') ? 'ai' : $testType,
'emoji' => $emoji,
'typeName' => $typeName,
'resultText' => $resultText,
'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '',
'isPaid' => (int) ($row['isPaid'] ?? 0),
'requiresPayment' => (int) ($row['requiresPayment'] ?? 0),
];
}
/**
* 单条测试结果详情按ID读取数据库
* GET /api/test/detail?id=123
*/
public function detail()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$id = (int) Request::param('id', 0);
if ($id <= 0) {
return error('缺少ID', 400);
}
$row = Db::name('test_results')
->where('id', $id)
->where('userId', $userId)
->find();
if (!$row) {
return error('记录不存在', 404);
}
$raw = $row['resultData'] ?? ($row['result'] ?? null);
$data = null;
if ($raw !== null && $raw !== '') {
$decoded = json_decode($raw, true);
$data = is_array($decoded) ? $decoded : $raw;
}
$requiresPayment = (int) ($row['requiresPayment'] ?? 0);
$isPaid = (int) ($row['isPaid'] ?? 0);
$paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$testType = $row['testType'] ?? '';
// 仅当需要付款且未付款且金额>0 时才脱敏系统设置需付款但金额为0 则直接可查看
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
if ($needPaymentToUnlock && $data !== null) {
$data = $this->filterResultToPartial($testType, $data);
}
return success([
'id' => $row['id'],
'testType' => $testType,
'createdAt' => $row['createdAt'],
'data' => $data,
'requiresPayment' => $requiresPayment,
'isPaid' => $isPaid,
'paidAmount' => $paidAmount,
'amountYuan' => $paidAmount > 0 ? round($paidAmount / 100, 2) : 0,
'needPaymentToUnlock'=> $needPaymentToUnlock,
'orderId' => isset($row['orderId']) ? (int) $row['orderId'] : null,
'paidAt' => isset($row['paidAt']) ? (int) $row['paidAt'] : null,
]);
}
/**
* 提交测试结果MBTI/DISC/PDP 等问卷)
* POST /api/test/submit
* body: { testType, answers, result, testDuration, timestamp }
* - userId 从 token 中解析,保证与当前登录微信用户一致
* - 结果统一写入 test_results 表,前端历史/详情接口复用现有逻辑
*/
public function submit()
{
$user = $this->request->user ?? null;
if (!$user || ($user['source'] ?? '') !== 'wechat') {
return error('未登录', 401);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('未登录', 401);
}
$input = Request::post();
$testType = $input['testType'] ?? '';
$result = $input['result'] ?? null;
$answers = $input['answers'] ?? [];
$duration = isset($input['testDuration']) ? (int) $input['testDuration'] : 0;
// 企业分享链接会传 enterpriseId个人分享不传稍后从 wechat_users 回落
$enterpriseId = isset($input['enterpriseId']) ? (int) $input['enterpriseId'] : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
$enterpriseId = null;
}
// 标记来源:只有"请求体明确传入"时才更新 wechat_users.enterpriseId
$enterpriseFromRequest = $enterpriseId !== null;
if (!$testType || $result === null) {
return error('缺少必要参数', 400);
}
// 仅允许已知类型,避免脏数据
if (!in_array($testType, ['mbti', 'disc', 'pdp', 'face', 'ai'], true)) {
return error('不支持的测试类型', 400);
}
// 结果结构中附带 answers / testDuration方便后续分析同时保持历史结构兼容
if (is_array($result)) {
if (!isset($result['answers']) && is_array($answers)) {
$result['answers'] = $answers;
}
if (!isset($result['testDuration']) && $duration > 0) {
$result['testDuration'] = $duration;
}
}
try {
$now = time();
// 三个变量各司其职:
// $enterpriseId —— 仅企业测试(请求体传入)才非 null决定走 admin_enterprise 定价
// $pricingEnterpriseId —— 个人测试时从 wechat_users 取,走 admin_personal + eid 定价
// $writeEnterpriseId —— 写入 test_results.enterpriseId企业测试 or 绑定企业都记录)
$pricingEnterpriseId = $enterpriseId;
$writeEnterpriseId = $enterpriseId;
if ($enterpriseId === null) {
$boundEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if (!empty($boundEid)) {
$pricingEnterpriseId = (int) $boundEid; // admin_personal + eid
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
}
}
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
$id = Db::name('test_results')->insertGetId([
'userId' => $userId,
'enterpriseId' => $writeEnterpriseId,
'testScope' => $enterpriseId !== null ? 'enterprise' : 'personal',
'testType' => $testType,
'resultData' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
'score' => null,
'orderId' => null,
'requiresPayment' => $requiresPayment,
'isPaid' => 0,
'paidAmount' => $standardAmountFen > 0 ? $standardAmountFen : null,
'paidAt' => null,
'createdAt' => $now,
'updatedAt' => $now,
]);
if ($id > 0) {
UserProfileModel::recordTest($userId, $testType, $id, $writeEnterpriseId, $now);
// 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系
if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
}
// 测试完成佣金结算(无需付款,异步不影响主流程)
try {
\app\controller\api\Distribution::settleTestCommission($id, $userId, $testType);
} catch (\Throwable $e) {
// 佣金结算失败不阻断测试保存
}
}
} catch (\Throwable $e) {
return error('保存测试结果失败', 500);
}
return success(null, '提交成功');
}
/**
* 根据定价配置返回该测试类型是否需要付费才显示完整报告
*
* @param string $testType face|mbti|disc|pdp
* @param int|null $enterpriseId 本次测试的企业 IDNULL=个人测试)
* @param int|null $pricingEnterpriseId 定价用企业 ID个人测试时也可能有归属企业
* @return int 0 或 1
*/
protected function getRequiresPaymentByTestType(string $testType, ?int $enterpriseId = null, ?int $pricingEnterpriseId = null): int
{
$pricingConfig = $this->resolvePricingConfig($enterpriseId, $pricingEnterpriseId);
if (!$pricingConfig || empty($pricingConfig->config)) {
return 0;
}
$pricing = is_array($pricingConfig->config) ? $pricingConfig->config : (array) $pricingConfig->config;
$key = $testType === 'team_analysis' ? 'teamAnalysis' : $testType;
return isset($pricing[$key]) && (float) $pricing[$key] > 0 ? 1 : 0;
}
/**
* 获取某测试类型当前定价金额(分),用于写入 test_results.paidAmount
*
* @param int|null $enterpriseId 本次测试企业 ID
* @param int|null $pricingEnterpriseId 定价用企业 ID
*/
protected function getStandardAmountFenByTestType(string $testType, ?int $enterpriseId = null, ?int $pricingEnterpriseId = null): int
{
$pricingConfig = $this->resolvePricingConfig($enterpriseId, $pricingEnterpriseId);
if (!$pricingConfig || empty($pricingConfig->config)) {
return 0;
}
$pricing = is_array($pricingConfig->config) ? $pricingConfig->config : (array) $pricingConfig->config;
$key = $testType === 'team_analysis' ? 'teamAnalysis' : $testType;
if (!isset($pricing[$key])) return 0;
$yuan = (float) $pricing[$key];
return $yuan > 0 ? (int) round($yuan * 100) : 0;
}
/**
* 解析定价配置:
* - 企业测试enterpriseId 非空)→ 企业版定价admin_enterprise 优先)
* - 个人测试但有归属企业pricingEnterpriseId 非空)→ 企业专属个人定价admin_personal 优先)
* - 纯个人测试 → 全局个人定价
*/
private function resolvePricingConfig(?int $enterpriseId, ?int $pricingEnterpriseId): ?PricingConfigModel
{
if ($enterpriseId !== null && $enterpriseId > 0) {
return PricingConfigModel::getByTypeAndEnterprise('enterprise', $enterpriseId);
}
if ($pricingEnterpriseId !== null && $pricingEnterpriseId > 0) {
return PricingConfigModel::getByTypeAndEnterprise('personal', $pricingEnterpriseId);
}
return PricingConfigModel::getByTypeAndEnterprise('personal', null);
}
/**
* 未付费时只返回部分数据(完整数据需付费解锁)
* @param string $testType
* @param array|null $data 原始 resultData
* @return array|null 脱敏后的数据
*/
protected function filterResultToPartial(string $testType, $data)
{
if (!is_array($data)) {
return $data;
}
if ($testType === 'face' || $testType === 'ai') {
$out = $data;
$out['faceAnalysis'] = null;
$out['boneAnalysis'] = null;
return $out;
}
if ($testType === 'mbti') {
return [
'mbtiType' => $data['mbtiType'] ?? $data['mbti'] ?? '',
'locked' => true,
];
}
if ($testType === 'disc') {
return [
'dominantType' => $data['dominantType'] ?? $data['disc'] ?? '',
'locked' => true,
];
}
if ($testType === 'pdp') {
return [
'description' => isset($data['description']) ? ['type' => $data['description']['type'] ?? '', 'emoji' => $data['description']['emoji'] ?? ''] : [],
'locked' => true,
];
}
return $data;
}
/**
* 获取当前用户最近的 MBTI / DISC / PDP 测试记录(暂不使用人脸/AI 结果),供简历综合分析使用
* @param int $userId 微信用户 ID
* @param int|null $enterpriseId 当前企业ID仅返回该企业下的记录为空则不按企业过滤
* @return array ['face' => row|null, 'mbti' => row|null, 'disc' => row|null, 'pdp' => row|null]row 含 id, testType, resultData, createdAt
*/
public static function getLatestResultsForResume(int $userId, ?int $enterpriseId = null): array
{
if ($userId <= 0) {
return ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null];
}
$out = ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null];
$base = Db::name('test_results')->where('userId', $userId);
if ($enterpriseId !== null && $enterpriseId > 0) {
$base = $base->where('enterpriseId', (int) $enterpriseId);
}
// face/ai 暂不参与简历分析,保持为 null避免写入上下文
// mbti
$out['mbti'] = (clone $base)
->where('testType', 'mbti')
->field('id, testType, resultData, createdAt')
->order('createdAt', 'desc')
->find();
// pdp
$out['pdp'] = (clone $base)
->where('testType', 'pdp')
->field('id, testType, resultData, createdAt')
->order('createdAt', 'desc')
->find();
// disc
$out['disc'] = (clone $base)
->where('testType', 'disc')
->field('id, testType, resultData, createdAt')
->order('createdAt', 'desc')
->find();
return $out;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace app\controller\api;
use app\controller\admin\Upload as AdminUpload;
/**
* 小程序用户上传(头像等),复用管理端上传逻辑,需 JWT 认证
*/
class Upload extends AdminUpload
{
}

View File

@@ -0,0 +1,101 @@
<?php
namespace app\controller\api;
use app\BaseController;
use app\common\service\WechatTransferService;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
* 微信商家转账结果回调
*
* 回调地址示例:/api/wechat/transfer/notify
*/
class WechatTransferNotify extends BaseController
{
public function notify()
{
$body = file_get_contents('php://input');
$headers = [
'wechatpay-signature' => Request::header('wechatpay-signature'),
'wechatpay-timestamp' => Request::header('wechatpay-timestamp'),
'wechatpay-nonce' => Request::header('wechatpay-nonce'),
'wechatpay-serial' => Request::header('wechatpay-serial'),
];
Log::info('[WechatTransferNotify] raw body: ' . $body);
// 这里只做最小实现:直接解密 resource按 out_bill_no 匹配提现记录
try {
$data = json_decode($body, true) ?: [];
if (empty($data['resource'])) {
throw new \Exception('missing resource');
}
$service = new WechatTransferService();
$resource = $data['resource'];
// 复用文档中的解密逻辑
$decrypted = $service->decryptCallbackResource($resource);
$outBillNo = $decrypted['out_bill_no'] ?? '';
$state = $decrypted['state'] ?? '';
$transferBillNo = $decrypted['transfer_bill_no'] ?? null;
if (!preg_match('/^TX(\d+)$/', (string) $outBillNo, $m)) {
throw new \Exception('invalid out_bill_no: ' . $outBillNo);
}
$withdrawId = (int) $m[1];
$now = time();
if ($state === 'SUCCESS') {
// 微信转账成功:仅允许从「待收款 status=2」更新为「已收款 status=3」
Db::name('distribution_withdrawals')
->where('id', $withdrawId)
->where('status', 2)
->update([
'status' => 3,
'wechat_pay_state' => $state,
'transfer_bill_no' => $transferBillNo,
'transferAt' => $now,
'updatedAt' => $now,
]);
} elseif ($state === 'FAIL') {
// 转账失败:退回余额
$record = Db::name('distribution_withdrawals')->where('id', $withdrawId)->find();
if ($record && (int)$record['status'] !== 3) {
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', $withdrawId)
->update([
// 1=已驳回
'status' => 1,
'auditNote' => '微信转账失败自动退回',
'wechat_pay_state' => $state,
'transfer_bill_no' => $transferBillNo,
'updatedAt' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
Log::error('[WechatTransferNotify] fail rollback error: ' . $e->getMessage());
}
}
}
return json(['code' => 'SUCCESS']);
} catch (\Throwable $e) {
Log::error('[WechatTransferNotify] error: ' . $e->getMessage());
return json(['code' => 'FAIL', 'message' => '处理失败'])->code(500);
}
}
}

View File

@@ -0,0 +1,482 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\AiProvider as AiProviderModel;
use think\facade\Request;
use think\facade\Db;
/**
* AI服务商配置管理控制器超管专用
*/
class AiConfig extends BaseController
{
/**
* 获取所有AI服务商配置
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 列表只返回“显示”的配置visible=1 或未设);隐藏的由数据库 visible=0 控制,不在此列表展示
$providers = AiProviderModel::order('id', 'asc')
->whereRaw('(visible IS NULL OR visible = 1)')
->select()
->toArray();
// 处理返回数据
$result = [];
foreach ($providers as $provider) {
$result[] = [
'id' => $provider['providerId'],
'name' => $provider['name'],
'enabled' => $provider['enabled'] == 1,
'visible' => isset($provider['visible']) ? ($provider['visible'] == 1) : true,
'apiKey' => $provider['apiKey'] ?? '', // 脱敏后的密钥
'apiEndpoint' => $provider['apiEndpoint'] ?? '',
'model' => $provider['model'] ?? '',
'organizationId' => $provider['organizationId'] ?? '',
'maxTokens' => $provider['maxTokens'] ?? 4096,
'balanceAlertEnabled' => $provider['balanceAlertEnabled'] == 1,
'balanceAlertThreshold' => floatval($provider['balanceAlertThreshold'] ?? 10),
'notes' => $provider['notes'] ?? '',
'docUrl' => $provider['docUrl'] ?? '',
'isFree' => $provider['isFree'] == 1,
'supportsBalance' => $provider['supportsBalance'] == 1,
'_hasKey' => !empty($provider['apiKey']),
'lastBalance' => $provider['lastBalance'] ? floatval($provider['lastBalance']) : null,
'lastBalanceCurrency' => $provider['lastBalanceCurrency'] ?? null,
'lastBalanceCheckedAt' => $provider['lastBalanceCheckedAt'] ? date('Y-m-d H:i:s', $provider['lastBalanceCheckedAt']) : null,
'extraConfig' => is_array($provider['extraConfig'] ?? null) ? $provider['extraConfig'] : (isset($provider['extraConfig']) && is_string($provider['extraConfig']) ? (json_decode($provider['extraConfig'], true) ?: []) : [])
];
}
return success($result);
}
/**
* 更新AI服务商配置
* @return \think\response\Json
*/
public function update()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$providerId = Request::param('providerId', '');
$data = Request::only([
'name', 'enabled', 'visible', 'apiKey', 'apiEndpoint', 'model', 'organizationId',
'maxTokens', 'balanceAlertEnabled', 'balanceAlertThreshold', 'notes',
'extraConfig'
]);
if (empty($providerId)) {
return error('服务商ID不能为空', 400);
}
// 查找服务商配置
$provider = AiProviderModel::where('providerId', $providerId)->find();
if (!$provider) {
return error('服务商配置不存在', 404);
}
// 处理 enabled 字段(前端传的是布尔值)
if (isset($data['enabled'])) {
$data['enabled'] = $data['enabled'] ? 1 : 0;
}
// 处理 balanceAlertEnabled 字段
if (isset($data['balanceAlertEnabled'])) {
$data['balanceAlertEnabled'] = $data['balanceAlertEnabled'] ? 1 : 0;
}
// 处理 visible 字段(显示/隐藏,数据库直接控制)
if (isset($data['visible'])) {
$data['visible'] = $data['visible'] ? 1 : 0;
}
// extraConfig 可为数组或 JSON 字符串,模型 type=json 会处理
if (isset($data['extraConfig']) && is_string($data['extraConfig'])) {
$decoded = json_decode($data['extraConfig'], true);
$data['extraConfig'] = is_array($decoded) ? $decoded : [];
}
// 如果API Key为空或包含脱敏标记****),不更新(保持原值)
if (isset($data['apiKey'])) {
if (empty($data['apiKey']) || strpos($data['apiKey'], '****') !== false) {
unset($data['apiKey']);
}
}
// 更新配置
$provider->save($data);
// 返回更新后的数据(脱敏)
$result = [
'id' => $provider->providerId,
'name' => $provider->name,
'enabled' => $provider->enabled == 1,
'visible' => isset($provider->visible) ? ($provider->visible == 1) : true,
'apiKey' => $provider->apiKey ?? '',
'apiEndpoint' => $provider->apiEndpoint ?? '',
'model' => $provider->model ?? '',
'organizationId' => $provider->organizationId ?? '',
'maxTokens' => $provider->maxTokens ?? 4096,
'balanceAlertEnabled' => $provider->balanceAlertEnabled == 1,
'balanceAlertThreshold' => floatval($provider->balanceAlertThreshold ?? 10),
'notes' => $provider->notes ?? '',
'isFree' => $provider->isFree == 1,
'supportsBalance' => $provider->supportsBalance == 1,
'_hasKey' => !empty($provider->apiKey),
'extraConfig' => $provider->extraConfig ?? []
];
return success($result, '保存成功');
}
/**
* 批量更新AI服务商配置
* @return \think\response\Json
*/
public function batchUpdate()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$providers = Request::param('providers', []);
if (empty($providers) || !is_array($providers)) {
return error('配置数据不能为空', 400);
}
$successCount = 0;
$errors = [];
Db::startTrans();
try {
foreach ($providers as $providerData) {
$providerId = $providerData['id'] ?? $providerData['providerId'] ?? '';
if (empty($providerId)) {
$errors[] = '服务商ID不能为空';
continue;
}
$provider = AiProviderModel::where('providerId', $providerId)->find();
if (!$provider) {
$errors[] = "服务商 {$providerId} 不存在";
continue;
}
// 准备更新数据
$updateData = [];
if (isset($providerData['enabled'])) {
$updateData['enabled'] = $providerData['enabled'] ? 1 : 0;
}
if (isset($providerData['apiKey']) && !empty($providerData['apiKey'])) {
$updateData['apiKey'] = $providerData['apiKey'];
}
if (isset($providerData['apiEndpoint'])) {
$updateData['apiEndpoint'] = $providerData['apiEndpoint'];
}
if (isset($providerData['model'])) {
$updateData['model'] = $providerData['model'];
}
if (isset($providerData['organizationId'])) {
$updateData['organizationId'] = $providerData['organizationId'];
}
if (isset($providerData['maxTokens'])) {
$updateData['maxTokens'] = intval($providerData['maxTokens']);
}
if (isset($providerData['balanceAlertEnabled'])) {
$updateData['balanceAlertEnabled'] = $providerData['balanceAlertEnabled'] ? 1 : 0;
}
if (isset($providerData['balanceAlertThreshold'])) {
$updateData['balanceAlertThreshold'] = floatval($providerData['balanceAlertThreshold']);
}
if (isset($providerData['notes'])) {
$updateData['notes'] = $providerData['notes'];
}
if (isset($providerData['visible'])) {
$updateData['visible'] = $providerData['visible'] ? 1 : 0;
}
if (isset($providerData['extraConfig'])) {
$updateData['extraConfig'] = is_array($providerData['extraConfig'])
? $providerData['extraConfig']
: (is_string($providerData['extraConfig']) ? json_decode($providerData['extraConfig'], true) : []);
if (!is_array($updateData['extraConfig'])) {
$updateData['extraConfig'] = [];
}
}
$provider->save($updateData);
$successCount++;
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
return error('批量保存失败:' . $e->getMessage(), 500);
}
if (!empty($errors)) {
return error('部分配置保存失败:' . implode('', $errors), 400);
}
return success(null, "成功保存 {$successCount} 个配置");
}
/**
* 查询余额(单个服务商)
* @return \think\response\Json
*/
public function queryBalance()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$providerId = Request::param('providerId', '');
if (empty($providerId)) {
return error('服务商ID不能为空', 400);
}
$provider = AiProviderModel::where('providerId', $providerId)->find();
if (!$provider) {
return error('服务商配置不存在', 404);
}
if (empty($provider->apiKey)) {
return error('请先配置 API Key', 400);
}
if (!$provider->supportsBalance) {
return error('该服务商暂不支持余额查询', 400);
}
// 调用余额查询服务
$balanceResult = $this->queryProviderBalance($provider);
// 更新最后查询的余额
if ($balanceResult['status'] === 'success' && isset($balanceResult['balance'])) {
$provider->lastBalance = $balanceResult['balance'];
$provider->lastBalanceCurrency = $balanceResult['currency'] ?? 'CNY';
$provider->lastBalanceCheckedAt = time();
$provider->save();
}
return success($balanceResult);
}
/**
* 批量查询余额
* @return \think\response\Json
*/
public function queryAllBalances()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$providerIds = Request::param('providerIds', []);
// 如果没有指定,查询所有已启用且已配置密钥的服务商
if (empty($providerIds)) {
$providers = AiProviderModel::where('enabled', 1)
->where('apiKey', '<>', '')
->where('apiKey', '<>', null)
->select();
} else {
$providers = AiProviderModel::where('providerId', 'in', $providerIds)
->where('apiKey', '<>', '')
->where('apiKey', '<>', null)
->select();
}
$results = [];
foreach ($providers as $provider) {
if (!$provider->supportsBalance) {
continue;
}
$balanceResult = $this->queryProviderBalance($provider);
// 更新最后查询的余额
if ($balanceResult['status'] === 'success' && isset($balanceResult['balance'])) {
$provider->lastBalance = $balanceResult['balance'];
$provider->lastBalanceCurrency = $balanceResult['currency'] ?? 'CNY';
$provider->lastBalanceCheckedAt = time();
$provider->save();
}
$results[] = $balanceResult;
}
return success($results);
}
/**
* 查询服务商余额(内部方法)
* @param AiProviderModel $provider
* @return array
*/
private function queryProviderBalance($provider)
{
// 这里需要实现各服务商的余额查询逻辑
// 由于各服务商的API不同这里提供一个基础框架
$providerId = $provider->providerId;
$apiKey = $provider->getRawApiKey(); // 获取原始密钥用于API调用
// TODO: 实现各服务商的余额查询API调用
// 目前返回模拟数据实际需要调用各服务商的API
try {
switch ($providerId) {
case 'openai':
// OpenAI余额查询逻辑
return $this->queryOpenAIBalance($apiKey);
case 'deepseek':
// DeepSeek余额查询逻辑
return $this->queryDeepSeekBalance($apiKey);
case 'moonshot':
// Moonshot余额查询逻辑
return $this->queryMoonshotBalance($apiKey);
default:
return [
'providerId' => $providerId,
'providerName' => $provider->name,
'status' => 'unsupported',
'message' => '该服务商暂不支持余额查询',
'balance' => null,
'currency' => null,
'checkedAt' => date('Y-m-d H:i:s')
];
}
} catch (\Exception $e) {
return [
'providerId' => $providerId,
'providerName' => $provider->name,
'status' => 'error',
'message' => '查询失败:' . $e->getMessage(),
'balance' => null,
'currency' => null,
'checkedAt' => date('Y-m-d H:i:s')
];
}
}
/**
* 查询OpenAI余额
* @param string $apiKey
* @return array
*/
private function queryOpenAIBalance($apiKey)
{
// TODO: 实现OpenAI余额查询
// OpenAI没有直接的余额查询API需要通过使用情况估算
return [
'providerId' => 'openai',
'providerName' => 'OpenAI (GPT)',
'status' => 'success',
'message' => '余额查询成功:$100.00',
'balance' => 100.00,
'currency' => 'USD',
'checkedAt' => date('Y-m-d H:i:s')
];
}
/**
* 查询DeepSeek余额
* @param string $apiKey
* @return array
*/
private function queryDeepSeekBalance($apiKey)
{
// TODO: 实现DeepSeek余额查询
try {
// 示例调用DeepSeek API查询余额
// $response = file_get_contents('https://api.deepseek.com/v1/balance', [
// 'http' => [
// 'method' => 'GET',
// 'header' => "Authorization: Bearer {$apiKey}\r\n"
// ]
// ]);
return [
'providerId' => 'deepseek',
'providerName' => 'DeepSeek',
'status' => 'success',
'message' => '余额查询成功¥500.00',
'balance' => 500.00,
'currency' => 'CNY',
'checkedAt' => date('Y-m-d H:i:s')
];
} catch (\Exception $e) {
return [
'providerId' => 'deepseek',
'providerName' => 'DeepSeek',
'status' => 'error',
'message' => '查询失败:' . $e->getMessage(),
'balance' => null,
'currency' => null,
'checkedAt' => date('Y-m-d H:i:s')
];
}
}
/**
* 查询Moonshot余额
* @param string $apiKey
* @return array
*/
private function queryMoonshotBalance($apiKey)
{
// TODO: 实现Moonshot余额查询
try {
// 示例调用Moonshot API查询余额
return [
'providerId' => 'moonshot',
'providerName' => 'Moonshot (Kimi)',
'status' => 'success',
'message' => '余额查询成功¥200.00',
'balance' => 200.00,
'currency' => 'CNY',
'checkedAt' => date('Y-m-d H:i:s')
];
} catch (\Exception $e) {
return [
'providerId' => 'moonshot',
'providerName' => 'Moonshot (Kimi)',
'status' => 'error',
'message' => '查询失败:' . $e->getMessage(),
'balance' => null,
'currency' => null,
'checkedAt' => date('Y-m-d H:i:s')
];
}
}
}

View File

@@ -0,0 +1,525 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 超管 - 测试用户(小程序用户)管理
* 数据来源wechat_users测试记录表物理表名一般为 mbti_test_results逻辑使用 Db::name('test_results')
*/
class AppUser extends BaseController
{
/**
* 概览用户统计、卡片、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
]
];
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 '';
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\JwtService;
use think\facade\Request;
use think\facade\Db;
/**
* 超级管理员认证控制器
*/
class Auth extends BaseController
{
/**
* 超级管理员登录
* @return \think\response\Json
*/
public function login()
{
$username = Request::param('username', '');
$password = Request::param('password', '');
if (empty($username) || empty($password)) {
return error('用户名和密码不能为空', 400);
}
// 查找用户(只允许超级管理员登录)
$user = Db::name('users')
->where('username', $username)
->where('role', 'superadmin')
->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 = [
'userId' => $user['id'],
'username' => $user['username'],
'role' => $user['role']
];
$token = JwtService::generateToken($payload);
unset($user['password']);
return success([
'token' => $token,
'expiresIn' => config('jwt.expire'),
'user' => $user
], '登录成功');
}
/**
* 获取当前登录超级管理员信息(需要认证)
* @return \think\response\Json
*/
public function me()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
// 验证是否为超级管理员
if ($user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$userModel = Db::name('users')->where('id', $user['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['userId'])) {
JwtService::deleteToken($user['userId']);
}
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,
'expiresIn' => config('jwt.expire')
], '刷新成功');
}
}

View File

@@ -0,0 +1,750 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\BackupRecord as BackupRecordModel;
use think\facade\Request;
use think\facade\Db;
use think\facade\Config;
use think\facade\Log;
/**
* 数据库管理控制器(超管专用)
*/
class Database extends BaseController
{
/**
* 获取数据库信息
* @return \think\response\Json
*/
public function info()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
// 获取数据库配置
$config = Config::get('database.connections.mysql');
$database = $config['database'] ?? '';
// 获取数据库大小
$dbSize = $this->getDatabaseSize($database);
// 获取表数量
$tableCount = $this->getTableCount($database);
// 获取连接状态
try {
Db::query('SELECT 1');
$connected = true;
} catch (\Exception $e) {
$connected = false;
}
return success([
'databaseType' => 'MySQL',
'databaseName' => $database,
'connected' => $connected,
'databaseSize' => $dbSize,
'tableCount' => $tableCount
]);
} catch (\Exception $e) {
return error('获取数据库信息失败:' . $e->getMessage(), 500);
}
}
/**
* 获取表列表
* @return \think\response\Json
*/
public function tables()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$config = Config::get('database.connections.mysql');
$database = $config['database'] ?? '';
$prefix = $config['prefix'] ?? 'mbti_';
// 获取所有表
$tables = Db::query("SHOW TABLE STATUS FROM `{$database}`");
$result = [];
foreach ($tables as $table) {
$tableName = $table['Name'];
// 只显示带前缀的表(或者所有表)
if (empty($prefix) || strpos($tableName, $prefix) === 0) {
// 获取记录数
$rowCount = Db::query("SELECT COUNT(*) as count FROM `{$tableName}`")[0]['count'] ?? 0;
// 获取索引数
$indexes = Db::query("SHOW INDEX FROM `{$tableName}`");
$indexCount = count(array_unique(array_column($indexes, 'Key_name')));
$result[] = [
'name' => $tableName,
'docCount' => intval($rowCount),
'size' => intval($table['Data_length'] + $table['Index_length']),
'indexCount' => $indexCount,
'engine' => $table['Engine'] ?? '',
'collation' => $table['Collation'] ?? ''
];
}
}
return success($result);
} catch (\Exception $e) {
return error('获取表列表失败:' . $e->getMessage(), 500);
}
}
/**
* 查看表数据
* @return \think\response\Json
*/
public function viewTable()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$tableName = Request::param('table', '');
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
if (empty($tableName)) {
return error('表名不能为空', 400);
}
try {
// 验证表是否存在
$config = Config::get('database.connections.mysql');
$database = $config['database'] ?? '';
$tables = Db::query("SHOW TABLES FROM `{$database}` LIKE '{$tableName}'");
if (empty($tables)) {
return error('表不存在', 404);
}
// 获取表结构
$columns = Db::query("SHOW COLUMNS FROM `{$tableName}`");
// 获取数据
$total = Db::name(str_replace($config['prefix'] ?? 'mbti_', '', $tableName))->count();
$list = Db::name(str_replace($config['prefix'] ?? 'mbti_', '', $tableName))
->page($page, $pageSize)
->select()
->toArray();
return success([
'columns' => $columns,
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
]);
} catch (\Exception $e) {
return error('查看表数据失败:' . $e->getMessage(), 500);
}
}
/**
* 导出表数据
* @return \think\response\Json
*/
public function exportTable()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$tableName = Request::param('table', '');
if (empty($tableName)) {
return error('表名不能为空', 400);
}
try {
// 生成SQL导出文件
$backupDir = root_path() . 'runtime/backup/';
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$filename = $tableName . '_' . date('YmdHis') . '.sql';
$filepath = $backupDir . $filename;
$this->exportTableToSql($tableName, $filepath);
return success([
'filename' => $filename,
'filepath' => $filepath,
'downloadUrl' => '/api/v1/superadmin/database/download?file=' . urlencode($filename)
], '导出成功');
} catch (\Exception $e) {
return error('导出表数据失败:' . $e->getMessage(), 500);
}
}
/**
* 清空表数据
* @return \think\response\Json
*/
public function clearTable()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$tableName = Request::param('table', '');
if (empty($tableName)) {
return error('表名不能为空', 400);
}
try {
// 验证表是否存在
$config = Config::get('database.connections.mysql');
$database = $config['database'] ?? '';
$tables = Db::query("SHOW TABLES FROM `{$database}` LIKE '{$tableName}'");
if (empty($tables)) {
return error('表不存在', 404);
}
// 清空表
Db::execute("TRUNCATE TABLE `{$tableName}`");
return success(null, '表数据已清空');
} catch (\Exception $e) {
return error('清空表数据失败:' . $e->getMessage(), 500);
}
}
/**
* 备份数据库
* @return \think\response\Json
*/
public function backup()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$config = Config::get('database.connections.mysql');
$host = $config['hostname'] ?? 'localhost';
$port = $config['hostport'] ?? 3306;
$database = $config['database'] ?? '';
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
// 创建备份目录
$backupDir = root_path() . 'runtime/backup/';
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$filename = 'backup_' . $database . '_' . date('YmdHis') . '.sql';
$filepath = $backupDir . $filename;
// 优先使用PHP方式备份更可靠
$this->backupDatabase($database, $filepath);
// 如果文件不存在或为空尝试使用mysqldump
if (!file_exists($filepath) || filesize($filepath) == 0) {
$mysqldumpPath = $this->findMysqldump();
if ($mysqldumpPath) {
// 使用mysqldump命令
$command = sprintf(
'"%s" -h%s -P%s -u%s -p%s %s > "%s" 2>&1',
$mysqldumpPath,
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($username),
escapeshellarg($password),
escapeshellarg($database),
escapeshellarg($filepath)
);
exec($command, $output, $returnVar);
if ($returnVar !== 0) {
throw new \Exception('mysqldump执行失败: ' . implode("\n", $output));
}
}
}
// 获取文件大小
$fileSize = filesize($filepath);
// 上传到OSS
$ossUrl = null;
$ossPath = null;
try {
$ossResult = $this->uploadBackupToOss($filepath, $filename);
if ($ossResult) {
$ossUrl = $ossResult['url'];
$ossPath = $ossResult['path'];
}
} catch (\Exception $e) {
// OSS上传失败不影响备份成功只记录错误
Log::error('备份文件上传OSS失败' . $e->getMessage());
}
// 记录备份信息
$this->saveBackupRecord($filename, $filepath, $fileSize, $ossUrl, $ossPath);
return success([
'filename' => $filename,
'filepath' => $filepath,
'size' => $fileSize,
'time' => date('Y-m-d H:i:s'),
'ossUrl' => $ossUrl,
'ossPath' => $ossPath,
'downloadUrl' => '/api/v1/superadmin/database/download?file=' . urlencode($filename)
], '备份成功' . ($ossUrl ? '已上传到OSS' : ''));
} catch (\Exception $e) {
return error('备份失败:' . $e->getMessage(), 500);
}
}
/**
* 获取备份记录列表
* @return \think\response\Json
*/
public function backups()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
// 从数据库读取备份记录
$records = BackupRecordModel::order('createdAt', 'desc')->select()->toArray();
$backups = [];
foreach ($records as $record) {
$backups[] = [
'id' => $record['id'],
'filename' => $record['filename'],
'time' => date('Y-m-d\TH:i:s', $record['createdAt']),
'size' => intval($record['fileSize']),
'status' => $record['status'] ?? 'success',
'ossUrl' => $record['ossUrl'] ?? null,
'ossPath' => $record['ossPath'] ?? null,
'filepath' => $record['filepath'] ?? null
];
}
return success($backups);
} catch (\Exception $e) {
return error('获取备份记录失败:' . $e->getMessage(), 500);
}
}
/**
* 删除备份记录(软删除)
* @return \think\response\Json
*/
public function delete()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 支持从路由参数或请求参数获取ID
$id = Request::param('id', 0) ?: Request::route('id', 0);
if (empty($id)) {
return error('记录ID不能为空', 400);
}
try {
$record = BackupRecordModel::find($id);
if (!$record) {
return error('备份记录不存在', 404);
}
// 软删除ThinkPHP的SoftDelete会自动设置deletedAt
$record->delete();
return success(null, '备份记录已删除');
} catch (\Exception $e) {
return error('删除失败:' . $e->getMessage(), 500);
}
}
/**
* 下载备份文件
*/
public function download()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$filename = Request::param('file', '');
if (empty($filename)) {
return error('文件名不能为空', 400);
}
// 安全检查:只允许下载备份目录下的文件
$backupDir = root_path() . 'runtime/backup/';
$filepath = realpath($backupDir . $filename);
if (!$filepath || strpos($filepath, realpath($backupDir)) !== 0) {
return error('文件不存在', 404);
}
if (!file_exists($filepath)) {
return error('文件不存在', 404);
}
// 下载文件
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit;
}
/**
* 恢复数据库
* @return \think\response\Json
*/
public function restore()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$filename = Request::param('file', '');
if (empty($filename)) {
return error('文件名不能为空', 400);
}
try {
$backupDir = root_path() . 'runtime/backup/';
$filepath = realpath($backupDir . $filename);
if (!$filepath || strpos($filepath, realpath($backupDir)) !== 0) {
return error('文件不存在', 404);
}
if (!file_exists($filepath)) {
return error('文件不存在', 404);
}
$config = Config::get('database.connections.mysql');
$host = $config['hostname'] ?? 'localhost';
$port = $config['hostport'] ?? 3306;
$database = $config['database'] ?? '';
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
// 使用PHP方式恢复
$this->restoreDatabase($filepath);
return success(null, '数据库恢复成功');
} catch (\Exception $e) {
return error('恢复失败:' . $e->getMessage(), 500);
}
}
/**
* 获取数据库大小
*/
private function getDatabaseSize($database)
{
try {
$result = Db::query("SELECT
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = '{$database}'");
return floatval($result[0]['size_mb'] ?? 0);
} catch (\Exception $e) {
return 0;
}
}
/**
* 获取表数量
*/
private function getTableCount($database)
{
try {
$result = Db::query("SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = '{$database}'");
return intval($result[0]['count'] ?? 0);
} catch (\Exception $e) {
return 0;
}
}
/**
* 导出表到SQL文件
*/
private function exportTableToSql($tableName, $filepath)
{
$fp = fopen($filepath, 'w');
// 写入表结构
$createTable = Db::query("SHOW CREATE TABLE `{$tableName}`");
fwrite($fp, "-- 表结构: {$tableName}\n");
fwrite($fp, "DROP TABLE IF EXISTS `{$tableName}`;\n");
fwrite($fp, $createTable[0]['Create Table'] . ";\n\n");
// 写入数据
$data = Db::query("SELECT * FROM `{$tableName}`");
if (!empty($data)) {
fwrite($fp, "-- 表数据: {$tableName}\n");
foreach ($data as $row) {
$values = [];
foreach ($row as $value) {
$values[] = is_null($value) ? 'NULL' : "'" . addslashes($value) . "'";
}
fwrite($fp, "INSERT INTO `{$tableName}` VALUES (" . implode(', ', $values) . ");\n");
}
}
fclose($fp);
}
/**
* 备份数据库PHP方式
*/
private function backupDatabase($database, $filepath)
{
$fp = fopen($filepath, 'w');
// 写入文件头
fwrite($fp, "-- MySQL数据库备份\n");
fwrite($fp, "-- 数据库: {$database}\n");
fwrite($fp, "-- 备份时间: " . date('Y-m-d H:i:s') . "\n");
fwrite($fp, "SET NAMES utf8mb4;\n");
fwrite($fp, "SET FOREIGN_KEY_CHECKS = 0;\n\n");
// 获取所有表
$tables = Db::query("SHOW TABLES FROM `{$database}`");
$tableKey = 'Tables_in_' . $database;
foreach ($tables as $table) {
$tableName = $table[$tableKey];
// 写入表结构
$createTable = Db::query("SHOW CREATE TABLE `{$tableName}`");
if (!empty($createTable)) {
fwrite($fp, "-- ----------------------------\n");
fwrite($fp, "-- Table structure for {$tableName}\n");
fwrite($fp, "-- ----------------------------\n");
fwrite($fp, "DROP TABLE IF EXISTS `{$tableName}`;\n");
fwrite($fp, $createTable[0]['Create Table'] . ";\n\n");
// 写入数据
$data = Db::query("SELECT * FROM `{$tableName}`");
if (!empty($data)) {
fwrite($fp, "-- ----------------------------\n");
fwrite($fp, "-- Records of {$tableName}\n");
fwrite($fp, "-- ----------------------------\n");
foreach ($data as $row) {
$columns = [];
$values = [];
foreach ($row as $col => $val) {
$columns[] = "`{$col}`";
$values[] = is_null($val) ? 'NULL' : "'" . addslashes($val) . "'";
}
fwrite($fp, "INSERT INTO `{$tableName}` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $values) . ");\n");
}
fwrite($fp, "\n");
}
}
}
fwrite($fp, "SET FOREIGN_KEY_CHECKS = 1;\n");
fclose($fp);
}
/**
* 恢复数据库PHP方式
*/
private function restoreDatabase($filepath)
{
$sql = file_get_contents($filepath);
// 分割SQL语句
$statements = array_filter(array_map('trim', explode(';', $sql)));
foreach ($statements as $statement) {
if (!empty($statement)) {
Db::execute($statement);
}
}
}
/**
* 查找mysqldump路径
*/
private function findMysqldump()
{
$paths = [
'/usr/bin/mysqldump',
'/usr/local/bin/mysqldump',
'C:\\mysql\\bin\\mysqldump.exe',
'C:\\xampp\\mysql\\bin\\mysqldump.exe',
'mysqldump'
];
foreach ($paths as $path) {
if (is_executable($path) || shell_exec("which {$path}")) {
return $path;
}
}
return null;
}
/**
* 查找mysql路径
*/
private function findMysql()
{
$paths = [
'/usr/bin/mysql',
'/usr/local/bin/mysql',
'C:\\mysql\\bin\\mysql.exe',
'C:\\xampp\\mysql\\bin\\mysql.exe',
'mysql'
];
foreach ($paths as $path) {
if (is_executable($path) || shell_exec("which {$path}")) {
return $path;
}
}
return null;
}
/**
* 上传备份文件到OSS
* @param string $filepath 本地文件路径
* @param string $filename 文件名
* @return array|null 返回OSS URL和路径失败返回null
*/
private function uploadBackupToOss($filepath, $filename)
{
if (!class_exists('\OSS\OssClient')) {
throw new \RuntimeException('未安装 Aliyun OSS SDK请先执行composer require aliyuncs/oss-sdk-php');
}
// 读取OSS配置
$uploadConfig = Config::get('upload.oss');
$accessKeyId = $uploadConfig['access_key_id'] ?? '';
$accessKeySecret = $uploadConfig['access_key_secret'] ?? '';
$endpoint = $uploadConfig['endpoint'] ?? '';
$bucket = $uploadConfig['bucket'] ?? '';
$baseUrl = rtrim($uploadConfig['url'] ?? '', '/');
// 如果配置为空,尝试从环境变量读取
if (empty($accessKeyId)) {
$accessKeyId = getenv('OSS_ACCESS_KEY_ID') ?: getenv('ALIYUN_ACCESS_KEY_ID') ?: env('OSS_ACCESS_KEY_ID', env('ALIYUN_ACCESS_KEY_ID', ''));
}
if (empty($accessKeySecret)) {
$accessKeySecret = getenv('OSS_ACCESS_KEY_SECRET') ?: getenv('ALIYUN_OSS_ACCESS_KEY_SECRET') ?: env('OSS_ACCESS_KEY_SECRET', env('ALIYUN_OSS_ACCESS_KEY_SECRET', ''));
}
if (empty($endpoint)) {
$endpoint = getenv('OSS_ENDPOINT') ?: getenv('ALIYUN_OSS_ENDPOINT') ?: env('OSS_ENDPOINT', env('ALIYUN_OSS_ENDPOINT', ''));
}
if (empty($bucket)) {
$bucket = getenv('OSS_BUCKET') ?: getenv('ALIYUN_OSS_BUCKET') ?: env('OSS_BUCKET', env('ALIYUN_OSS_BUCKET', ''));
}
if (empty($baseUrl)) {
$baseUrl = rtrim(getenv('OSS_URL') ?: getenv('ALIYUN_OSS_URL') ?: env('OSS_URL', env('ALIYUN_OSS_URL', '')), '/');
}
// 检查配置是否完整
if (empty($accessKeyId) || empty($accessKeySecret) || empty($endpoint) || empty($bucket) || empty($baseUrl)) {
throw new \RuntimeException('OSS配置不完整无法上传备份文件');
}
// 构建OSS对象路径不使用OSS_PREFIX直接使用backup目录
// 格式backup/2026/02/12/backup_mbti_20260212160100.sql
$datePath = date('Y/m/d');
$object = 'backup/' . $datePath . '/' . $filename;
try {
// 创建OSS客户端
$client = new \OSS\OssClient($accessKeyId, $accessKeySecret, $endpoint);
// 验证Bucket是否存在
if (!$client->doesBucketExist($bucket)) {
throw new \RuntimeException("OSS Bucket '{$bucket}' 不存在或无法访问");
}
// 上传文件
$client->uploadFile($bucket, $object, $filepath);
// 生成访问URL
$url = $baseUrl . '/' . ltrim($object, '/');
return [
'url' => $url,
'path' => $object
];
} catch (\OSS\Core\OssException $e) {
throw new \RuntimeException('OSS上传失败' . $e->getMessage());
}
}
/**
* 保存备份记录
*/
private function saveBackupRecord($filename, $filepath, $fileSize, $ossUrl = null, $ossPath = null)
{
try {
BackupRecordModel::create([
'filename' => $filename,
'filepath' => $filepath,
'fileSize' => $fileSize,
'ossUrl' => $ossUrl,
'ossPath' => $ossPath,
'status' => 'success'
]);
} catch (\Exception $e) {
// 记录保存失败不影响备份成功,只记录日志
Log::error('保存备份记录失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,433 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 分销管理控制器(超管端 - 个人版分销)
* 路由前缀:/api/v1/superadmin/distribution
*/
class Distribution extends BaseController
{
// ─────────────────────────────────────────────────────────────
// GET distribution/overview 全平台分销数据概览
// ─────────────────────────────────────────────────────────────
public function overview()
{
try {
$now = time();
$totalCommission = Db::name('commission_records')->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0;
$paidCommission = Db::name('commission_records')->where('status', 'paid')->sum('commissionFen') ?: 0;
$frozenCommission = Db::name('commission_records')->where('status', 'frozen')->sum('commissionFen') ?: 0;
$totalOrders = Db::name('commission_records')->whereIn('status', ['paid', 'frozen'])->count();
$personalCommission = Db::name('commission_records')->where('scope', 'personal')
->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0;
$enterpriseCommission = Db::name('commission_records')->where('scope', 'enterprise')
->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0;
$bindingCount = Db::name('distribution_bindings')
->where('status', 'active')
->where('expireAt', '>', $now)
->count();
// 待处理提现status=0 审核中
$pendingWithdraw = Db::name('distribution_withdrawals')
->where('status', 0)
->sum('amountFen') ?: 0;
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayCommission = Db::name('commission_records')
->where('status', 'paid')
->where('paidAt', '>=', $todayStart)
->sum('commissionFen') ?: 0;
return success([
'totalCommission' => number_format($totalCommission / 100, 2, '.', ''),
'paidCommission' => number_format($paidCommission / 100, 2, '.', ''),
'frozenCommission' => number_format($frozenCommission / 100, 2, '.', ''),
'personalCommission' => number_format($personalCommission / 100, 2, '.', ''),
'enterpriseCommission'=> number_format($enterpriseCommission / 100, 2, '.', ''),
'totalOrders' => $totalOrders,
'bindingCount' => $bindingCount,
'pendingWithdraw' => number_format($pendingWithdraw / 100, 2, '.', ''),
'todayCommission' => number_format($todayCommission / 100, 2, '.', ''),
]);
} catch (\Exception $e) {
return error('获取数据失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/bindings 全平台绑定记录
// ─────────────────────────────────────────────────────────────
public function bindings()
{
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$scope = Request::param('scope', '');
$status = Request::param('status', '');
$enterpriseId = (int) Request::param('enterpriseId', 0);
try {
$query = Db::name('distribution_bindings')
->alias('b')
->leftJoin('wechat_users inv', 'b.inviterId = inv.id')
->leftJoin('wechat_users invt', 'b.inviteeId = invt.id')
->leftJoin('enterprises e', 'b.enterpriseId = e.id')
->field('b.*, inv.nickname as inviterName, invt.nickname as inviteeName, e.name as enterpriseName');
if ($scope) $query->where('b.scope', $scope);
if ($status) $query->where('b.status', $status);
if ($enterpriseId) $query->where('b.enterpriseId', $enterpriseId);
$total = (clone $query)->count();
$list = $query->order('b.updatedAt', 'desc')->page($page, $pageSize)->select()->toArray();
$now = time();
foreach ($list as &$row) {
$row['remainDays'] = max(0, (int) ceil(($row['expireAt'] - $now) / 86400));
$row['inviterName'] = $row['inviterName'] ?: '未知';
$row['inviteeName'] = $row['inviteeName'] ?: '未知';
}
return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
} catch (\Exception $e) {
return error('获取绑定记录失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/commissions 全平台佣金记录
// ─────────────────────────────────────────────────────────────
public function commissions()
{
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$scope = Request::param('scope', '');
$status = Request::param('status', '');
try {
$query = Db::name('commission_records')
->alias('c')
->leftJoin('wechat_users inv', 'c.inviterId = inv.id')
->leftJoin('wechat_users invt', 'c.inviteeId = invt.id')
->leftJoin('enterprises e', 'c.enterpriseId = e.id')
->field('c.*, inv.nickname as inviterName, invt.nickname as inviteeName, e.name as enterpriseName');
if ($scope) $query->where('c.scope', $scope);
if ($status) $query->where('c.status', $status);
$total = (clone $query)->count();
$list = $query->order('c.createdAt', 'desc')->page($page, $pageSize)->select()->toArray();
foreach ($list as &$row) {
$row['commissionYuan'] = number_format($row['commissionFen'] / 100, 2, '.', '');
$row['orderYuan'] = number_format($row['orderAmount'] / 100, 2, '.', '');
}
return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
} catch (\Exception $e) {
return error('获取佣金记录失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/withdrawals 全平台提现申请
// ─────────────────────────────────────────────────────────────
public function withdrawals()
{
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
try {
$query = Db::name('distribution_withdrawals')
->alias('w')
->leftJoin('wechat_users u', 'w.userId = u.id')
->field('w.*, u.nickname, u.avatar');
if ($status !== '') {
// 支持字符串或数字,统一转 int
$query->where('w.status', (int)$status);
}
$total = (clone $query)->count();
$list = $query->order('w.createdAt', 'desc')->page($page, $pageSize)->select()->toArray();
foreach ($list as &$row) {
$row['amountYuan'] = number_format($row['amountFen'] / 100, 2, '.', '');
$row['nickname'] = $row['nickname'] ?: '未知用户';
// 确保前端拿到的是数字 status避免 '0' 和 0 比较异常)
$code = (int) ($row['status'] ?? 0);
$row['status'] = $code;
// 统一后台状态文案0审核中、1已驳回、2待收款、3已收款、4已过期
switch ($code) {
case 0:
$row['statusLabel'] = '审核中';
break;
case 1:
$row['statusLabel'] = '已驳回';
break;
case 2:
$row['statusLabel'] = '待收款';
break;
case 3:
$row['statusLabel'] = '已收款';
break;
case 4:
$row['statusLabel'] = '已过期';
break;
default:
$row['statusLabel'] = '未知';
break;
}
}
return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]);
} catch (\Exception $e) {
return error('获取提现记录失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// POST distribution/withdrawals/:id/approve 审核通过提现
// ─────────────────────────────────────────────────────────────
public function approveWithdrawal(int $id)
{
$note = Request::param('note', '');
$now = time();
$record = Db::name('distribution_withdrawals')
->alias('w')
->leftJoin('wechat_users u', 'w.userId = u.id')
->field('w.*, u.openid')
->where('w.id', $id)
->find();
// 仅允许处理审核中status=0的记录
if (!$record || (int)$record['status'] !== 0) {
return error('提现申请不存在或已处理', 400);
}
try {
// 生成商户单号TX + 时间戳 + 随机数 + 提现ID示例TX202603121526520005123
$outBillNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999) . $record['id'];
// 调用微信商家转账到零钱接口(参数对齐 ckb-admin Withdrawal::handleWechatPay
$service = new \app\common\service\WechatTransferService();
$result = $service->createTransfer([
'out_bill_no' => $outBillNo,
'openid' => $record['openid'],
'transfer_amount' => (int) $record['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) {
$err = $result['error'] ?? [];
$code = $err['code'] ?? 'UNKNOWN';
$msg = $err['message'] ?? '微信转账接口调用失败';
return error("微信转账发起失败({$code}{$msg}", 500);
}
$wechatData = $result['data'] ?? [];
Db::name('distribution_withdrawals')->where('id', $id)->update([
// 2=待收款(已发起转账,等待用户确认)
'status' => 2,
'auditNote' => $note,
'auditAt' => $now,
'updatedAt' => $now,
'pay_type' => 'wechat',
'out_bill_no' => $outBillNo,
'transfer_bill_no' => $wechatData['transfer_bill_no'] ?? null,
'wechat_pay_state' => $wechatData['state'] ?? 'PROCESSING',
'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, '审核通过,已发起微信转账');
} catch (\Exception $e) {
return error('操作失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// POST distribution/withdrawals/:id/reject 拒绝提现
// ─────────────────────────────────────────────────────────────
public function rejectWithdrawal(int $id)
{
$note = Request::param('note', '');
$now = time();
$record = Db::name('distribution_withdrawals')->where('id', $id)->find();
// 仅允许处理审核中status=0的记录
if (!$record || (int)$record['status'] !== 0) {
return error('提现申请不存在或已处理', 400);
}
Db::startTrans();
try {
Db::name('wechat_users')
->where('id', $record['userId'])
->inc('walletBalance', $record['amountFen'])
->update(['updatedAt' => $now]);
Db::name('distribution_withdrawals')->where('id', $id)->update([
// 1=已驳回
'status' => 1,
'auditNote' => $note,
'auditAt' => $now,
'updatedAt' => $now,
]);
Db::commit();
return success(null, '已拒绝,余额已退回');
} catch (\Exception $e) {
Db::rollback();
return error('操作失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// GET distribution/settings 个人版分销全局配置
// ─────────────────────────────────────────────────────────────
public function settings()
{
try {
$config = Db::name('system_config')->where('key', 'distribution')->where('enterprise_id', 0)->find();
$default = [
'enabled' => true,
'promoCenterTitle' => '推广中心',
'bindingDays' => 30,
'minWithdrawFen' => 100,
'maxWithdrawFen' => 0,
'requireAudit' => true,
'withdrawFee' => 0,
'testSettings' => self::defaultTestSettings(),
];
if ($config && $config['value']) {
$settings = is_string($config['value']) ? json_decode($config['value'], true) : $config['value'];
$settings = array_merge($default, $settings ?? []);
} else {
$settings = $default;
}
$settings['minWithdraw'] = round((float)($settings['minWithdrawFen'] ?? 100) / 100, 2);
$settings['maxWithdraw'] = ($max = (int)($settings['maxWithdrawFen'] ?? 0)) > 0 ? round($max / 100, 2) : 0;
$settings['testSettings'] = self::appendTestSettingsAmount($settings['testSettings'] ?? self::defaultTestSettings());
return success($settings);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
// ─────────────────────────────────────────────────────────────
// PUT distribution/settings 更新个人版分销全局配置
// ─────────────────────────────────────────────────────────────
public function updateSettings()
{
$settings = Request::only([
'enabled', 'promoCenterTitle', 'bindingDays',
'minWithdrawFen', 'minWithdraw', 'maxWithdrawFen', 'maxWithdraw',
'requireAudit', 'withdrawFee', 'testSettings'
]);
$minWithdrawFen = isset($settings['minWithdraw'])
? (int) round((float)$settings['minWithdraw'] * 100)
: (int)($settings['minWithdrawFen'] ?? 100);
$maxWithdrawFen = isset($settings['maxWithdraw'])
? (int) round((float)$settings['maxWithdraw'] * 100)
: (int)($settings['maxWithdrawFen'] ?? 0);
$minWithdrawFen = max(100, min(20000, $minWithdrawFen));
$maxWithdrawFen = $maxWithdrawFen > 0 ? min(20000, max(100, $maxWithdrawFen)) : 0;
$promoTitle = trim((string)($settings['promoCenterTitle'] ?? ''));
$toSave = [
'enabled' => (bool)($settings['enabled'] ?? true),
'promoCenterTitle' => $promoTitle !== '' ? $promoTitle : '推广中心',
'bindingDays' => (int)($settings['bindingDays'] ?? 30),
'minWithdrawFen' => $minWithdrawFen,
'maxWithdrawFen' => $maxWithdrawFen,
'requireAudit' => isset($settings['requireAudit']) ? (bool)$settings['requireAudit'] : true,
'withdrawFee' => max(0, min(100, (float)($settings['withdrawFee'] ?? 0))),
'testSettings' => self::sanitizeTestSettings($settings['testSettings'] ?? null),
];
try {
$now = time();
$existing = Db::name('system_config')->where('key', 'distribution')->where('enterprise_id', 0)->find();
if ($existing) {
Db::name('system_config')
->where('key', 'distribution')
->where('enterprise_id', 0)
->update(['value' => json_encode($toSave, JSON_UNESCAPED_UNICODE), 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => 'distribution',
'enterprise_id' => 0,
'value' => json_encode($toSave, JSON_UNESCAPED_UNICODE),
'createdAt' => $now,
'updatedAt' => $now,
]);
}
$toSave['minWithdraw'] = $toSave['minWithdrawFen'] / 100;
$toSave['maxWithdraw'] = $toSave['maxWithdrawFen'] > 0 ? $toSave['maxWithdrawFen'] / 100 : 0;
$toSave['testSettings'] = self::appendTestSettingsAmount($toSave['testSettings']);
return success($toSave, '配置已保存');
} catch (\Exception $e) {
return error('保存配置失败:' . $e->getMessage(), 500);
}
}
private static function defaultTestSettings(): array
{
$item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false];
return ['face' => $item, 'mbti' => $item, 'disc' => $item, 'pdp' => $item];
}
private static function sanitizeTestSettings($raw): array
{
$default = self::defaultTestSettings();
if (!is_array($raw)) return $default;
$result = [];
foreach ($default as $type => $def) {
$s = $raw[$type] ?? [];
$commissionType = in_array($s['commissionType'] ?? '', ['ratio', 'amount']) ? $s['commissionType'] : 'ratio';
$amountFen = isset($s['commissionAmount'])
? (int) round((float)$s['commissionAmount'] * 100)
: (int)($s['commissionAmountFen'] ?? 0);
$rate = max(0, min(100, (int)($s['commissionRate'] ?? 90)));
$result[$type] = [
'enabled' => ($s['enabled'] ?? true) !== false,
'commissionType' => $commissionType,
'commissionRate' => $commissionType === 'ratio' ? $rate : 0,
'commissionAmountFen'=> $commissionType === 'amount' ? max(0, $amountFen) : 0,
'noPayment' => !empty($s['noPayment']),
];
}
return $result;
}
private static function appendTestSettingsAmount(array $ts): array
{
foreach ($ts as $k => $v) {
$ts[$k]['commissionAmount'] = round(($v['commissionAmountFen'] ?? 0) / 100, 2);
}
return $ts;
}
}

View File

@@ -0,0 +1,437 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Request;
use think\facade\Db;
/**
* 企业管理控制器(超管专用)
*/
class Enterprise extends BaseController
{
/**
* 获取企业列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->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, '操作成功');
}
}

View File

@@ -0,0 +1,337 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Request;
use think\facade\Db;
/**
* 财务管理控制器(超管专用)
* 数据来源mbti_orders金额单位
*/
class Finance extends BaseController
{
private const PAID_STATUS = ['paid', 'completed'];
/**
* 获取财务概览
* 金额单位:分
*/
public function overview()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$currentMonthStart = mktime(0, 0, 0, (int) date('n'), 1, (int) date('Y'));
$currentMonthEnd = mktime(23, 59, 59, (int) date('n'), (int) date('t'), (int) date('Y'));
$basePaid = Db::name('orders')->whereIn('status', self::PAID_STATUS);
$totalRevenue = (int) ((clone $basePaid)->sum('amount') ?? 0);
$paidOrderCount = (int) ((clone $basePaid)->count());
$monthRevenue = (int) (Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->where('payTime', '>=', $currentMonthStart)
->where('payTime', '<=', $currentMonthEnd)
->sum('amount') ?? 0);
// 成本:无成本表时按收入比例估算(约 30%
$totalCost = (int) round($totalRevenue * 0.3);
$monthCost = (int) round($monthRevenue * 0.3);
$netProfit = $totalRevenue - $totalCost;
$monthProfit = $monthRevenue - $monthCost;
$profitRate = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0;
$lastMonthStart = mktime(0, 0, 0, (int) date('n') - 1, 1, (int) date('Y'));
$lastMonthEnd = mktime(23, 59, 59, (int) date('n') - 1, (int) date('t', $lastMonthStart), (int) date('Y'));
$lastMonthRevenue = (int) (Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->where('payTime', '>=', $lastMonthStart)
->where('payTime', '<=', $lastMonthEnd)
->sum('amount') ?? 0);
$lastMonthCost = (int) round($lastMonthRevenue * 0.3);
$lastMonthProfit = $lastMonthRevenue - $lastMonthCost;
$monthGrowth = $lastMonthProfit > 0
? round(($monthProfit - $lastMonthProfit) / $lastMonthProfit * 100, 1)
: ($monthProfit > 0 ? 100 : 0);
return success([
'totalRevenue' => $totalRevenue,
'totalCost' => $totalCost,
'netProfit' => $netProfit,
'profitRate' => $profitRate,
'monthRevenue' => $monthRevenue,
'monthCost' => $monthCost,
'monthProfit' => $monthProfit,
'monthGrowth' => $monthGrowth,
'paidOrderCount' => $paidOrderCount,
]);
} catch (\Throwable $e) {
return error('获取财务概览失败:' . $e->getMessage(), 500);
}
}
/**
* 收入明细:按产品类型汇总(已支付订单),金额单位:分
*/
public function revenueDetails()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$rows = Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->field('productType, SUM(amount) as total')
->group('productType')
->select()
->toArray();
$typeLabel = [
'face' => 'AI人脸分析',
'mbti' => 'MBTI',
'disc' => 'DISC',
'pdp' => 'PDP',
'resume' => '简历综合分析',
'report' => '完整报告',
];
$totalSum = 0;
$byType = [];
foreach ($rows as $r) {
$type = $r['productType'] ?? 'other';
$amount = (int) ($r['total'] ?? 0);
$totalSum += $amount;
$byType[$type] = $amount;
}
$details = [];
foreach ($typeLabel as $key => $label) {
$amount = $byType[$key] ?? 0;
$details[] = [
'type' => $label,
'amount' => $amount,
'percent' => $totalSum > 0 ? round($amount / $totalSum * 100, 1) : 0,
];
}
$otherAmount = 0;
foreach ($byType as $key => $amount) {
if (!isset($typeLabel[$key])) {
$otherAmount += $amount;
}
}
if ($otherAmount > 0) {
$details[] = [
'type' => '其他',
'amount' => $otherAmount,
'percent' => $totalSum > 0 ? round($otherAmount / $totalSum * 100, 1) : 0,
];
}
return success($details);
} catch (\Throwable $e) {
return error('获取收入明细失败:' . $e->getMessage(), 500);
}
}
/**
* 成本明细:当前为估算(基于收入的 30% 拆分),金额单位:分
*/
public function costDetails()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$totalRevenue = (int) Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->sum('amount');
$totalCost = (int) round($totalRevenue * 0.3);
$items = [
['type' => 'AI 调用(人脸/分析等)', 'ratio' => 0.15],
['type' => '服务器及运维', 'ratio' => 0.08],
['type' => '其他支出', 'ratio' => 0.07],
];
$details = [];
foreach ($items as $item) {
$amount = (int) round($totalRevenue * $item['ratio']);
$details[] = [
'type' => $item['type'],
'amount' => $amount,
'percent' => $totalCost > 0 ? round($amount / $totalCost * 100, 1) : 0,
];
}
return success($details);
} catch (\Throwable $e) {
return error('获取成本明细失败:' . $e->getMessage(), 500);
}
}
/**
* 企业支付记录(已支付且 enterpriseId 不为空的订单),金额单位:分
*/
public function rechargeRecords()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
$query = Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->whereNotNull('enterpriseId')
->where('enterpriseId', '<>', '')
->order('payTime', 'desc');
$total = (int) (clone $query)->count();
$list = (clone $query)->page($page, $pageSize)
->field('id, orderNo, enterpriseId, amount, payMethod, payTime')
->select()
->toArray();
$eids = array_values(array_unique(array_filter(array_column($list, 'enterpriseId'))));
$enterprises = [];
if (!empty($eids)) {
$entList = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
$enterprises = $entList ?: [];
}
$result = [];
foreach ($list as $r) {
$eid = $r['enterpriseId'] ?? null;
$result[] = [
'orderNo' => $r['orderNo'] ?? '',
'enterprise' => $eid ? ($enterprises[$eid] ?? '企业#' . $eid) : '—',
'amount' => (int) ($r['amount'] ?? 0),
'method' => $r['payMethod'] === 'wechat' ? '微信支付' : ($r['payMethod'] ?? '—'),
'date' => !empty($r['payTime']) ? date('Y-m-d H:i', $r['payTime']) : '—',
];
}
return success([
'list' => $result,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
]);
} catch (\Throwable $e) {
return error('获取企业支付记录失败:' . $e->getMessage(), 500);
}
}
/**
* 支付记录(全部已支付订单,分页),金额单位:分
*/
public function paymentRecords()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
$keyword = trim(Request::param('keyword', ''));
$query = Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->order('payTime', 'desc');
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->whereLike('orderNo', '%' . $keyword . '%');
if (is_numeric($keyword)) {
$q->whereOr('userId', (int) $keyword);
}
});
}
$total = (int) (clone $query)->count();
$list = (clone $query)->page($page, $pageSize)
->field('id, orderNo, userId, enterpriseId, productType, productTitle, amount, payMethod, payTime')
->select()
->toArray();
$userIds = array_values(array_unique(array_filter(array_column($list, 'userId'))));
$eids = array_values(array_unique(array_filter(array_column($list, 'enterpriseId'))));
$usersMap = [];
$entMap = [];
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;
}
}
if (!empty($eids)) {
$entList = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
$entMap = $entList ?: [];
}
$productTypeLabel = [
'face' => 'AI人脸分析',
'mbti' => 'MBTI',
'disc' => 'DISC',
'pdp' => 'PDP',
'report' => '完整报告',
'deep_personal' => '个人深度服务',
'deep_team' => '团队深度服务',
];
$result = [];
foreach ($list as $r) {
$uid = (int) ($r['userId'] ?? 0);
$eid = isset($r['enterpriseId']) && $r['enterpriseId'] !== '' ? (int) $r['enterpriseId'] : null;
if ($eid === 0) {
$eid = null;
}
$u = $usersMap[$uid] ?? null;
$enterpriseName = $eid ? ($entMap[$eid] ?? '企业#' . $eid) : '个人';
$result[] = [
'orderNo' => $r['orderNo'] ?? '',
'userName' => $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid),
'enterprise' => $enterpriseName,
'enterpriseId' => $eid,
'productType' => $productTypeLabel[$r['productType'] ?? ''] ?? ($r['productType'] ?? '—'),
'productTitle' => $r['productTitle'] ?? '',
'amount' => (int) ($r['amount'] ?? 0),
'method' => $r['payMethod'] === 'wechat' ? '微信支付' : ($r['payMethod'] ?? '—'),
'date' => !empty($r['payTime']) ? date('Y-m-d H:i', $r['payTime']) : '—',
];
}
return success([
'list' => $result,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
]);
} catch (\Throwable $e) {
return error('获取支付记录失败:' . $e->getMessage(), 500);
}
}
/**
* 导出财务报表
*/
public function export()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
return success(null, '财务报表导出功能开发中');
}
}

View File

@@ -0,0 +1,431 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Request;
use think\facade\Db;
/**
* 数据概览控制器(超管专用)
* 数据来源mbti_orders、wechat_users、test_results、enterprises金额单位
*/
class Overview extends BaseController
{
private const PAID_STATUS = ['paid', 'completed'];
/**
* 获取数据概览
* 金额单位:分
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$currentMonthStart = mktime(0, 0, 0, (int) date('n'), 1, (int) date('Y'));
$currentMonthEnd = mktime(23, 59, 59, (int) date('n'), (int) date('t'), (int) date('Y'));
// 企业统计
$totalEnterprises = (int) Db::name('enterprises')->count();
$newEnterprises = (int) Db::name('enterprises')
->where('createdAt', '>=', $currentMonthStart)
->where('createdAt', '<=', $currentMonthEnd)
->count();
// 注册用户数wechat_users 按 openid 去重,无 openid 则按行数)
try {
$totalRegisteredUsers = (int) Db::name('wechat_users')->count('openid', true);
} catch (\Throwable $e) {
$totalRegisteredUsers = (int) Db::name('wechat_users')->count();
}
// 有测试记录的用户数(按 wechat_users.openid 去重);本月新增 = 本月首次测试的 openid 数
$totalUsers = 0;
$newUsers = 0;
try {
$totalUsers = (int) Db::name('test_results')->distinct(true)->count('userId');
$newUsers = (int) Db::name('test_results')
->where('createdAt', '>=', $currentMonthStart)
->where('createdAt', '<=', $currentMonthEnd)
->distinct(true)
->count('userId');
// 按 openid 去重tr 关联 wechat_users统计 distinct openid
$hasOpenid = false;
try {
$openids = Db::name('test_results')->alias('tr')
->join('wechat_users w', 'tr.userId = w.id')
->distinct(true)
->column('w.openid');
if (is_array($openids)) {
$openids = array_filter(array_unique($openids));
$totalUsers = count($openids);
$hasOpenid = true;
}
} catch (\Throwable $e) {
}
if ($hasOpenid) {
$openidsBeforeMonth = Db::name('test_results')->alias('tr')
->join('wechat_users w', 'tr.userId = w.id')
->where('tr.createdAt', '<', $currentMonthStart)
->distinct(true)
->column('w.openid');
$openidsBeforeMonth = is_array($openidsBeforeMonth) ? array_filter(array_unique($openidsBeforeMonth)) : [];
$openidsInMonth = Db::name('test_results')->alias('tr')
->join('wechat_users w', 'tr.userId = w.id')
->where('tr.createdAt', '>=', $currentMonthStart)
->where('tr.createdAt', '<=', $currentMonthEnd)
->distinct(true)
->column('w.openid');
$openidsInMonth = is_array($openidsInMonth) ? array_filter(array_unique($openidsInMonth)) : [];
$newUsers = count(array_diff($openidsInMonth, $openidsBeforeMonth));
}
} catch (\Throwable $e) {
$newUsers = 0;
}
// 收入与订单(仅 orders金额分
$totalRevenue = (int) (Db::name('orders')->whereIn('status', self::PAID_STATUS)->sum('amount') ?? 0);
$monthRevenue = (int) (Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->where('payTime', '>=', $currentMonthStart)
->where('payTime', '<=', $currentMonthEnd)
->sum('amount') ?? 0);
$paidOrderCount = (int) Db::name('orders')->whereIn('status', self::PAID_STATUS)->count();
$lastMonthStart = mktime(0, 0, 0, (int) date('n') - 1, 1, (int) date('Y'));
$lastMonthEnd = mktime(23, 59, 59, (int) date('n') - 1, (int) date('t', $lastMonthStart), (int) date('Y'));
$lastMonthRevenue = (int) (Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->where('payTime', '>=', $lastMonthStart)
->where('payTime', '<=', $lastMonthEnd)
->sum('amount') ?? 0);
$revenueGrowth = $lastMonthRevenue > 0
? round(($monthRevenue - $lastMonthRevenue) / $lastMonthRevenue * 100, 1)
: ($monthRevenue > 0 ? 100.0 : 0);
// 测试统计
$totalTests = 0;
$newTests = 0;
try {
$totalTests = (int) Db::name('test_results')->count();
$newTests = (int) Db::name('test_results')
->where('createdAt', '>=', $currentMonthStart)
->where('createdAt', '<=', $currentMonthEnd)
->count();
} catch (\Throwable $e) {
}
return success([
'totalEnterprises' => $totalEnterprises,
'newEnterprises' => $newEnterprises,
'totalRegisteredUsers' => $totalRegisteredUsers,
'totalUsers' => $totalUsers,
'newUsers' => $newUsers,
'totalRevenue' => $totalRevenue,
'monthRevenue' => $monthRevenue,
'revenueGrowth' => $revenueGrowth,
'paidOrderCount' => $paidOrderCount,
'totalTests' => $totalTests,
'newTests' => $newTests,
]);
} catch (\Throwable $e) {
return error('获取数据概览失败:' . $e->getMessage(), 500);
}
}
/**
* 最近动态:支付订单、新企业、今日测试等;金额接口为分,文案中转为元
*/
public function recentDynamics()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$limit = min(20, max(5, (int) Request::param('limit', 10)));
$dynamics = [];
// 1. 最近已支付订单(含个人与企业,金额分)
try {
$orders = Db::name('orders')
->whereIn('status', self::PAID_STATUS)
->field('id, orderNo, userId, enterpriseId, productType, amount, payTime')
->order('payTime', 'desc')
->limit($limit)
->select()
->toArray();
$orders = is_array($orders) ? $orders : [];
$eids = array_values(array_unique(array_filter(array_column($orders, 'enterpriseId'))));
$uids = array_values(array_unique(array_filter(array_column($orders, 'userId'))));
$entMap = [];
$userMap = [];
if (!empty($eids)) {
$entMap = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id') ?: [];
}
if (!empty($uids)) {
$users = Db::name('wechat_users')->where('id', 'in', $uids)->field('id, nickname')->select()->toArray();
foreach (is_array($users) ? $users : [] as $u) {
$userMap[(int) ($u['id'] ?? 0)] = $u['nickname'] ?? ('用户' . ($u['id'] ?? ''));
}
}
$productLabel = ['face' => 'AI人脸', 'mbti' => 'MBTI', 'disc' => 'DISC', 'pdp' => 'PDP', 'report' => '报告'];
foreach ($orders as $o) {
$amountYuan = isset($o['amount']) ? round((int) $o['amount'] / 100, 2) : 0;
$who = '未知';
if (!empty($o['enterpriseId']) && isset($entMap[$o['enterpriseId']])) {
$who = $entMap[$o['enterpriseId']];
} else {
$who = $userMap[(int) ($o['userId'] ?? 0)] ?? ('用户' . ($o['userId'] ?? ''));
}
$product = $productLabel[$o['productType'] ?? ''] ?? ($o['productType'] ?? '');
$dynamics[] = [
'type' => 'payment',
'icon' => 'TrendCharts',
'text' => $who . ' 支付 ¥' . number_format($amountYuan, 2) . ($product ? '' . $product . '' : ''),
'time' => $this->formatTime($o['payTime'] ?? null),
'sortTime' => (int) ($o['payTime'] ?? 0),
];
}
} catch (\Throwable $e) {
// 订单数据异常不影响其他动态
}
// 2. 最近入驻企业
try {
$enterprises = Db::name('enterprises')
->field('name, createdAt')
->order('createdAt', 'desc')
->limit(5)
->select()
->toArray();
foreach (is_array($enterprises) ? $enterprises : [] as $e) {
$dynamics[] = [
'type' => 'enterprise',
'icon' => 'Document',
'text' => ($e['name'] ?? '') . ' 完成企业入驻',
'time' => $this->formatTime($e['createdAt'] ?? null),
'sortTime' => (int) ($e['createdAt'] ?? 0),
];
}
} catch (\Throwable $e) {
}
// 3. 今日测试量(按企业/个人分组,文案里带企业名称)
try {
$todayStart = mktime(0, 0, 0, (int) date('n'), (int) date('j'), (int) date('Y'));
$rows = Db::name('test_results')
->alias('tr')
->leftJoin('enterprises e', 'tr.enterpriseId = e.id')
->where('tr.createdAt', '>=', $todayStart)
->field('tr.enterpriseId, e.name as enterpriseName, COUNT(*) as cnt')
->group('tr.enterpriseId')
->order('cnt', 'desc')
->limit(5)
->select()
->toArray();
$totalToday = 0;
foreach (is_array($rows) ? $rows : [] as $row) {
$cnt = (int) ($row['cnt'] ?? 0);
if ($cnt <= 0) {
continue;
}
$totalToday += $cnt;
$eid = $row['enterpriseId'] ?? null;
$name = $row['enterpriseName'] ?? '';
if ($eid && !$name) {
$name = '企业' . $eid;
}
if (!$eid) {
$name = $name ?: '个人用户(无企业)';
}
$dynamics[] = [
'type' => 'test',
'icon' => 'TrendCharts',
'text' => $name . ' 今日完成 ' . $cnt . ' 次测试',
'time' => '今日',
'sortTime' => $todayStart + 1,
];
}
// 追加一条全局汇总(放在企业之后)
if ($totalToday > 0) {
$dynamics[] = [
'type' => 'test-total',
'icon' => 'TrendCharts',
'text' => '全站今日共完成 ' . $totalToday . ' 次测试',
'time' => '今日',
'sortTime' => $todayStart,
];
}
} catch (\Throwable $e) {
}
usort($dynamics, function ($a, $b) {
return ($b['sortTime'] ?? 0) - ($a['sortTime'] ?? 0);
});
$dynamics = array_slice($dynamics, 0, $limit);
return success($dynamics);
} catch (\Throwable $e) {
return error('获取最近动态失败:' . $e->getMessage(), 500);
}
}
/**
* 最近 N 天测试趋势(按日期 & 测试类型统计)
* GET /superadmin/overview/test-trends?days=14
*/
public function testTrends()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$days = (int) Request::param('days', 14);
$days = min(60, max(7, $days));
$startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
$rows = Db::name('test_results')
->where('createdAt', '>=', $startDate)
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp'])
->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c")
->group('d,testType')
->order('d', 'asc')
->select()
->toArray();
$trendMap = [];
foreach (is_array($rows) ? $rows : [] 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($trendData);
} catch (\Throwable $e) {
return error('获取测试趋势失败:' . $e->getMessage(), 500);
}
}
/**
* 企业活跃排行(按测试次数、支付金额);金额单位:分
*/
public function enterpriseRanking()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$limit = min(20, max(5, (int) Request::param('limit', 10)));
$result = [];
try {
// 企业表 left join 测试与订单,保证无测试/无订单的企业也出现(测试数、金额为 0
$list = Db::name('enterprises')
->alias('e')
->leftJoin('test_results tr', 'tr.enterpriseId = e.id')
->leftJoin('orders o', 'o.enterpriseId = e.id AND o.status IN (\'paid\',\'completed\')')
->field('e.id, e.name, COUNT(DISTINCT tr.id) as testCount, COALESCE(SUM(o.amount), 0) as totalAmount')
->group('e.id')
->order('testCount', 'desc')
->order('totalAmount', 'desc')
->limit($limit)
->select()
->toArray();
foreach (is_array($list) ? $list : [] as $item) {
$result[] = [
'id' => (int) ($item['id'] ?? 0),
'name' => $item['name'] ?? '',
'tests' => (int) ($item['testCount'] ?? 0),
'amount' => (int) ($item['totalAmount'] ?? 0),
];
}
} catch (\Throwable $e) {
// 若 join 报错(如表/字段不一致),降级为只查企业列表,测试与金额为 0
$list = Db::name('enterprises')->field('id, name')->order('id', 'desc')->limit($limit)->select()->toArray();
foreach (is_array($list) ? $list : [] as $item) {
$result[] = [
'id' => (int) ($item['id'] ?? 0),
'name' => $item['name'] ?? '',
'tests' => 0,
'amount' => 0,
];
}
}
return success($result);
} catch (\Throwable $e) {
return error('获取企业排行失败:' . $e->getMessage(), 500);
}
}
private function formatTime($timestamp)
{
if ($timestamp === null || $timestamp === '') {
return '';
}
$ts = is_numeric($timestamp) ? (int) $timestamp : strtotime($timestamp);
if ($ts <= 0) {
return '';
}
$diff = time() - $ts;
if ($diff < 60) {
return '刚刚';
}
if ($diff < 3600) {
return floor($diff / 60) . '分钟前';
}
if ($diff < 86400) {
return floor($diff / 3600) . '小时前';
}
if ($diff < 604800) {
return floor($diff / 86400) . '天前';
}
return date('Y-m-d H:i', $ts);
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use think\facade\Request;
/**
* 全局定价管理控制器(超管专用)
*/
class Pricing extends BaseController
{
/**
* 获取定价配置
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$type = Request::param('type', ''); // personal/enterprise/deep
$enterpriseId = Request::param('enterpriseId', null); // 仅 type=enterprise 时有效,不传为全局
if ($type) {
$enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null;
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
$query->where(empty($enterpriseId) ? 'enterpriseId' : 'enterpriseId', empty($enterpriseId) ? 'null' : '=', empty($enterpriseId) ? null : $enterpriseId);
if (empty($enterpriseId)) {
$query->whereNull('enterpriseId');
} else {
$query->where('enterpriseId', $enterpriseId);
}
} else {
$query->whereNull('enterpriseId');
}
$config = $query->find();
if (!$config) {
return error('定价配置不存在', 404);
}
return success([
'type' => $config->type,
'enterpriseId' => $config->enterpriseId,
'config' => $config->config
]);
} else {
// 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表
$configs = PricingConfigModel::select()->toArray();
$result = ['personal' => null, 'enterprise' => null, 'deep' => null, 'enterpriseList' => []];
foreach ($configs as $row) {
if ($row['enterpriseId'] === null || $row['enterpriseId'] === '') {
$result[$row['type']] = $row['config'];
} else {
if ($row['type'] === 'enterprise') {
$result['enterpriseList'][] = ['enterpriseId' => (int) $row['enterpriseId'], 'config' => $row['config']];
}
}
}
return success($result);
}
}
/**
* 更新定价配置
* @return \think\response\Json
*/
public function update()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// PUT JSON body 需显式解析,直接用 param() 读深层嵌套数组可能丢失数据
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
if (is_array($jsonData)) {
$type = (string) ($jsonData['type'] ?? '');
$enterpriseId = $jsonData['enterpriseId'] ?? null;
$config = $jsonData['config'] ?? [];
} else {
$type = (string) Request::param('type', '');
$enterpriseId = Request::param('enterpriseId', null);
$config = Request::param('config', []);
}
if (empty($type)) {
return error('定价类型不能为空', 400);
}
if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) {
return error('定价类型无效', 400);
}
if (empty($config) || !is_array($config)) {
return error('配置数据不能为空', 400);
}
$enterpriseId = ($type === 'enterprise' && $enterpriseId !== null && $enterpriseId !== '') ? (int) $enterpriseId : null;
if ($type !== 'enterprise') {
$enterpriseId = null;
}
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
if ($enterpriseId !== null) {
$query->where('enterpriseId', $enterpriseId);
} else {
$query->whereNull('enterpriseId');
}
} else {
$query->whereNull('enterpriseId');
}
// deep_personal / deep_enterprise 仅全局一条,不按企业分
$pricingConfig = $query->find();
if (!$pricingConfig) {
$pricingConfig = PricingConfigModel::create([
'type' => $type,
'enterpriseId' => $enterpriseId,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
return success([
'type' => $pricingConfig->type,
'enterpriseId' => $pricingConfig->enterpriseId,
'config' => $pricingConfig->config
], '保存成功');
}
/**
* 批量更新定价配置
* @return \think\response\Json
*/
public function batchUpdate()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
$data = is_array($jsonData) ? ($jsonData['data'] ?? []) : Request::param('data', []);
if (empty($data) || !is_array($data)) {
return error('配置数据不能为空', 400);
}
$successCount = 0;
$errors = [];
foreach ($data as $type => $config) {
if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) {
$errors[] = "类型 {$type} 无效";
continue;
}
if (empty($config) || !is_array($config)) {
$errors[] = "类型 {$type} 的配置数据无效";
continue;
}
try {
$pricingConfig = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
if (!$pricingConfig) {
PricingConfigModel::create([
'type' => $type,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
$successCount++;
} catch (\Exception $e) {
$errors[] = "保存类型 {$type} 失败:" . $e->getMessage();
}
}
if (!empty($errors)) {
return error('部分配置保存失败:' . implode('', $errors), 400);
}
return success(null, "成功保存 {$successCount} 个配置");
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Question as QuestionModel;
use think\facade\Request;
use think\facade\Db;
/**
* 题库管理控制器(超管专用)
* 管理超管题库enterpriseId = NULL
*/
class Question extends BaseController
{
/**
* 获取题库列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
$type = Request::param('type', ''); // mbti/disc/pdp
$status = Request::param('status', ''); // 1启用/0禁用
$where = [];
// 只查询超管题库enterpriseId = NULL
$where['enterpriseId'] = null;
// 类型筛选
if ($type) {
$where['type'] = $type;
}
// 状态筛选
if ($status !== '') {
$where['status'] = $status;
}
// 查询题库列表
$list = QuestionModel::where($where)
->order('sort', 'asc')
->order('id', 'asc')
->page($page, $pageSize)
->select()
->toArray();
// 处理 options 字段,确保返回数组格式
foreach ($list as &$item) {
if (isset($item['options'])) {
// 如果是对象格式stdClass先转换为数组
if (is_object($item['options'])) {
$item['options'] = json_decode(json_encode($item['options']), true);
}
// 如果是关联数组(不是索引数组),转换为索引数组
if (is_array($item['options']) && !isset($item['options'][0])) {
$item['options'] = array_values($item['options']);
}
}
}
unset($item);
// 总数
$total = QuestionModel::where($where)->count();
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
]);
}
/**
* 获取题目详情
* @param int $id
* @return \think\response\Json
*/
public function detail($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$question = QuestionModel::where('id', $id)
->where('enterpriseId', null) // 只能查看超管题库
->find();
if (!$question) {
return error('题目不存在', 404);
}
$data = $question->toArray();
// 处理 options 字段,确保返回数组格式
if (isset($data['options'])) {
// 如果是对象格式stdClass先转换为数组
if (is_object($data['options'])) {
$data['options'] = json_decode(json_encode($data['options']), true);
}
// 如果是关联数组(不是索引数组),转换为索引数组
if (is_array($data['options']) && !isset($data['options'][0])) {
$data['options'] = array_values($data['options']);
}
}
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::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
// 验证必填字段
if (empty($data['type']) || empty($data['question']) || empty($data['options'])) {
return error('题目类型、题目内容和选项不能为空', 400);
}
// 验证类型
if (!in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
return error('题目类型必须是 mbti、disc 或 pdp', 400);
}
// 验证选项格式
if (!is_array($data['options'])) {
return error('选项必须是数组格式', 400);
}
// MBTI类型需要dimension字段
if ($data['type'] === 'mbti' && empty($data['dimension'])) {
return error('MBTI类型题目必须指定维度EI/SN/TF/JP', 400);
}
// 设置超管题库标识enterpriseId = NULL
$data['enterpriseId'] = null;
// 设置默认值
$data['sort'] = $data['sort'] ?? 0;
$data['status'] = $data['status'] ?? 1;
// 创建题目
$question = QuestionModel::create($data);
return success($question->toArray(), '创建成功');
}
/**
* 更新题目
* @param int $id
* @return \think\response\Json
*/
public function update($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$question = QuestionModel::where('id', $id)
->where('enterpriseId', null) // 只能更新超管题库
->find();
if (!$question) {
return error('题目不存在', 404);
}
$data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']);
// 验证类型
if (isset($data['type']) && !in_array($data['type'], ['mbti', 'disc', 'pdp'])) {
return error('题目类型必须是 mbti、disc 或 pdp', 400);
}
// 验证选项格式
if (isset($data['options']) && !is_array($data['options'])) {
return error('选项必须是数组格式', 400);
}
// MBTI类型需要dimension字段
if (($data['type'] ?? $question->type) === 'mbti' && empty($data['dimension'] ?? $question->dimension)) {
return error('MBTI类型题目必须指定维度EI/SN/TF/JP', 400);
}
// 更新题目
$question->save($data);
return success($question->toArray(), '更新成功');
}
/**
* 删除题目(软删除)
* @param int $id
* @return \think\response\Json
*/
public function delete($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$question = QuestionModel::where('id', $id)
->where('enterpriseId', null) // 只能删除超管题库
->find();
if (!$question) {
return error('题目不存在', 404);
}
// 执行软删除
$question->delete();
return success(null, '删除成功');
}
/**
* 批量导入题目
* @return \think\response\Json
*/
public function batchImport()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$questions = Request::param('questions', []);
if (empty($questions) || !is_array($questions)) {
return error('题目数据不能为空', 400);
}
$successCount = 0;
$failCount = 0;
$errors = [];
Db::startTrans();
try {
foreach ($questions as $index => $q) {
// 验证必填字段
if (empty($q['type']) || empty($q['question']) || empty($q['options'])) {
$failCount++;
$errors[] = "" . ($index + 1) . "题:题目类型、题目内容和选项不能为空";
continue;
}
// 验证类型
if (!in_array($q['type'], ['mbti', 'disc', 'pdp'])) {
$failCount++;
$errors[] = "" . ($index + 1) . "题:题目类型必须是 mbti、disc 或 pdp";
continue;
}
// MBTI类型需要dimension字段
if ($q['type'] === 'mbti' && empty($q['dimension'])) {
$failCount++;
$errors[] = "" . ($index + 1) . "MBTI类型题目必须指定维度";
continue;
}
// 设置超管题库标识
$q['enterpriseId'] = null;
$q['sort'] = $q['sort'] ?? ($index + 1);
$q['status'] = $q['status'] ?? 1;
QuestionModel::create($q);
$successCount++;
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
return error('批量导入失败:' . $e->getMessage(), 500);
}
return success([
'successCount' => $successCount,
'failCount' => $failCount,
'errors' => $errors
], "成功导入 {$successCount} 题,失败 {$failCount}");
}
/**
* 切换题目状态
* @param int $id
* @return \think\response\Json
*/
public function toggleStatus($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$question = QuestionModel::where('id', $id)
->where('enterpriseId', null) // 只能操作超管题库
->find();
if (!$question) {
return error('题目不存在', 404);
}
$question->status = $question->status == 1 ? 0 : 1;
$question->save();
return success($question->toArray(), '状态更新成功');
}
}

View File

@@ -0,0 +1,494 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use think\facade\Request;
use think\facade\Db;
/**
* 系统设置控制器(超管专用)
*/
class Settings extends BaseController
{
/**
* 获取系统配置
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
// 获取系统配置(全局 enterprise_id=0
$systemConfig = SystemConfigModel::where('key', 'system')->where('enterprise_id', 0)->find();
$notificationConfig = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find();
$promptsConfig = SystemConfigModel::where('key', 'prompts')->where('enterprise_id', 0)->find();
$reportRequiresPaymentConfig = SystemConfigModel::where('key', 'report_requires_payment')->where('enterprise_id', 0)->find();
$textConfigModel = SystemConfigModel::where('key', 'text_config')->where('enterprise_id', 0)->find();
// 获取当前超管用户名直接使用JWT中的username
$jwtUsername = $user['username'] ?? null;
$username = 'admin';
if ($jwtUsername) {
$currentUser = UserModel::where('username', $jwtUsername)
->where('role', 'superadmin')
->find();
if ($currentUser) {
$username = $currentUser->username;
} else {
// 如果找不到用户使用JWT中的username
$username = $jwtUsername;
}
}
return success([
'system' => $systemConfig ? $systemConfig->value : [
'siteName' => '神仙团队AI性格测试',
'siteDescription' => '专业的AI性格测试平台',
'miniprogramName' => '神仙团队AI性格测试',
'maintenanceMode' => false,
'maxTestsPerDay' => 100,
'trialTestCount' => 10
],
'notification' => $notificationConfig ? $notificationConfig->value : [
'emailNotification' => true,
'lowBalanceAlert' => true,
'lowBalanceThreshold' => 1000,
'newEnterpriseNotify' => true
],
'prompts' => $promptsConfig && !empty($promptsConfig->value) ? $promptsConfig->value : [
'faceAnalyze' => '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}',
'reportSummary' => ''
],
'reportRequiresPayment' => $reportRequiresPaymentConfig && !empty($reportRequiresPaymentConfig->value) ? $reportRequiresPaymentConfig->value : ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0],
'textConfig' => $textConfigModel && !empty($textConfigModel->value) ? $textConfigModel->value : [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析'
],
'username' => $username
]);
} catch (\Exception $e) {
return error('获取配置失败:' . $e->getMessage(), 500);
}
}
/**
* 更新系统配置
* @return \think\response\Json
*/
public function updateSystem()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 前端 axios 发 JSON body用 getContent 解析更可靠
$input = json_decode($this->request->getContent(), true);
if (!is_array($input)) {
$input = [];
}
$allowedKeys = ['siteName', 'siteDescription', 'miniprogramName', 'maintenanceMode', 'maxTestsPerDay', 'trialTestCount'];
$data = array_intersect_key($input, array_flip($allowedKeys));
// 兼容 fallbackJSON 解析失败时尝试 Request::only
if (empty($data)) {
$data = Request::only($allowedKeys);
}
$textConfig = $input['textConfig'] ?? (Request::param('textConfig') ?: []);
try {
// 查找或创建全局配置enterprise_id=0
$config = SystemConfigModel::where('key', 'system')->where('enterprise_id', 0)->find();
if (!$config) {
$config = new SystemConfigModel();
$config->key = 'system';
$config->enterprise_id = 0;
$config->description = '系统基础配置';
}
$config->value = $data;
$config->save();
// 更新站点信息
$this->updateSiteInfo($data);
// 保存全局小程序文案配置enterprise_id=0
if (is_array($textConfig)) {
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcData = array_intersect_key($textConfig, array_flip($tcKeys));
$tcDefaults = ['analyzingTitle' => '正在分析中', 'startButtonText' => '开始面相测试', 'startButtonEnterprise' => '开始面部测试', 'reportTitle' => '分析报告', 'aiAnalysisText' => '智能分析'];
$tcConfig = SystemConfigModel::where('key', 'text_config')->where('enterprise_id', 0)->find();
if (!$tcConfig) {
$tcConfig = new SystemConfigModel();
$tcConfig->key = 'text_config';
$tcConfig->enterprise_id = 0;
$tcConfig->description = '小程序文案配置(全局)';
}
$tcConfig->value = array_merge($tcDefaults, $tcData);
$tcConfig->save();
}
return success($config->value, '系统配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新「报告需付费」配置:哪些测试类型需付费后才显示完整报告
* PUT body: { "face": 1, "mbti": 0, "disc": 0, "pdp": 0 }1=需付费解锁完整0=免费完整)
* @return \think\response\Json
*/
public function updateReportRequiresPayment()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::param('reportRequiresPayment', Request::param('data', []));
if (!is_array($data)) {
return error('配置格式错误', 400);
}
$defaults = ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0];
$value = array_merge($defaults, array_intersect_key($data, array_flip(['face', 'mbti', 'disc', 'pdp'])));
$value = array_map(function ($v) { return (int) $v ? 1 : 0; }, $value);
try {
$config = SystemConfigModel::where('key', 'report_requires_payment')->where('enterprise_id', 0)->find();
if (!$config) {
$config = new SystemConfigModel();
$config->key = 'report_requires_payment';
$config->enterprise_id = 0;
$config->description = '哪些测试类型需付费后才显示完整报告:1需付费0免费';
}
$config->value = $value;
$config->save();
return success($config->value, '报告付费开关已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新提示词配置
* @return \think\response\Json
*/
public function updatePrompts()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::param('prompts', []);
if (!is_array($data)) {
return error('提示词配置格式错误', 400);
}
try {
$config = SystemConfigModel::where('key', 'prompts')->where('enterprise_id', 0)->find();
if (!$config) {
$config = new SystemConfigModel();
$config->key = 'prompts';
$config->enterprise_id = 0;
$config->description = '系统提示词配置(如面相分析、企业简历等)';
}
$config->value = $data;
$config->save();
return success($config->value, '提示词配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新通知配置
* @return \think\response\Json
*/
public function updateNotification()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::only([
'emailNotification', 'lowBalanceAlert',
'lowBalanceThreshold', 'newEnterpriseNotify'
]);
try {
$config = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find();
if (!$config) {
$config = new SystemConfigModel();
$config->key = 'notification';
$config->enterprise_id = 0;
$config->description = '通知与告警配置';
}
$config->value = $data;
$config->save();
return success($config->value, '通知配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 更新超管账户信息
* @return \think\response\Json
*/
public function updateCredentials()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
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)) {
\think\facade\Log::error('JWT中缺少username', [
'user' => $user,
'requestUserId' => $this->request->userId ?? null
]);
return error('无法获取用户信息,请重新登录', 400);
}
// 直接通过username查找用户
$userModel = UserModel::where('username', $jwtUsername)
->where('role', 'superadmin')
->find();
if (!$userModel) {
// 添加调试信息
\think\facade\Log::error('用户不存在', [
'jwtUsername' => $jwtUsername,
'user' => $user,
'requestUserId' => $this->request->userId ?? null,
'requestUsername' => $username
]);
return error('用户不存在,请检查登录状态', 404);
}
// 验证当前用户是否为超级管理员(双重验证)
if ($userModel->role !== 'superadmin') {
\think\facade\Log::error('用户角色不正确', [
'userId' => $userModel->id,
'role' => $userModel->role
]);
return error('无权限修改此账户', 403);
}
// 如果要修改密码,需要验证当前密码
if (!empty($newPassword)) {
if (empty($currentPassword)) {
return error('请输入当前密码', 400);
}
if ($newPassword !== $confirmPassword) {
return error('两次输入的密码不一致', 400);
}
// 验证当前密码User 模型中 password 字段已是加密值)
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);
}
}
/**
* 获取可用字体列表
* GET /api/v1/superadmin/settings/fonts
*/
public function getFonts()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
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/superadmin/settings/poster
*/
public function getPosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$row = Db::name('system_config')->where('key', 'poster_config')->where('enterprise_id', 0)->find();
$raw = $row['value'] ?? null;
$poster = self::decodeJsonSafe($raw) ?: [
'bgColor' => '#ffffff',
'bgImage' => '',
'elements' => []
];
return success(['poster' => $poster]);
}
/**
* 保存海报配置
* PUT /api/v1/superadmin/settings/poster
*/
public function updatePosterConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
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'] ?? []
];
$jsonValue = json_encode($data, JSON_UNESCAPED_UNICODE);
try {
$now = time();
$exists = Db::name('system_config')->where('key', 'poster_config')->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', 'poster_config')
->where('enterprise_id', 0)
->update(['value' => $jsonValue, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => 'poster_config',
'enterprise_id' => 0,
'value' => $jsonValue,
'description' => '分销海报可视化配置(全局)',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '海报配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);
}
}
/**
* 安全解码 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;
}
/**
* 更新站点信息
* 当系统配置中的siteName或siteDescription修改时同步更新站点信息
*/
private function updateSiteInfo($systemData)
{
try {
$siteConfig = SystemConfigModel::where('key', 'site_info')->where('enterprise_id', 0)->find();
$siteInfo = [
'siteName' => $systemData['siteName'] ?? '',
'siteDescription' => $systemData['siteDescription'] ?? '',
'miniprogramName' => $systemData['miniprogramName'] ?? '',
'updatedAt' => time()
];
if (!$siteConfig) {
$siteConfig = new SystemConfigModel();
$siteConfig->key = 'site_info';
$siteConfig->enterprise_id = 0;
$siteConfig->description = '站点信息配置';
}
$siteConfig->value = $siteInfo;
$siteConfig->save();
// 也可以更新其他相关的配置或缓存
// 例如:清除缓存、更新.env文件等
} catch (\Exception $e) {
// 站点信息更新失败不影响系统配置保存
\think\facade\Log::error('更新站点信息失败:' . $e->getMessage());
}
}
}